mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-03 13:35:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c237c93270 |
No files matched your search
@@ -20,7 +20,18 @@ OCR_LEADER_REGEX = re.compile(r"[._\-\u00b7]{4,}")
|
|||||||
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
|
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
|
||||||
|
|
||||||
|
|
||||||
def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
def get_embedding_model(
|
||||||
|
config: AIConfig,
|
||||||
|
*,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> "BaseEmbedding":
|
||||||
|
"""Build the configured embedding model.
|
||||||
|
|
||||||
|
For the Hugging Face backend, a model already in the local cache is loaded
|
||||||
|
without contacting the Hub, which saves seconds per call. ``refresh=True``
|
||||||
|
checks the Hub for a newer revision first; index rebuilds use it so a new
|
||||||
|
model release is picked up together with re-embedding every document.
|
||||||
|
"""
|
||||||
match config.llm_embedding_backend:
|
match config.llm_embedding_backend:
|
||||||
case LLMEmbeddingBackend.OPENAI_LIKE:
|
case LLMEmbeddingBackend.OPENAI_LIKE:
|
||||||
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
|
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
|
||||||
@@ -52,11 +63,18 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
|||||||
case LLMEmbeddingBackend.HUGGINGFACE:
|
case LLMEmbeddingBackend.HUGGINGFACE:
|
||||||
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
|
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
|
||||||
|
|
||||||
return HuggingFaceEmbedding(
|
hf_kwargs = {
|
||||||
model_name=config.llm_embedding_model
|
"model_name": config.llm_embedding_model
|
||||||
or "sentence-transformers/all-MiniLM-L6-v2",
|
or "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
"cache_folder": str(settings.DATA_DIR / "hf_cache"),
|
||||||
)
|
}
|
||||||
|
if refresh:
|
||||||
|
return HuggingFaceEmbedding(**hf_kwargs)
|
||||||
|
try:
|
||||||
|
return HuggingFaceEmbedding(**hf_kwargs, local_files_only=True)
|
||||||
|
except OSError:
|
||||||
|
# Not cached yet, download it
|
||||||
|
return HuggingFaceEmbedding(**hf_kwargs)
|
||||||
case LLMEmbeddingBackend.OLLAMA:
|
case LLMEmbeddingBackend.OLLAMA:
|
||||||
from llama_index.embeddings.ollama import OllamaEmbedding
|
from llama_index.embeddings.ollama import OllamaEmbedding
|
||||||
from ollama import AsyncClient
|
from ollama import AsyncClient
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ def update_llm_index(
|
|||||||
logger.warning("No documents found to index.")
|
logger.warning("No documents found to index.")
|
||||||
|
|
||||||
chunk_size = config.llm_embedding_chunk_size
|
chunk_size = config.llm_embedding_chunk_size
|
||||||
embed_model = get_embedding_model(config)
|
embed_model = get_embedding_model(config, refresh=rebuild)
|
||||||
|
|
||||||
with write_store(embed_model_name=model_name) as store:
|
with write_store(embed_model_name=model_name) as store:
|
||||||
if rebuild or not store.table_exists():
|
if rebuild or not store.table_exists():
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ def test_get_embedding_model_huggingface(mock_ai_config):
|
|||||||
MockHuggingFaceEmbedding.assert_called_once_with(
|
MockHuggingFaceEmbedding.assert_called_once_with(
|
||||||
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
||||||
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
||||||
|
local_files_only=True,
|
||||||
)
|
)
|
||||||
assert model == MockHuggingFaceEmbedding.return_value
|
assert model == MockHuggingFaceEmbedding.return_value
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from unittest.mock import call
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_mock
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from paperless.models import LLMEmbeddingBackend
|
||||||
|
from paperless_ai import indexing
|
||||||
|
from paperless_ai.embedding import get_embedding_model
|
||||||
|
from paperless_ai.tests.conftest import FakeEmbedding
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
|
||||||
|
MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
||||||
|
CACHE = str(settings.DATA_DIR / "hf_cache")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hf_config() -> MagicMock:
|
||||||
|
config = MagicMock()
|
||||||
|
config.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
|
||||||
|
config.llm_embedding_model = MODEL
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hf_embedding(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
||||||
|
return mocker.patch("llama_index.embeddings.huggingface.HuggingFaceEmbedding")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHuggingFaceLocalFirst:
|
||||||
|
def test_uses_cached_model_without_hub(
|
||||||
|
self,
|
||||||
|
hf_config: MagicMock,
|
||||||
|
hf_embedding: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
model = get_embedding_model(hf_config)
|
||||||
|
|
||||||
|
hf_embedding.assert_called_once_with(
|
||||||
|
model_name=MODEL,
|
||||||
|
cache_folder=CACHE,
|
||||||
|
local_files_only=True,
|
||||||
|
)
|
||||||
|
assert model is hf_embedding.return_value
|
||||||
|
|
||||||
|
def test_downloads_when_not_cached(
|
||||||
|
self,
|
||||||
|
hf_config: MagicMock,
|
||||||
|
hf_embedding: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
downloaded = MagicMock()
|
||||||
|
hf_embedding.side_effect = [OSError("not cached"), downloaded]
|
||||||
|
|
||||||
|
model = get_embedding_model(hf_config)
|
||||||
|
|
||||||
|
assert hf_embedding.call_args_list == [
|
||||||
|
call(model_name=MODEL, cache_folder=CACHE, local_files_only=True),
|
||||||
|
call(model_name=MODEL, cache_folder=CACHE),
|
||||||
|
]
|
||||||
|
assert model is downloaded
|
||||||
|
|
||||||
|
def test_refresh_checks_the_hub(
|
||||||
|
self,
|
||||||
|
hf_config: MagicMock,
|
||||||
|
hf_embedding: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
get_embedding_model(hf_config, refresh=True)
|
||||||
|
|
||||||
|
hf_embedding.assert_called_once_with(model_name=MODEL, cache_folder=CACHE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestIndexUpdateRefresh:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _index_inputs(self, temp_llm_index_dir: Path) -> None:
|
||||||
|
DocumentFactory.create()
|
||||||
|
|
||||||
|
def _update(self, mock_embed_model: FakeEmbedding, *, rebuild: bool) -> MagicMock:
|
||||||
|
mock_config = MagicMock()
|
||||||
|
mock_config.llm_embedding_chunk_size = 512
|
||||||
|
with (
|
||||||
|
patch("paperless_ai.indexing.AIConfig", return_value=mock_config),
|
||||||
|
patch("paperless_ai.indexing.build_document_node", return_value=[]),
|
||||||
|
patch(
|
||||||
|
"paperless_ai.indexing.get_embedding_model",
|
||||||
|
return_value=mock_embed_model,
|
||||||
|
) as get_model,
|
||||||
|
):
|
||||||
|
indexing.update_llm_index(rebuild=rebuild)
|
||||||
|
get_model.assert_called_once()
|
||||||
|
return get_model
|
||||||
|
|
||||||
|
def test_rebuild_refreshes(self, mock_embed_model: FakeEmbedding) -> None:
|
||||||
|
get_model = self._update(mock_embed_model, rebuild=True)
|
||||||
|
|
||||||
|
assert get_model.call_args.kwargs == {"refresh": True}
|
||||||
|
|
||||||
|
def test_incremental_update_stays_local(
|
||||||
|
self,
|
||||||
|
mock_embed_model: FakeEmbedding,
|
||||||
|
) -> None:
|
||||||
|
get_model = self._update(mock_embed_model, rebuild=False)
|
||||||
|
|
||||||
|
assert get_model.call_args.kwargs == {"refresh": False}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from llama_index.core.schema import TextNode
|
|
||||||
|
|
||||||
from paperless_ai.tables import DocumentChunksTable
|
|
||||||
from paperless_ai.vector_store import DB_FILENAME
|
|
||||||
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
|
|
||||||
from paperless_ai.vector_store import VEC0_CHUNK_SIZE
|
|
||||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
|
||||||
|
|
||||||
DIM = 16
|
|
||||||
|
|
||||||
|
|
||||||
def make_node(node_id: str, document_id: int) -> TextNode:
|
|
||||||
node = TextNode(
|
|
||||||
id_=node_id,
|
|
||||||
text="some text",
|
|
||||||
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
|
|
||||||
)
|
|
||||||
node.relationships = {}
|
|
||||||
node.embedding = [document_id + i / 100 for i in range(DIM)]
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def vec_table_sql(db_path: Path) -> str:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
return conn.execute(
|
|
||||||
"SELECT sql FROM sqlite_master WHERE name = ?",
|
|
||||||
(DEFAULT_TABLE_NAME,),
|
|
||||||
).fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def create_legacy_vec_table(conn: sqlite3.Connection, dim: int) -> None:
|
|
||||||
"""The table definition from before chunk_size was set explicitly."""
|
|
||||||
conn.execute(
|
|
||||||
f"CREATE VIRTUAL TABLE {DEFAULT_TABLE_NAME} USING vec0("
|
|
||||||
"id TEXT PRIMARY KEY, document_id INTEGER, +node_content TEXT, "
|
|
||||||
f"embedding float[{dim}] distance_metric=cosine)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
|
|
||||||
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
|
|
||||||
yield store
|
|
||||||
|
|
||||||
|
|
||||||
class TestVecChunkSize:
|
|
||||||
def test_new_table_uses_configured_chunk_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an empty store
|
|
||||||
WHEN the first document is indexed and the vec0 table is created
|
|
||||||
THEN the table definition carries the configured chunk size
|
|
||||||
"""
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1)])
|
|
||||||
|
|
||||||
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
|
|
||||||
def test_compact_moves_legacy_table_to_configured_chunk_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an index whose vec0 table was created with the default chunk size
|
|
||||||
WHEN the index is compacted
|
|
||||||
THEN the rebuilt table uses the configured chunk size
|
|
||||||
AND no indexed chunks are lost
|
|
||||||
"""
|
|
||||||
with patch.object(
|
|
||||||
PaperlessSqliteVecVectorStore,
|
|
||||||
"_create_vec_table",
|
|
||||||
staticmethod(create_legacy_vec_table),
|
|
||||||
):
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
|
|
||||||
store.upsert_document(2, [make_node("2-a", 2)])
|
|
||||||
assert "chunk_size" not in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
|
|
||||||
store.compact(force=True)
|
|
||||||
|
|
||||||
assert f"chunk_size={VEC0_CHUNK_SIZE}" in vec_table_sql(tmp_path / DB_FILENAME)
|
|
||||||
assert DocumentChunksTable.count(store.client) == 3
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from collections.abc import Generator
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from llama_index.core.schema import TextNode
|
|
||||||
|
|
||||||
from paperless_ai.tables import DocumentChunksTable
|
|
||||||
from paperless_ai.vector_store import DB_FILENAME
|
|
||||||
from paperless_ai.vector_store import DB_PAGE_SIZE
|
|
||||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
|
||||||
|
|
||||||
DIM = 16
|
|
||||||
LEGACY_PAGE_SIZE = 4096
|
|
||||||
|
|
||||||
|
|
||||||
def make_node(node_id: str, document_id: int) -> TextNode:
|
|
||||||
node = TextNode(
|
|
||||||
id_=node_id,
|
|
||||||
text="some text",
|
|
||||||
metadata={"document_id": document_id, "modified": "2026-06-10T00:00:00"},
|
|
||||||
)
|
|
||||||
node.relationships = {}
|
|
||||||
node.embedding = [document_id + i / 100 for i in range(DIM)]
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def page_size_of(db_path: Path) -> int:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
return conn.execute("PRAGMA page_size").fetchone()[0]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def legacy_page_size_db(tmp_path: Path) -> Path:
|
|
||||||
"""An existing index file created before the page size was set."""
|
|
||||||
db_path = tmp_path / DB_FILENAME
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute(f"PRAGMA page_size={LEGACY_PAGE_SIZE}")
|
|
||||||
conn.execute("CREATE TABLE _seed(x)")
|
|
||||||
conn.execute("DROP TABLE _seed")
|
|
||||||
conn.close()
|
|
||||||
assert page_size_of(db_path) == LEGACY_PAGE_SIZE
|
|
||||||
return db_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def store(tmp_path: Path) -> Generator[PaperlessSqliteVecVectorStore, None, None]:
|
|
||||||
with PaperlessSqliteVecVectorStore(uri=str(tmp_path)) as store:
|
|
||||||
yield store
|
|
||||||
|
|
||||||
|
|
||||||
class TestIndexPageSize:
|
|
||||||
def test_new_index_file_uses_configured_page_size(
|
|
||||||
self,
|
|
||||||
store: PaperlessSqliteVecVectorStore,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN no existing index file
|
|
||||||
WHEN a store is opened and a document is indexed
|
|
||||||
THEN the database file uses the configured page size
|
|
||||||
"""
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1)])
|
|
||||||
|
|
||||||
assert page_size_of(tmp_path / DB_FILENAME) == DB_PAGE_SIZE
|
|
||||||
|
|
||||||
def test_compact_moves_legacy_file_to_configured_page_size(
|
|
||||||
self,
|
|
||||||
legacy_page_size_db: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN an existing index file created with a different page size
|
|
||||||
WHEN the index is compacted
|
|
||||||
THEN the rebuilt file uses the configured page size
|
|
||||||
AND no indexed chunks are lost
|
|
||||||
"""
|
|
||||||
with PaperlessSqliteVecVectorStore(
|
|
||||||
uri=str(legacy_page_size_db.parent),
|
|
||||||
) as store:
|
|
||||||
store.upsert_document(1, [make_node("1-a", 1), make_node("1-b", 1)])
|
|
||||||
assert page_size_of(legacy_page_size_db) == LEGACY_PAGE_SIZE
|
|
||||||
|
|
||||||
store.compact(force=True)
|
|
||||||
|
|
||||||
assert page_size_of(legacy_page_size_db) == DB_PAGE_SIZE
|
|
||||||
assert DocumentChunksTable.count(store.client) == 2
|
|
||||||
@@ -47,20 +47,6 @@ SCHEMA_VERSION = 2
|
|||||||
# a rebuild copies the live rows into a fresh table.
|
# a rebuild copies the live rows into a fresh table.
|
||||||
COMPACT_BLOAT_RATIO = 2.0
|
COMPACT_BLOAT_RATIO = 2.0
|
||||||
|
|
||||||
# Rows per vec0 vector chunk (vec0's default is 1024, which makes each chunk a
|
|
||||||
# multi-megabyte blob). On spinning disks, inserts and re-indexing were dozens
|
|
||||||
# of times faster with small chunks, at no cost in query speed or file size.
|
|
||||||
# Only applies to tables created from here on; an existing index picks it up
|
|
||||||
# the next time it is rebuilt (compact or rebuild).
|
|
||||||
VEC0_CHUNK_SIZE = 32
|
|
||||||
|
|
||||||
# SQLite page size for the index file, in bytes. With the small vec0 chunks above,
|
|
||||||
# 64 KiB pages made cold scans about 35% faster and warm ones about 45% faster,
|
|
||||||
# at the cost of a larger write-ahead log (up to about 70 MB). Only takes effect
|
|
||||||
# on a newly created file, so like the chunk size it reaches an existing index
|
|
||||||
# when it is rebuilt.
|
|
||||||
DB_PAGE_SIZE = 65536
|
|
||||||
|
|
||||||
# Number of rows fetched/copied per batch whenever this module streams rows
|
# Number of rows fetched/copied per batch whenever this module streams rows
|
||||||
# instead of materializing them all at once, keeping memory bounded regardless
|
# instead of materializing them all at once, keeping memory bounded regardless
|
||||||
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
|
# of index size -- used by compact()'s rebuild, m0001_v1_to_v2's migration
|
||||||
@@ -236,7 +222,6 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
|
|||||||
conn.enable_load_extension(True) # noqa: FBT003
|
conn.enable_load_extension(True) # noqa: FBT003
|
||||||
sqlite_vec.load(conn)
|
sqlite_vec.load(conn)
|
||||||
conn.enable_load_extension(False) # noqa: FBT003
|
conn.enable_load_extension(False) # noqa: FBT003
|
||||||
conn.execute(f"PRAGMA page_size={DB_PAGE_SIZE}")
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
IndexMetaTable.create(conn)
|
IndexMetaTable.create(conn)
|
||||||
@@ -344,8 +329,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
|
|||||||
+ " +node_content TEXT,"
|
+ " +node_content TEXT,"
|
||||||
+ " embedding float["
|
+ " embedding float["
|
||||||
+ str(int(dim))
|
+ str(int(dim))
|
||||||
+ "] distance_metric=cosine, chunk_size="
|
+ "] distance_metric=cosine"
|
||||||
+ str(VEC0_CHUNK_SIZE)
|
|
||||||
+ ")",
|
+ ")",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user