Compare commits

..
Author SHA1 Message Date
Crowdin Bot 5d44bec918 New Crowdin translations by GitHub Action 2026-11-12 12:18:33 +00:00
99 changed files with 32389 additions and 30028 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -26
View File
@@ -46,16 +46,6 @@ def _build_document_reference(
} }
def _permitted_documents_filter(store, permitted_ids: set[int]):
"""Scope retrieval to ``permitted_ids`` using whichever of an inclusion or
exclusion list is shorter, since a long inclusion list is much slower for
the vector store than a long exclusion list."""
denied_ids = store.indexed_document_ids() - permitted_ids
if len(permitted_ids) <= len(denied_ids):
return document_id_filters(str(pk) for pk in permitted_ids)
return exclude_document_ids_filter(str(pk) for pk in denied_ids)
def _get_document_references( def _get_document_references(
documents: QuerySet[Document], documents: QuerySet[Document],
top_nodes: list, top_nodes: list,
@@ -138,23 +128,24 @@ def _stream_chat_with_documents(
from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.retrievers import VectorIndexRetriever
config = AIConfig() config = AIConfig()
permitted_ids: set[int] | None = None
if unrestricted: if unrestricted:
# Exclude trashed ids (usually few) rather than send every permitted # Exclude trashed ids (usually few) instead of an IN filter over the
# id on each query. Trashed documents stay indexed until permanent # full permitted set, which risks the vector store's bound parameter
# deletion (post_delete, not trash), so must be excluded explicitly. # limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
# indexed until permanent deletion (delete_document_from_llm_index
# hangs off post_delete, not trash), so must be excluded explicitly.
trashed_ids = Document.deleted_objects.values_list("pk", flat=True) trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids) filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
else: else:
permitted_ids = set(documents.values_list("pk", flat=True)) filters = document_id_filters(
str(pk) for pk in documents.values_list("pk", flat=True)
)
# Hold the shared read lock only while reading the index, so the compaction # Hold the shared read lock only while reading the index, so the compaction
# swap cannot replace the database file while this connection is open. # swap cannot replace the database file while this connection is open.
# Everything after retrieval works on the nodes already in memory, so a slow # Everything after retrieval works on the nodes already in memory, so a slow
# LLM stream never holds the lock. # LLM stream never holds the lock.
with read_store() as store: with read_store() as store:
if permitted_ids is not None:
filters = _permitted_documents_filter(store, permitted_ids)
index = load_or_build_index(config, store) index = load_or_build_index(config, store)
retriever = VectorIndexRetriever( retriever = VectorIndexRetriever(
index=index, index=index,
@@ -168,15 +159,6 @@ def _stream_chat_with_documents(
with db_connection_released(): with db_connection_released():
top_nodes = retriever.retrieve(query_str) top_nodes = retriever.retrieve(query_str)
if permitted_ids is not None:
# An exclusion filter fails open for a document indexed after the
# permitted set was read, so drop anything not explicitly permitted.
top_nodes = [
node
for node in top_nodes
if int(node.metadata["document_id"]) in permitted_ids
]
if not top_nodes: if not top_nodes:
logger.warning("No nodes found for the given documents.") logger.warning("No nodes found for the given documents.")
yield CHAT_NO_CONTENT_MESSAGE yield CHAT_NO_CONTENT_MESSAGE
-9
View File
@@ -141,15 +141,6 @@ class DocumentMetaTable:
(DocumentMetaRow(r["document_id"], r["modified"]) for r in batch), (DocumentMetaRow(r["document_id"], r["modified"]) for r in batch),
) )
@staticmethod
def all_document_ids(conn: sqlite3.Connection) -> set[int]:
"""Every indexed document_id. document_meta has one row per document,
so this is a cheap primary-key scan, unlike reading the vec0 table."""
return {
row["document_id"]
for row in conn.execute("SELECT document_id FROM document_meta")
}
@staticmethod @staticmethod
def all_modified_times(conn: sqlite3.Connection) -> dict[str, str]: def all_modified_times(conn: sqlite3.Connection) -> dict[str, str]:
"""Full document_id -> modified map, for get_modified_times()'s """Full document_id -> modified map, for get_modified_times()'s
+4 -67
View File
@@ -13,7 +13,6 @@ from django.utils import timezone
from llama_index.core import settings as llama_settings from llama_index.core import settings as llama_settings
from llama_index.core.embeddings.mock_embed_model import MockEmbedding from llama_index.core.embeddings.mock_embed_model import MockEmbedding
from llama_index.core.schema import TextNode from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.types import FilterOperator
from documents.models import Document from documents.models import Document
from paperless_ai import chat from paperless_ai import chat
@@ -413,8 +412,10 @@ class TestStreamChatRetrieval:
WHEN: WHEN:
- stream_chat_with_documents is called with unrestricted=True - stream_chat_with_documents is called with unrestricted=True
THEN: THEN:
- The retriever receives a NOT IN filter excluding zero ids - The retriever receives a NOT IN filter excluding zero ids, so
rather than an IN list of every permitted id the whole index is effectively searched -- and no IN-list is
built from the full permitted set, which is what risks the
vector store's safety limit on large installs
""" """
document = DocumentFactory.create(content="indexed document content") document = DocumentFactory.create(content="indexed document content")
indexing.llm_index_add_or_update_document(document) indexing.llm_index_add_or_update_document(document)
@@ -467,70 +468,6 @@ class TestStreamChatRetrieval:
assert str(trashed.pk) in filter_values assert str(trashed.pk) in filter_values
assert str(kept.pk) not in filter_values assert str(kept.pk) not in filter_values
def test_restricted_chat_excludes_denied_ids_when_few_are_denied(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
captured_filters: list[Any],
) -> None:
"""
GIVEN:
- Three indexed documents, one of which the caller may not see
WHEN:
- A restricted caller chats
THEN:
- The retriever receives a NOT IN filter on the denied id, not an
IN list of the permitted ids
"""
docs = DocumentFactory.create_batch(3, content="indexed document content")
for doc in docs:
indexing.llm_index_add_or_update_document(doc)
permitted = Document.objects.filter(pk__in=[docs[0].pk, docs[1].pk])
list(chat.stream_chat_with_documents("question?", permitted))
metadata_filter = captured_filters[0].filters[0]
assert metadata_filter.operator == FilterOperator.NIN
assert metadata_filter.value == [str(docs[2].pk)]
def test_restricted_chat_drops_retrieved_nodes_of_unpermitted_documents(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- A retriever that returns a node for a document the caller may
not see, as when it is indexed after the permitted set was read
WHEN:
- A restricted caller chats
THEN:
- Only the node of the permitted document reaches the synthesizer
"""
permitted_doc = DocumentFactory.create(content="permitted content")
denied_doc = DocumentFactory.create(content="denied content")
permitted_node = MagicMock(metadata={"document_id": str(permitted_doc.pk)})
denied_node = MagicMock(metadata={"document_id": str(denied_doc.pk)})
mocker.patch("paperless_ai.chat.AIClient")
retriever = mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
).return_value
retriever.retrieve.return_value = [denied_node, permitted_node]
synthesizer = mocker.patch(
"llama_index.core.response_synthesizers.get_response_synthesizer",
).return_value
synthesizer.synthesize.return_value.response_gen = iter(["chunk"])
list(
chat.stream_chat_with_documents(
"question?",
Document.objects.filter(pk=permitted_doc.pk),
),
)
synthesizer.synthesize.assert_called_once_with("question?", [permitted_node])
@pytest.mark.django_db @pytest.mark.django_db
def test_get_document_references_only_queries_referenced_documents( def test_get_document_references_only_queries_referenced_documents(
self, self,
+21 -23
View File
@@ -18,7 +18,7 @@ from paperless_ai.migrations import Migration
from paperless_ai.migrations import m0001_v1_to_v2 from paperless_ai.migrations import m0001_v1_to_v2
from paperless_ai.tables import DocumentChunksTable from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.vector_store import _MAX_INLINE_IN_VALUES from paperless_ai.vector_store import _MAX_IN_VALUES
from paperless_ai.vector_store import DB_FILENAME from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DB_PAGE_SIZE from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import DEFAULT_TABLE_NAME from paperless_ai.vector_store import DEFAULT_TABLE_NAME
@@ -354,39 +354,37 @@ class TestBuildWhere:
assert params == [] assert params == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
("build_filter", "expected_ids"), "build_filter",
[ [_in_filter, _nin_filter],
pytest.param(_in_filter, ["a", "c"], id="in"), ids=["in", "nin"],
pytest.param(_nin_filter, ["b"], id="nin"),
],
) )
def test_query_with_oversized_filter_scopes_results( def test_fails_closed_when_filter_exceeds_max_values(
self, self,
store: PaperlessSqliteVecVectorStore,
build_filter: Callable[[list[str]], MetadataFilters], build_filter: Callable[[list[str]], MetadataFilters],
expected_ids: list[str], caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
- A store holding documents 1, 2 and 40000 - An IN or NOT IN filter with more values than _MAX_IN_VALUES
- An IN or NOT IN filter naming documents 1 and 40000, padded (SQLite's own bound-parameter limit is 32766; this guard sits
past _MAX_INLINE_IN_VALUES with unindexed ids below that with headroom for the query's other bound parameters)
WHEN: WHEN:
- The store is queried and get_nodes() is called with that filter - _build_where() translates it to SQL
THEN: THEN:
- Both return exactly the documents the filter allows - It fails closed ("1 = 0", no params) instead of building a
clause SQLite would reject, and logs a warning -- this filter
scopes document access, so refusing to build it must never
widen the scope to "everything" by accident. Failing open on
a NOT IN would surface exactly the excluded rows
""" """
store.add( oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
[make_node("a", 1), make_node("b", 2), make_node("c", 40000)],
)
padding = range(100, 100 + _MAX_INLINE_IN_VALUES)
filters = build_filter([str(i) for i in (1, 40000, *padding)])
result = _query(store, [0.0] * DIM, top_k=5, filters=filters) with caplog.at_level("WARNING"):
nodes = store.get_nodes(filters=filters) where, params = _build_where(oversized)
assert sorted(result.ids) == expected_ids assert where == "(1 = 0)"
assert sorted(n.node_id for n in nodes) == expected_ids assert params == []
assert "document_id" in caplog.text
def test_query_with_untranslatable_filter_returns_no_rows( def test_query_with_untranslatable_filter_returns_no_rows(
self, self,
+25 -16
View File
@@ -89,10 +89,15 @@ class _Row(NamedTuple):
embedding: bytes embedding: bytes
# _build_where(): the largest IN / NOT IN list bound as one parameter per value, # _build_where(): the largest IN value list translated into bound SQL
# leaving headroom under SQLite's 32766-variable limit for the query's other # parameters. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER) is 32766
# parameters. Longer lists are bound as a single JSON array via json_each(). # by default; this leaves headroom below that for the query's other bound
_MAX_INLINE_IN_VALUES = 32700 # parameters (the embedding blob, k, and any NE clause) and for the limit
# itself to move. An IN filter this large should not happen in practice --
# callers are expected to pass None (no filter) rather than every id when
# the filter would not actually narrow anything -- so this is a guard
# against a future regression, not a normal code path.
_MAX_IN_VALUES = 32700
def _pack(embedding: Sequence[float]) -> bytes: def _pack(embedding: Sequence[float]) -> bytes:
@@ -115,7 +120,7 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
return [(r.chunk_id, r.document_id, r.node_content, r.embedding) for r in rows] return [(r.chunk_id, r.document_id, r.node_content, r.embedding) for r in rows]
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int | str]]: def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized """Translate the EQ / IN / NIN / NE filters we use into a parameterized
SQL clause on vec0 metadata columns. Returns ("", []) when there is SQL clause on vec0 metadata columns. Returns ("", []) when there is
nothing to filter. document_id is vec0's only filterable column and is nothing to filter. document_id is vec0's only filterable column and is
@@ -127,7 +132,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int | str]]
if filters is None or not filters.filters: if filters is None or not filters.filters:
return "", [] return "", []
clauses: list[str] = [] clauses: list[str] = []
params: list[int | str] = [] params: list[int] = []
for f in filters.filters: for f in filters.filters:
# filters.filters is Union[MetadataFilter, ExactMatchFilter, MetadataFilters]; # filters.filters is Union[MetadataFilter, ExactMatchFilter, MetadataFilters];
# we only build MetadataFilter entries, so skip anything else at runtime. # we only build MetadataFilter entries, so skip anything else at runtime.
@@ -144,11 +149,21 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int | str]]
# excludes nothing, so it matches everything. # excludes nothing, so it matches everything.
clauses.append("1 = 0" if is_in else "1 = 1") clauses.append("1 = 0" if is_in else "1 = 1")
continue continue
if len(values) > _MAX_INLINE_IN_VALUES: if len(values) > _MAX_IN_VALUES:
clauses.append( # Refuse rather than risk SQLite's own bound-parameter limit
f"{f.key} {sql_op} (SELECT value FROM json_each(?))", # ("too many SQL variables"): a list this large must match no
# rows, never widen the scope to "everything" -- true for
# NOT IN too, where failing open would surface every
# excluded row.
logger.warning(
"Refusing to build a %s filter on %r with %d values "
"(over the %d-value safety limit); returning no rows.",
sql_op,
f.key,
len(values),
_MAX_IN_VALUES,
) )
params.append(json.dumps(values)) clauses.append("1 = 0")
continue continue
placeholders = ",".join("?" for _ in values) placeholders = ",".join("?" for _ in values)
clauses.append(f"{f.key} {sql_op} ({placeholders})") clauses.append(f"{f.key} {sql_op} ({placeholders})")
@@ -543,12 +558,6 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
return {} return {}
return DocumentMetaTable.all_modified_times(self._conn) return DocumentMetaTable.all_modified_times(self._conn)
def indexed_document_ids(self) -> set[int]:
"""Return the ids of all indexed documents."""
if not self.table_exists():
return set()
return DocumentMetaTable.all_document_ids(self._conn)
@property @property
def _db_path(self) -> str: def _db_path(self) -> str:
return str(Path(self._uri) / DB_FILENAME) return str(Path(self._uri) / DB_FILENAME)