Compare commits

...
Author SHA1 Message Date
stumpylog 2f440cade2 Handles the rebase from the trash changes 2026-09-02 10:11:23 -07:00
stumpylog 61397749b7 If the filter will exceed SQLite IN limits, load to a temporary table and use a subquery to filter instead 2026-09-02 10:07:13 -07:00
stumpylog 3666234f51 When a user is unrestricted chatting, still exclude trashed documents using a 'NOT IN' SQL statement. Wire that up where we need it 2026-09-02 10:00:36 -07:00
stumpylog ec7745e71f Minor improvements from a Claude review 2026-09-02 08:14:41 -07:00
stumpylog 98e87d91ad Fix: skip vector store document id filter for unrestricted chat users
ChatStreamingView built an IN filter from every permitted document id
for the "chat over all documents" case, which exceeds the vector
store's SQLite bound-parameter safety limit on installs with more
than ~32700 documents, silently returning no context. For a user who
can see every document (an active superuser), that filter never
narrows anything, so skip it and let the retriever search the whole
index instead.
2026-09-02 08:14:23 -07:00
10 changed files with 542 additions and 82 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
isort.force-single-line = true isort.force-single-line = true
[tool.codespell] [tool.codespell]
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
skip = """\ skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\ src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/mail/*,src/documents/tests/samples/*,*.po,*.json\ /mail/*,src/documents/tests/samples/*,*.po,*.json\
+77
View File
@@ -1,11 +1,18 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from unittest import mock from unittest import mock
import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
if TYPE_CHECKING:
from pytest_mock import MockerFixture
class TestChatStreamingViewInputValidation(APITestCase): class TestChatStreamingViewInputValidation(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -42,3 +49,73 @@ class TestChatStreamingViewInputValidation(APITestCase):
format="json", format="json",
) )
assert resp.status_code == status.HTTP_400_BAD_REQUEST assert resp.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestChatStreamingViewUnrestrictedFlag:
"""The document id filter may only be skipped (``unrestricted=True``) for
an active superuser, never for a regular user -- regardless of what
permissions that user holds.
"""
@pytest.fixture
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
"""AI enabled, with stream_chat_with_documents patched so the view
never touches the real vector store; returns the patched callable so
tests can inspect how it was called.
"""
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
return mocker.patch(
"documents.views.stream_chat_with_documents",
return_value=iter(()),
)
@pytest.fixture
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
"""The conftest regular-user client, granted the global
view_document permission -- the minimum ViewDocumentsPermissions
needs to reach the view at all. Model-level only: says nothing
about which documents (if any) this user can actually see.
"""
regular_user.user_permissions.add(
*Permission.objects.filter(codename="view_document"),
)
return user_client
@pytest.mark.parametrize(
("client_fixture", "expected_unrestricted"),
[
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
],
)
def test_unrestricted_only_for_superuser(
self,
request: pytest.FixtureRequest,
mocked_stream_chat: mock.MagicMock,
client_fixture: str,
*,
expected_unrestricted: bool,
) -> None:
"""
GIVEN:
- A superuser, or a regular user holding the global
view_document permission (but no object-level document access)
WHEN:
- They post a chat question with no document_id
THEN:
- stream_chat_with_documents is called with unrestricted=True
only for the superuser; the regular user is always
unrestricted=False, regardless of their permissions
"""
client: APIClient = request.getfixturevalue(client_fixture)
client.post(
"/api/documents/chat/",
data={"q": "What's in these documents?"},
format="json",
)
assert (
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
)
+4
View File
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.permissions import user_is_unrestricted
from documents.plugins.date_parsing import get_date_parser from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema from documents.schema import generate_object_with_permissions_schema
from documents.search import SearchHit from documents.search import SearchHit
@@ -2329,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
documents = Document.objects.filter(pk=document.pk) documents = Document.objects.filter(pk=document.pk)
unrestricted = False
else: else:
documents = Document.objects.filter( documents = Document.objects.filter(
id__in=permitted_document_ids(request.user), id__in=permitted_document_ids(request.user),
) )
unrestricted = user_is_unrestricted(request.user)
output_language = get_llm_output_language( output_language = get_llm_output_language(
ai_config=ai_config, ai_config=ai_config,
@@ -2343,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
stream_chat_with_documents( stream_chat_with_documents(
query_str=question, query_str=question,
documents=documents, documents=documents,
unrestricted=unrestricted,
output_language=output_language, output_language=output_language,
), ),
content_type="text/event-stream", content_type="text/event-stream",
+21 -4
View File
@@ -8,7 +8,8 @@ from documents.models import Document
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.db import db_connection_released from paperless_ai.db import db_connection_released
from paperless_ai.indexing import _document_id_filters from paperless_ai.indexing import document_id_filters
from paperless_ai.indexing import exclude_document_ids_filter
from paperless_ai.indexing import get_rag_prompt_helper from paperless_ai.indexing import get_rag_prompt_helper
from paperless_ai.indexing import load_or_build_index from paperless_ai.indexing import load_or_build_index
from paperless_ai.indexing import read_store from paperless_ai.indexing import read_store
@@ -95,12 +96,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
def stream_chat_with_documents( def stream_chat_with_documents(
query_str: str, query_str: str,
documents: QuerySet[Document], documents: QuerySet[Document],
*,
unrestricted: bool = False,
output_language: str | None = None, output_language: str | None = None,
): ):
try: try:
yield from _stream_chat_with_documents( yield from _stream_chat_with_documents(
query_str, query_str,
documents, documents,
unrestricted=unrestricted,
output_language=output_language, output_language=output_language,
) )
except Exception as e: except Exception as e:
@@ -111,6 +115,8 @@ def stream_chat_with_documents(
def _stream_chat_with_documents( def _stream_chat_with_documents(
query_str: str, query_str: str,
documents: QuerySet[Document], documents: QuerySet[Document],
*,
unrestricted: bool = False,
output_language: str | None = None, output_language: str | None = None,
): ):
if not documents.exists(): if not documents.exists():
@@ -123,9 +129,20 @@ def _stream_chat_with_documents(
from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.retrievers import VectorIndexRetriever
config = AIConfig() config = AIConfig()
filters = _document_id_filters( if unrestricted:
str(pk) for pk in documents.values_list("pk", flat=True) # Exclude trashed ids (usually few) instead of an IN filter over the
) # full permitted set, which risks the vector store's bound parameter
# 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.global_objects.filter(
deleted_at__isnull=False,
).values_list("pk", flat=True)
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
else:
filters = document_id_filters(
str(pk) for pk in documents.values_list("pk", flat=True)
)
# Hold the shared read lock for the whole operation: the query engine # Hold the shared read lock for the whole operation: the query engine
# retrieves from the vector store again during synthesis, so the connection # retrieves from the vector store again during synthesis, so the connection
+19 -2
View File
@@ -362,7 +362,7 @@ def _embed_nodes(nodes: list["BaseNode"], embed_model) -> None:
node.embedding = emb node.embedding = emb
def _document_id_filters(doc_ids): def document_id_filters(doc_ids):
"""Return a MetadataFilters IN filter scoped to ``doc_ids``.""" """Return a MetadataFilters IN filter scoped to ``doc_ids``."""
from llama_index.core.vector_stores.types import FilterOperator from llama_index.core.vector_stores.types import FilterOperator
from llama_index.core.vector_stores.types import MetadataFilter from llama_index.core.vector_stores.types import MetadataFilter
@@ -396,6 +396,23 @@ def _exclude_document_id_filter(document_id: int | str):
) )
def exclude_document_ids_filter(doc_ids):
"""Return a MetadataFilters NIN filter excluding every id in ``doc_ids``."""
from llama_index.core.vector_stores.types import FilterOperator
from llama_index.core.vector_stores.types import MetadataFilter
from llama_index.core.vector_stores.types import MetadataFilters
return MetadataFilters(
filters=[
MetadataFilter(
key="document_id",
operator=FilterOperator.NIN,
value=list(doc_ids),
),
],
)
def update_llm_index( def update_llm_index(
*, *,
iter_wrapper: IterWrapper[Document] = identity, iter_wrapper: IterWrapper[Document] = identity,
@@ -660,7 +677,7 @@ def retrieve_similar_nodes(
filter_parts = [] filter_parts = []
if allowed_document_ids is not None: if allowed_document_ids is not None:
filter_parts.extend(_document_id_filters(allowed_document_ids).filters) filter_parts.extend(document_id_filters(allowed_document_ids).filters)
if document.pk is not None: if document.pk is not None:
filter_parts.extend(_exclude_document_id_filter(document.pk).filters) filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
+29
View File
@@ -154,6 +154,35 @@ class DocumentMetaTable:
} }
class PermittedIdsTable:
"""Per-connection scratch space for an oversized IN-filter id list.
A literal ``IN (?,?,...)`` list binds one SQL parameter per id, capped by
SQLite's own SQLITE_MAX_VARIABLE_NUMBER (see _MAX_IN_VALUES in
vector_store.py). Loading the ids into a TEMP TABLE and filtering via a
subquery instead has no such limit. TEMP tables live in a
connection-private namespace -- never visible to another connection,
even under this identical name -- so this is safe under the vector
store's one-connection-per-request model without any extra locking or
per-call naming scheme.
"""
TABLE_NAME = "permitted_document_ids"
@staticmethod
def load(conn: sqlite3.Connection, ids: Iterable[int]) -> None:
"""Replace this connection's scratch table with ``ids``."""
conn.execute(f"DROP TABLE IF EXISTS temp.{PermittedIdsTable.TABLE_NAME}")
conn.execute(
f"CREATE TEMP TABLE {PermittedIdsTable.TABLE_NAME} "
"(id INTEGER PRIMARY KEY)",
)
conn.executemany(
f"INSERT INTO {PermittedIdsTable.TABLE_NAME} (id) VALUES (?)",
((i,) for i in ids),
)
class IndexMetaTable: class IndexMetaTable:
"""Typed accessors over index_meta's key/value rows -- replaces """Typed accessors over index_meta's key/value rows -- replaces
PaperlessSqliteVecVectorStore._meta_get_on/_meta_set_on, which returned PaperlessSqliteVecVectorStore._meta_get_on/_meta_set_on, which returned
+114 -23
View File
@@ -1,9 +1,14 @@
from __future__ import annotations
import json import json
from typing import TYPE_CHECKING
from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from django.db.models.signals import post_init from django.db.models.signals import post_init
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
@@ -18,6 +23,11 @@ from paperless_ai.chat import _build_chat_prompt
from paperless_ai.chat import _build_refine_prompt from paperless_ai.chat import _build_refine_prompt
from paperless_ai.chat import stream_chat_with_documents from paperless_ai.chat import stream_chat_with_documents
if TYPE_CHECKING:
from pathlib import Path
import pytest_mock
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_embed_model(): def patch_embed_model():
@@ -310,8 +320,40 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
assert "private provider detail" in caplog.text assert "private provider detail" in caplog.text
def _retriever_filter_values(captured_filters: list[Any]) -> list[str]:
"""The value list of the single MetadataFilter the retriever received."""
assert captured_filters, "VectorIndexRetriever was never constructed"
filt = captured_filters[0]
assert filt is not None, "Retriever must receive a MetadataFilters"
return filt.filters[0].value
@pytest.mark.django_db @pytest.mark.django_db
class TestStreamChatRetrieval: class TestStreamChatRetrieval:
@pytest.fixture
def captured_filters(self, mocker: pytest_mock.MockerFixture) -> list[Any]:
"""Stub out the AI client and the retriever, capturing the ``filters``
kwarg of every VectorIndexRetriever construction.
VectorIndexRetriever is imported inside _stream_chat_with_documents,
so it is patched at the llama_index source for the lazy import to
pick it up.
"""
captured: list[Any] = []
retriever = mocker.MagicMock()
retriever.retrieve.return_value = []
def capture_retriever(*args, **kwargs) -> pytest_mock.MockType:
captured.append(kwargs.get("filters"))
return retriever
mocker.patch("paperless_ai.chat.AIClient")
mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
side_effect=capture_retriever,
)
return captured
def test_no_nodes_yields_no_content_message( def test_no_nodes_yields_no_content_message(
self, self,
temp_llm_index_dir, temp_llm_index_dir,
@@ -329,9 +371,9 @@ class TestStreamChatRetrieval:
def test_chat_filter_contains_only_requested_document_ids( def test_chat_filter_contains_only_requested_document_ids(
self, self,
temp_llm_index_dir, temp_llm_index_dir: Path,
mock_embed_model, mock_embed_model: pytest_mock.MockType,
mocker, captured_filters: list[Any],
) -> None: ) -> None:
"""The MetadataFilter passed to the retriever must be scoped to the """The MetadataFilter passed to the retriever must be scoped to the
requested documents only content from other indexed documents must requested documents only content from other indexed documents must
@@ -342,22 +384,6 @@ class TestStreamChatRetrieval:
indexing.llm_index_add_or_update_document(included) indexing.llm_index_add_or_update_document(included)
indexing.llm_index_add_or_update_document(excluded) indexing.llm_index_add_or_update_document(excluded)
# VectorIndexRetriever is imported inside _stream_chat_with_documents;
# patch it at the llama_index source so the lazy import picks it up.
captured_filters = []
mock_retriever = mocker.MagicMock()
mock_retriever.retrieve.return_value = []
def capture_retriever(*args, **kwargs):
captured_filters.append(kwargs.get("filters"))
return mock_retriever
mocker.patch("paperless_ai.chat.AIClient")
mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
side_effect=capture_retriever,
)
list( list(
chat.stream_chat_with_documents( chat.stream_chat_with_documents(
"question?", "question?",
@@ -365,13 +391,78 @@ class TestStreamChatRetrieval:
), ),
) )
assert captured_filters, "VectorIndexRetriever was never constructed" filter_values = _retriever_filter_values(captured_filters)
filt = captured_filters[0]
assert filt is not None, "Retriever must receive a MetadataFilters"
filter_values = filt.filters[0].value
assert str(included.pk) in filter_values assert str(included.pk) in filter_values
assert str(excluded.pk) not in filter_values assert str(excluded.pk) not in filter_values
def test_unrestricted_chat_excludes_nothing_when_no_documents_are_trashed(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
captured_filters: list[Any],
) -> None:
"""
GIVEN:
- A document indexed in the vector store, nothing trashed
WHEN:
- stream_chat_with_documents is called with unrestricted=True
THEN:
- The retriever receives a NOT IN filter excluding zero ids, so
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")
indexing.llm_index_add_or_update_document(document)
list(
chat.stream_chat_with_documents(
"question?",
Document.objects.filter(pk=document.pk),
unrestricted=True,
),
)
assert _retriever_filter_values(captured_filters) == []
def test_unrestricted_chat_excludes_trashed_documents(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
captured_filters: list[Any],
) -> None:
"""
GIVEN:
- Two indexed documents, one of them trashed -- trashed documents
stay in the vector index until permanently deleted, since
delete_document_from_llm_index is wired to post_delete
WHEN:
- stream_chat_with_documents is called with unrestricted=True
THEN:
- The retriever receives a NOT IN filter excluding the trashed
document's id, so an unrestricted caller (e.g. a superuser)
never has trashed content surfaced in a chat answer
"""
kept = DocumentFactory.create(content="kept document content")
trashed = DocumentFactory.create(content="trashed document content")
indexing.llm_index_add_or_update_document(kept)
indexing.llm_index_add_or_update_document(trashed)
Document.global_objects.filter(pk=trashed.pk).update(
deleted_at=timezone.now(),
)
list(
chat.stream_chat_with_documents(
"question?",
Document.objects.filter(pk=kept.pk),
unrestricted=True,
),
)
filter_values = _retriever_filter_values(captured_filters)
assert str(trashed.pk) in filter_values
assert str(kept.pk) not in filter_values
@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,
+83
View File
@@ -9,6 +9,7 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaRow from paperless_ai.tables import DocumentMetaRow
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.tables import IndexMetaTable from paperless_ai.tables import IndexMetaTable
from paperless_ai.tables import PermittedIdsTable
@pytest.fixture @pytest.fixture
@@ -338,3 +339,85 @@ class TestIndexMetaTable:
IndexMetaTable.increment_total_inserts(conn, 100) IndexMetaTable.increment_total_inserts(conn, 100)
IndexMetaTable.reset_total_inserts(conn, 7) IndexMetaTable.reset_total_inserts(conn, 7)
assert IndexMetaTable.get_total_inserts(conn) == 7 assert IndexMetaTable.get_total_inserts(conn) == 7
class TestPermittedIdsTable:
def _loaded_ids(self, conn: sqlite3.Connection) -> list[int]:
return [
row["id"]
for row in conn.execute(
f"SELECT id FROM {PermittedIdsTable.TABLE_NAME} ORDER BY id",
)
]
def test_load_then_read_back_all_ids(self, conn: sqlite3.Connection) -> None:
"""
GIVEN:
- A bare sqlite3 connection
WHEN:
- load() is called with a set of ids
THEN:
- Every id is present in the TEMP TABLE, and only those ids
"""
PermittedIdsTable.load(conn, [3, 1, 2])
assert self._loaded_ids(conn) == [1, 2, 3]
def test_load_replaces_previous_contents(self, conn: sqlite3.Connection) -> None:
"""
GIVEN:
- A connection whose PermittedIdsTable already holds one id set
WHEN:
- load() is called again with a different id set
THEN:
- Only the new ids are present -- a connection reused across
multiple queries in one request never leaks a stale filter
"""
PermittedIdsTable.load(conn, [1, 2, 3])
PermittedIdsTable.load(conn, [4, 5])
assert self._loaded_ids(conn) == [4, 5]
def test_load_is_connection_private(self) -> None:
"""
GIVEN:
- Two separate connections
WHEN:
- Each loads PermittedIdsTable with a different id set, under
the identical TABLE_NAME
THEN:
- Each connection sees only its own ids -- TEMP TABLE is
connection-private, so concurrent requests never collide or
cross-contaminate despite sharing the same table name (the
vector store opens one connection per request; see
PaperlessSqliteVecVectorStore)
"""
conn_a = sqlite3.connect(":memory:")
conn_a.row_factory = sqlite3.Row
conn_b = sqlite3.connect(":memory:")
conn_b.row_factory = sqlite3.Row
try:
PermittedIdsTable.load(conn_a, [1, 2, 3])
PermittedIdsTable.load(conn_b, [4, 5, 6])
assert self._loaded_ids(conn_a) == [1, 2, 3]
assert self._loaded_ids(conn_b) == [4, 5, 6]
finally:
conn_a.close()
conn_b.close()
def test_load_handles_more_ids_than_a_bound_parameter_list_could(
self,
conn: sqlite3.Connection,
) -> None:
"""
GIVEN:
- An id count over SQLite's own bound-parameter limit
(SQLITE_MAX_VARIABLE_NUMBER, 32766 by default) -- more than a
literal IN(?,?,...) list could ever bind in one statement
WHEN:
- load() is called with that many ids
THEN:
- Every id is loaded without error, since executemany() binds
one row at a time rather than one statement with N parameters
"""
ids = list(range(40_000))
PermittedIdsTable.load(conn, ids)
assert self._loaded_ids(conn) == ids
+153 -18
View File
@@ -1,5 +1,6 @@
import inspect import inspect
import sqlite3 import sqlite3
from collections.abc import Callable
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
@@ -17,6 +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.tables import PermittedIdsTable
from paperless_ai.vector_store import _MAX_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 DEFAULT_TABLE_NAME from paperless_ai.vector_store import DEFAULT_TABLE_NAME
@@ -97,6 +99,18 @@ def _ne_filter(document_id: int):
) )
def _nin_filter(document_ids: list[int]):
return MetadataFilters(
filters=[
MetadataFilter(
key="document_id",
operator=FilterOperator.NIN,
value=document_ids,
),
],
)
class TestCrud: class TestCrud:
def test_add_then_query_returns_node(self, store) -> None: def test_add_then_query_returns_node(self, store) -> None:
node = make_node("n1", 1) node = make_node("n1", 1)
@@ -267,8 +281,23 @@ class TestCrud:
class TestBuildWhere: class TestBuildWhere:
def test_ne_filter_translates_to_not_equal_clause(self) -> None: @pytest.fixture
where, params = _build_where(_ne_filter(1)) def conn(self) -> Generator[sqlite3.Connection, None, None]:
"""A bare connection, sufficient for _build_where(): it only ever
touches the connection via PermittedIdsTable, which needs no vec0
extension loaded.
"""
connection = sqlite3.connect(":memory:")
try:
yield connection
finally:
connection.close()
def test_ne_filter_translates_to_not_equal_clause(
self,
conn: sqlite3.Connection,
) -> None:
where, params = _build_where(conn, _ne_filter(1))
assert where == "(document_id != ?)" assert where == "(document_id != ?)"
assert params == [1] assert params == [1]
@@ -280,7 +309,60 @@ class TestBuildWhere:
"b1", "b1",
] ]
def test_fails_closed_when_no_filter_is_translatable(self) -> None: def test_nin_filter_translates_to_not_in_clause(
self,
conn: sqlite3.Connection,
) -> None:
where, params = _build_where(conn, _nin_filter([1, 2]))
assert where == "(document_id NOT IN (?,?))"
assert params == [1, 2]
def test_query_with_nin_filter_excludes_matching_documents(self, store) -> None:
store.add([make_node("a1", 1), make_node("b1", 2), make_node("c1", 3)])
assert sorted(
_query(store, [0.0] * DIM, top_k=5, filters=_nin_filter([1, 2])).ids,
) == ["c1"]
def test_empty_in_filter_excludes_everything(
self,
conn: sqlite3.Connection,
) -> None:
"""
GIVEN:
- An IN filter with an empty value list
WHEN:
- _build_where() translates it to SQL
THEN:
- It excludes everything (the opposite of an empty NOT IN
filter) -- an empty inclusion list must never widen results
"""
where, params = _build_where(conn, _in_filter([]))
assert where == "(1 = 0)"
assert params == []
def test_empty_nin_filter_excludes_nothing(
self,
conn: sqlite3.Connection,
) -> None:
"""
GIVEN:
- A NOT IN filter with an empty value list -- e.g. an
unrestricted chat caller when nothing is currently trashed
WHEN:
- _build_where() translates it to SQL
THEN:
- It excludes nothing (unlike an empty IN filter, which
excludes everything) -- an empty exclusion list must never
narrow results
"""
where, params = _build_where(conn, _nin_filter([]))
assert where == "(1 = 1)"
assert params == []
def test_fails_closed_when_no_filter_is_translatable(
self,
conn: sqlite3.Connection,
) -> None:
# A nested MetadataFilters is not a MetadataFilter, so it is skipped. # A nested MetadataFilters is not a MetadataFilter, so it is skipped.
# With no translatable clauses, the function must fail closed rather # With no translatable clauses, the function must fail closed rather
# than emit "()" (invalid SQL) and never widen document access. # than emit "()" (invalid SQL) and never widen document access.
@@ -293,35 +375,88 @@ class TestBuildWhere:
), ),
], ],
) )
where, params = _build_where(MetadataFilters(filters=[nested])) where, params = _build_where(conn, MetadataFilters(filters=[nested]))
assert where == "1 = 0" assert where == "1 = 0"
assert params == [] assert params == []
def test_fails_closed_when_in_filter_exceeds_max_values( @pytest.mark.parametrize(
("build_filter", "sql_op"),
[(_in_filter, "IN"), (_nin_filter, "NOT IN")],
ids=["in", "nin"],
)
def test_filter_over_max_values_uses_permitted_ids_table(
self, self,
caplog: pytest.LogCaptureFixture, conn: sqlite3.Connection,
build_filter: Callable[[list[str]], MetadataFilters],
sql_op: str,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
- An IN filter with more values than _MAX_IN_VALUES (SQLite's - An IN or NOT IN filter with more values than _MAX_IN_VALUES
own bound-parameter limit is 32766; this guard sits below (SQLite's own bound-parameter limit is 32766; this threshold
that with headroom for the query's other bound parameters) sits below that with headroom for the query's other bound
parameters)
WHEN: WHEN:
- _build_where() translates it to SQL - _build_where() translates it to SQL
THEN: THEN:
- It fails closed ("1 = 0", no params) instead of building an - It builds a subquery against PermittedIdsTable's TEMP TABLE,
IN clause SQLite would reject, and logs a warning -- this loaded with every id, instead of a literal list SQLite would
filter scopes document access, so refusing to build it must reject past its own limit -- true for NOT IN too (e.g. an
never widen the scope to "everything" by accident install with an enormous trash), not just IN
""" """
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)]) ids = list(range(_MAX_IN_VALUES + 1))
oversized = build_filter([str(i) for i in ids])
with caplog.at_level("WARNING"): where, params = _build_where(conn, oversized)
where, params = _build_where(oversized)
assert where == "(1 = 0)" assert where == (
f"(document_id {sql_op} (SELECT id FROM {PermittedIdsTable.TABLE_NAME}))"
)
assert params == [] assert params == []
assert "document_id" in caplog.text loaded = [
row[0]
for row in conn.execute(
f"SELECT id FROM {PermittedIdsTable.TABLE_NAME} ORDER BY id",
)
]
assert loaded == ids
@pytest.mark.parametrize(
("build_filter", "expected_ids"),
[(_in_filter, ["b1", "c1"]), (_nin_filter, ["a1"])],
ids=["in", "nin"],
)
def test_query_and_get_nodes_scope_correctly_when_filter_exceeds_max_values(
self,
store: PaperlessSqliteVecVectorStore,
mocker: MockerFixture,
build_filter: Callable[[list[int]], MetadataFilters],
expected_ids: list[str],
) -> None:
"""
GIVEN:
- _MAX_IN_VALUES lowered so a small IN/NOT IN filter exceeds it
WHEN:
- query() and get_nodes() are called with that filter
THEN:
- Both still correctly scope results -- the PermittedIdsTable
temp-table path behaves identically to the literal
IN(...)/NOT IN(...) path it replaces above the threshold
"""
mocker.patch("paperless_ai.vector_store._MAX_IN_VALUES", 1)
store.add(
[
make_node("a1", 1, seed=0.0),
make_node("b1", 2, seed=1.0),
make_node("c1", 3, seed=2.0),
],
)
result = _query(store, [0.0] * DIM, top_k=10, filters=build_filter([2, 3]))
nodes = store.get_nodes(filters=build_filter([2, 3]))
assert sorted(result.ids) == expected_ids
assert sorted(n.node_id for n in nodes) == expected_ids
def test_query_with_untranslatable_filter_returns_no_rows( def test_query_with_untranslatable_filter_returns_no_rows(
self, self,
+41 -34
View File
@@ -30,6 +30,7 @@ from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaRow from paperless_ai.tables import DocumentMetaRow
from paperless_ai.tables import DocumentMetaTable from paperless_ai.tables import DocumentMetaTable
from paperless_ai.tables import IndexMetaTable from paperless_ai.tables import IndexMetaTable
from paperless_ai.tables import PermittedIdsTable
logger = logging.getLogger("paperless_ai.vector_store") logger = logging.getLogger("paperless_ai.vector_store")
@@ -75,14 +76,12 @@ class _Row(NamedTuple):
embedding: bytes embedding: bytes
# _build_where(): the largest IN value list translated into bound SQL # _build_where(): the largest IN value list translated into a literal
# parameters. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER) is 32766 # IN (?,?,...) clause. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER)
# by default; this leaves headroom below that for the query's other bound # is 32766 by default; this leaves headroom below that for the query's other
# parameters (the embedding blob, k, and any NE clause) and for the limit # bound parameters (the embedding blob, k, and any NE clause) and for the
# itself to move. An IN filter this large should not happen in practice -- # limit itself to move. Above this threshold _build_where() switches to
# callers are expected to pass None (no filter) rather than every id when # PermittedIdsTable instead of failing closed -- see its docstring.
# 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 _MAX_IN_VALUES = 32700
@@ -106,13 +105,21 @@ 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]]: def _build_where(
"""Translate the EQ / IN / NE filters we use into a parameterized SQL conn: sqlite3.Connection,
clause on vec0 metadata columns. Returns ("", []) when there is nothing filters: MetadataFilters | None,
to filter. document_id is vec0's only filterable column and is INTEGER; ) -> tuple[str, list[int]]:
every value is coerced via int() here so callers (which today still pass """Translate the EQ / IN / NIN / NE filters we use into a parameterized
strings in places, e.g. indexing.py's MetadataFilter construction) don't SQL clause on vec0 metadata columns. Returns ("", []) when there is
have to be individually correct -- vec0 doesn't coerce types itself. nothing to filter. document_id is vec0's only filterable column and is
INTEGER; every value is coerced via int() here so callers (which today
still pass strings in places, e.g. indexing.py's MetadataFilter
construction) don't have to be individually correct -- vec0 doesn't
coerce types itself.
``conn`` is only used for an IN/NOT IN filter over _MAX_IN_VALUES: it
loads the ids into PermittedIdsTable's TEMP TABLE on that connection
rather than binding them as SQL parameters.
""" """
if filters is None or not filters.filters: if filters is None or not filters.filters:
return "", [] return "", []
@@ -125,28 +132,28 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
continue continue
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
raise NotImplementedError(f"Unsupported filter column: {f.key}") raise NotImplementedError(f"Unsupported filter column: {f.key}")
if f.operator == FilterOperator.IN: if f.operator in (FilterOperator.IN, FilterOperator.NIN):
is_in = f.operator == FilterOperator.IN
sql_op = "IN" if is_in else "NOT IN"
values = [int(v) for v in f.value] # type: ignore[union-attr] values = [int(v) for v in f.value] # type: ignore[union-attr]
if not values: # pragma: no cover if not values:
clauses.append("1 = 0") # An empty IN list matches nothing; an empty NOT IN list
# excludes nothing, so it matches everything.
clauses.append("1 = 0" if is_in else "1 = 1")
continue continue
if len(values) > _MAX_IN_VALUES: if len(values) > _MAX_IN_VALUES:
# Fail closed (see the empty-clauses case below) rather than # A literal list this large would exceed SQLite's own
# let SQLite raise "too many SQL variables" past its own # bound-parameter limit. Load the ids into a TEMP TABLE on
# limit: this filter scopes document access, so an IN list # this connection instead and filter via subquery, which has
# too large to safely bind must match no rows, never widen # no such limit -- see PermittedIdsTable. Applies to NOT IN
# the scope to "everything" by accident. # too (e.g. an install with an enormous trash), not just IN.
logger.warning( PermittedIdsTable.load(conn, values)
"Refusing to build an IN filter on %r with %d values " clauses.append(
"(over the %d-value safety limit); returning no rows.", f"{f.key} {sql_op} (SELECT id FROM {PermittedIdsTable.TABLE_NAME})",
f.key,
len(values),
_MAX_IN_VALUES,
) )
clauses.append("1 = 0")
continue continue
placeholders = ",".join("?" for _ in values) placeholders = ",".join("?" for _ in values)
clauses.append(f"{f.key} IN ({placeholders})") clauses.append(f"{f.key} {sql_op} ({placeholders})")
params.extend(values) params.extend(values)
elif f.operator == FilterOperator.EQ: elif f.operator == FilterOperator.EQ:
clauses.append(f"{f.key} = ?") clauses.append(f"{f.key} = ?")
@@ -154,7 +161,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
elif f.operator == FilterOperator.NE: elif f.operator == FilterOperator.NE:
clauses.append(f"{f.key} != ?") clauses.append(f"{f.key} != ?")
params.append(int(f.value)) params.append(int(f.value))
else: # pragma: no cover - we only ever build EQ/IN/NE filters else: # pragma: no cover - we only ever build EQ/IN/NIN/NE filters
raise NotImplementedError(f"Unsupported filter operator: {f.operator}") raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
if not clauses: if not clauses:
# Filters were requested but none could be translated. Fail closed # Filters were requested but none could be translated. Fail closed
@@ -482,7 +489,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
) )
if not self.table_exists(): if not self.table_exists():
return [] return []
where, params = _build_where(filters) where, params = _build_where(self._conn, filters)
sql = "SELECT node_content, embedding FROM " + DEFAULT_TABLE_NAME sql = "SELECT node_content, embedding FROM " + DEFAULT_TABLE_NAME
if where: if where:
sql += " WHERE " + where sql += " WHERE " + where
@@ -498,7 +505,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
if query.query_embedding is None: # pragma: no cover if query.query_embedding is None: # pragma: no cover
return VectorStoreQueryResult(nodes=[], similarities=[], ids=[]) return VectorStoreQueryResult(nodes=[], similarities=[], ids=[])
top_k = query.similarity_top_k if query.similarity_top_k is not None else 10 top_k = query.similarity_top_k if query.similarity_top_k is not None else 10
where, params = _build_where(query.filters) where, params = _build_where(self._conn, query.filters)
sql = ( sql = (
"SELECT id, node_content, embedding, distance FROM " "SELECT id, node_content, embedding, distance FROM "
+ DEFAULT_TABLE_NAME + DEFAULT_TABLE_NAME