Feature: paginate search highlights and remove 10k document search limit (#12518)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Trenton H
2026-04-15 23:20:31 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 shamoon
parent 21db608d57
commit 3ffbb8862c
12 changed files with 904 additions and 664 deletions
@@ -43,7 +43,7 @@
</div> </div>
<p class="card-text"> <p class="card-text">
@if (document) { @if (document) {
@if (document.__search_hit__ && document.__search_hit__.highlights) { @if (hasSearchHighlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span> <span [innerHtml]="document.__search_hit__.highlights"></span>
} }
@for (highlight of searchNoteHighlights; track highlight) { @for (highlight of searchNoteHighlights; track highlight) {
@@ -52,7 +52,7 @@
<span [innerHtml]="highlight"></span> <span [innerHtml]="highlight"></span>
</span> </span>
} }
@if (!document.__search_hit__?.score) { @if (shouldShowContentFallback) {
<span class="result-content">{{contentTrimmed}}</span> <span class="result-content">{{contentTrimmed}}</span>
} }
} @else { } @else {
@@ -65,7 +65,9 @@
} }
} }
span ::ng-deep .match { .card-text ::ng-deep .match,
.card-text ::ng-deep b {
font-weight: normal;
color: black; color: black;
background-color: rgb(255, 211, 66); background-color: rgb(255, 211, 66);
} }
@@ -127,6 +127,19 @@ describe('DocumentCardLargeComponent', () => {
expect(component.searchNoteHighlights).toContain('<span>bananas</span>') expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
}) })
it('should fall back to document content when a search hit has no highlights', () => {
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
highlights: '',
note_highlights: null,
}
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Cupcake ipsum')
expect(component.shouldShowContentFallback).toBe(true)
})
it('should try to close the preview on mouse leave', () => { it('should try to close the preview on mouse leave', () => {
component.popupPreview = { component.popupPreview = {
close: jest.fn(), close: jest.fn(),
@@ -164,6 +164,17 @@ export class DocumentCardLargeComponent
) )
} }
get hasSearchHighlights() {
return Boolean(this.document?.__search_hit__?.highlights?.trim()?.length)
}
get shouldShowContentFallback() {
return (
this.document?.__search_hit__?.score == null ||
(!this.hasSearchHighlights && this.searchNoteHighlights.length === 0)
)
}
get notesEnabled(): boolean { get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED) return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
} }
+2 -2
View File
@@ -1,6 +1,6 @@
from documents.search._backend import SearchHit
from documents.search._backend import SearchIndexLockError from documents.search._backend import SearchIndexLockError
from documents.search._backend import SearchMode from documents.search._backend import SearchMode
from documents.search._backend import SearchResults
from documents.search._backend import TantivyBackend from documents.search._backend import TantivyBackend
from documents.search._backend import TantivyRelevanceList from documents.search._backend import TantivyRelevanceList
from documents.search._backend import WriteBatch from documents.search._backend import WriteBatch
@@ -10,9 +10,9 @@ from documents.search._schema import needs_rebuild
from documents.search._schema import wipe_index from documents.search._schema import wipe_index
__all__ = [ __all__ = [
"SearchHit",
"SearchIndexLockError", "SearchIndexLockError",
"SearchMode", "SearchMode",
"SearchResults",
"TantivyBackend", "TantivyBackend",
"TantivyRelevanceList", "TantivyRelevanceList",
"WriteBatch", "WriteBatch",
+345 -241
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import threading import threading
from collections import Counter from collections import Counter
from dataclasses import dataclass
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
@@ -88,45 +88,63 @@ class SearchHit(TypedDict):
highlights: dict[str, str] highlights: dict[str, str]
@dataclass(frozen=True, slots=True)
class SearchResults:
"""
Container for search results with pagination metadata.
Attributes:
hits: List of search results with scores and highlights
total: Total matching documents across all pages (for pagination)
query: Preprocessed query string after date/syntax rewriting
"""
hits: list[SearchHit]
total: int # total matching documents (for pagination)
query: str # preprocessed query string
class TantivyRelevanceList: class TantivyRelevanceList:
""" """
DRF-compatible list wrapper for Tantivy search hits. DRF-compatible list wrapper for Tantivy search results.
Provides paginated access to search results while storing all hits in memory Holds a lightweight ordered list of IDs (for pagination count and
for efficient ID retrieval. Used by Django REST framework for pagination. ``selection_data``) together with a small page of rich ``SearchHit``
dicts (for serialization). DRF's ``PageNumberPagination`` calls
``__len__`` to compute the total page count and ``__getitem__`` to
slice the displayed page.
Methods: Args:
__len__: Returns total hit count for pagination calculations ordered_ids: All matching document IDs in display order.
__getitem__: Slices the hit list for page-specific results page_hits: Rich SearchHit dicts for the requested DRF page only.
page_offset: Index into *ordered_ids* where *page_hits* starts.
Note: Stores ALL post-filter hits so get_all_result_ids() can return
every matching document ID without requiring a second search query.
""" """
def __init__(self, hits: list[SearchHit]) -> None: def __init__(
self._hits = hits self,
ordered_ids: list[int],
page_hits: list[SearchHit],
page_offset: int = 0,
) -> None:
self._ordered_ids = ordered_ids
self._page_hits = page_hits
self._page_offset = page_offset
def __len__(self) -> int: def __len__(self) -> int:
return len(self._hits) return len(self._ordered_ids)
def __getitem__(self, key: slice) -> list[SearchHit]: def __getitem__(self, key: int | slice) -> SearchHit | list[SearchHit]:
return self._hits[key] if isinstance(key, int):
idx = key if key >= 0 else len(self._ordered_ids) + key
if self._page_offset <= idx < self._page_offset + len(self._page_hits):
return self._page_hits[idx - self._page_offset]
return SearchHit(
id=self._ordered_ids[key],
score=0.0,
rank=idx + 1,
highlights={},
)
start = key.start or 0
stop = key.stop or len(self._ordered_ids)
# DRF slices to extract the current page. If the slice aligns
# with our pre-fetched page_hits, return them directly.
# We only check start — DRF always slices with stop=start+page_size,
# which exceeds page_hits length on the last page.
if start == self._page_offset:
return self._page_hits[: stop - start]
# Fallback: return stub dicts (no highlights).
return [
SearchHit(id=doc_id, score=0.0, rank=start + i + 1, highlights={})
for i, doc_id in enumerate(self._ordered_ids[key])
]
def get_all_ids(self) -> list[int]:
"""Return all matching document IDs in display order."""
return self._ordered_ids
class SearchIndexLockError(Exception): class SearchIndexLockError(Exception):
@@ -206,10 +224,13 @@ class WriteBatch:
""" """
Remove a document from the batch by its primary key. Remove a document from the batch by its primary key.
Uses range query instead of term query to work around unsigned integer Uses range_query instead of term_query to work around a tantivy-py bug
type detection bug in tantivy-py 0.25. where Python integers are inferred as i64, producing Terms that never
match u64 fields.
TODO: Replace with term_query("id", doc_id) once
https://github.com/quickwit-oss/tantivy-py/pull/642 lands.
""" """
# Use range query to work around u64 deletion bug
self._writer.delete_documents_by_query( self._writer.delete_documents_by_query(
tantivy.Query.range_query( tantivy.Query.range_query(
self._backend._schema, self._backend._schema,
@@ -234,6 +255,34 @@ class TantivyBackend:
the underlying index directory changes (e.g., during test isolation). the underlying index directory changes (e.g., during test isolation).
""" """
# Maps DRF ordering field names to Tantivy index field names.
SORT_FIELD_MAP: dict[str, str] = {
"title": "title_sort",
"correspondent__name": "correspondent_sort",
"document_type__name": "type_sort",
"created": "created",
"added": "added",
"modified": "modified",
"archive_serial_number": "asn",
"page_count": "page_count",
"num_notes": "num_notes",
}
# Fields where Tantivy's sort order matches the ORM's sort order.
# Text-based fields (title, correspondent__name, document_type__name)
# are excluded because Tantivy's tokenized fast fields produce different
# ordering than the ORM's collation-based ordering.
SORTABLE_FIELDS: frozenset[str] = frozenset(
{
"created",
"added",
"modified",
"archive_serial_number",
"page_count",
"num_notes",
},
)
def __init__(self, path: Path | None = None): def __init__(self, path: Path | None = None):
# path=None → in-memory index (for tests) # path=None → in-memory index (for tests)
# path=some_dir → on-disk index (for production) # path=some_dir → on-disk index (for production)
@@ -272,6 +321,36 @@ class TantivyBackend:
if self._index is None: if self._index is None:
self.open() # pragma: no cover self.open() # pragma: no cover
def _parse_query(
self,
query: str,
search_mode: SearchMode,
) -> tantivy.Query:
"""Parse a user query string into a Tantivy Query object."""
tz = get_current_timezone()
if search_mode is SearchMode.TEXT:
return parse_simple_text_query(self._index, query)
elif search_mode is SearchMode.TITLE:
return parse_simple_title_query(self._index, query)
else:
return parse_user_query(self._index, query, tz)
def _apply_permission_filter(
self,
query: tantivy.Query,
user: AbstractBaseUser | None,
) -> tantivy.Query:
"""Wrap a query with a permission filter if the user is not a superuser."""
if user is not None:
permission_filter = build_permission_filter(self._schema, user)
return tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, query),
(tantivy.Occur.Must, permission_filter),
],
)
return query
def _build_tantivy_doc( def _build_tantivy_doc(
self, self,
document: Document, document: Document,
@@ -326,12 +405,17 @@ class TantivyBackend:
doc.add_unsigned("tag_id", tag.pk) doc.add_unsigned("tag_id", tag.pk)
tag_names.append(tag.name) tag_names.append(tag.name)
# Notes — JSON for structured queries (notes.user:alice, notes.note:text), # Notes — JSON for structured queries (notes.user:alice, notes.note:text).
# companion text field for default full-text search. # notes_text is a plain-text companion for snippet/highlight generation;
# tantivy's SnippetGenerator does not support JSON fields.
num_notes = 0 num_notes = 0
note_texts: list[str] = []
for note in document.notes.all(): for note in document.notes.all():
num_notes += 1 num_notes += 1
doc.add_json("notes", {"note": note.note, "user": note.user.username}) doc.add_json("notes", {"note": note.note, "user": note.user.username})
note_texts.append(note.note)
if note_texts:
doc.add_text("notes_text", " ".join(note_texts))
# Custom fields — JSON for structured queries (custom_fields.name:x, custom_fields.value:y), # Custom fields — JSON for structured queries (custom_fields.name:x, custom_fields.value:y),
# companion text field for default full-text search. # companion text field for default full-text search.
@@ -425,155 +509,125 @@ class TantivyBackend:
with self.batch_update(lock_timeout=5.0) as batch: with self.batch_update(lock_timeout=5.0) as batch:
batch.remove(doc_id) batch.remove(doc_id)
def search( def highlight_hits(
self, self,
query: str, query: str,
user: AbstractBaseUser | None, doc_ids: list[int],
page: int,
page_size: int,
sort_field: str | None,
*, *,
sort_reverse: bool,
search_mode: SearchMode = SearchMode.QUERY, search_mode: SearchMode = SearchMode.QUERY,
) -> SearchResults: rank_start: int = 1,
) -> list[SearchHit]:
""" """
Execute a search query against the document index. Generate SearchHit dicts with highlights for specific document IDs.
Processes the user query through date rewriting, normalization, and Unlike search(), this does not execute a ranked query — it looks up
permission filtering before executing against Tantivy. Supports both each document by ID and generates snippets against the provided query.
relevance-based and field-based sorting. Use this when you already know which documents to display (from
search_ids + ORM filtering) and just need highlight data.
QUERY search mode supports natural date keywords, field filters, etc. Note: Each doc_id requires an individual index lookup because tantivy-py
TITLE search mode treats the query as plain text to search for in title only does not yet expose a batch fast-field read API. This is acceptable for
TEXT search mode treats the query as plain text to search for in title and content page-sized batches (typically 25 docs) but should not be called with
thousands of IDs.
TODO: When https://github.com/quickwit-oss/tantivy-py/pull/641 lands,
the per-doc range_query lookups here can be replaced with a single
collect_u64_fast_field("id", doc_addresses) call.
Args: Args:
query: User's search query query: The search query (used for snippet generation)
user: User for permission filtering (None for superuser/no filtering) doc_ids: Ordered list of document IDs to generate hits for
page: Page number (1-indexed) for pagination search_mode: Query parsing mode (for building the snippet query)
page_size: Number of results per page rank_start: Starting rank value (1-based absolute position in the
sort_field: Field to sort by (None for relevance ranking) full result set; pass ``page_offset + 1`` for paginated calls)
sort_reverse: Whether to reverse the sort order
search_mode: "query" for advanced Tantivy syntax, "text" for
plain-text search over title and content only, "title" for
plain-text search over title only
Returns: Returns:
SearchResults with hits, total count, and processed query List of SearchHit dicts in the same order as doc_ids
""" """
self._ensure_open() if not doc_ids:
tz = get_current_timezone() return []
if search_mode is SearchMode.TEXT:
user_query = parse_simple_text_query(self._index, query)
elif search_mode is SearchMode.TITLE:
user_query = parse_simple_title_query(self._index, query)
else:
user_query = parse_user_query(self._index, query, tz)
# Apply permission filter if user is not None (not superuser) self._ensure_open()
if user is not None: user_query = self._parse_query(query, search_mode)
permission_filter = build_permission_filter(self._schema, user)
final_query = tantivy.Query.boolean_query( # For notes_text snippet generation, we need a query that targets the
[ # notes_text field directly. user_query may contain JSON-field terms
(tantivy.Occur.Must, user_query), # (e.g. notes.note:urgent) that the SnippetGenerator cannot resolve
(tantivy.Occur.Must, permission_filter), # against a text field. Strip field:value prefixes so bare terms like
], # "urgent" are re-parsed against notes_text, producing highlights even
# when the original query used structured syntax.
bare_query = re.sub(r"\w[\w.]*:", "", query).strip()
try:
notes_text_query = (
self._index.parse_query(bare_query, ["notes_text"])
if bare_query
else user_query
) )
else: except Exception:
final_query = user_query notes_text_query = user_query
searcher = self._index.searcher() searcher = self._index.searcher()
offset = (page - 1) * page_size
# Map sort fields
sort_field_map = {
"title": "title_sort",
"correspondent__name": "correspondent_sort",
"document_type__name": "type_sort",
"created": "created",
"added": "added",
"modified": "modified",
"archive_serial_number": "asn",
"page_count": "page_count",
"num_notes": "num_notes",
}
# Perform search
if sort_field and sort_field in sort_field_map:
mapped_field = sort_field_map[sort_field]
results = searcher.search(
final_query,
limit=offset + page_size,
order_by_field=mapped_field,
order=tantivy.Order.Desc if sort_reverse else tantivy.Order.Asc,
)
# Field sorting: hits are still (score, DocAddress) tuples; score unused
all_hits = [(hit[1], 0.0) for hit in results.hits]
else:
# Score-based search: hits are (score, DocAddress) tuples
results = searcher.search(final_query, limit=offset + page_size)
all_hits = [(hit[1], hit[0]) for hit in results.hits]
total = results.count
# Normalize scores for score-based searches
if not sort_field and all_hits:
max_score = max(hit[1] for hit in all_hits) or 1.0
all_hits = [(hit[0], hit[1] / max_score) for hit in all_hits]
# Apply threshold filter if configured (score-based search only)
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
if threshold is not None and not sort_field:
all_hits = [hit for hit in all_hits if hit[1] >= threshold]
# Get the page's hits
page_hits = all_hits[offset : offset + page_size]
# Build result hits with highlights
hits: list[SearchHit] = []
snippet_generator = None snippet_generator = None
notes_snippet_generator = None notes_snippet_generator = None
hits: list[SearchHit] = []
for rank, (doc_address, score) in enumerate(page_hits, start=offset + 1): for rank, doc_id in enumerate(doc_ids, start=rank_start):
# Get the actual document from the searcher using the doc address # Look up document by ID, scoring against the user query so that
# the returned SearchHit carries a real BM25 relevance score.
id_query = tantivy.Query.range_query(
self._schema,
"id",
tantivy.FieldType.Unsigned,
doc_id,
doc_id,
)
scored_query = tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, user_query),
(tantivy.Occur.Must, id_query),
],
)
results = searcher.search(scored_query, limit=1)
if not results.hits:
continue
score, doc_address = results.hits[0]
actual_doc = searcher.doc(doc_address) actual_doc = searcher.doc(doc_address)
doc_dict = actual_doc.to_dict() doc_dict = actual_doc.to_dict()
doc_id = doc_dict["id"][0]
highlights: dict[str, str] = {} highlights: dict[str, str] = {}
try:
if snippet_generator is None:
snippet_generator = tantivy.SnippetGenerator.create(
searcher,
user_query,
self._schema,
"content",
)
# Generate highlights if score > 0 content_html = snippet_generator.snippet_from_doc(actual_doc).to_html()
if score > 0: if content_html:
try: highlights["content"] = content_html
if snippet_generator is None:
snippet_generator = tantivy.SnippetGenerator.create( if "notes_text" in doc_dict:
# Use notes_text (plain text) for snippet generation — tantivy's
# SnippetGenerator does not support JSON fields.
if notes_snippet_generator is None:
notes_snippet_generator = tantivy.SnippetGenerator.create(
searcher, searcher,
final_query, notes_text_query,
self._schema, self._schema,
"content", "notes_text",
) )
notes_html = notes_snippet_generator.snippet_from_doc(
actual_doc,
).to_html()
if notes_html:
highlights["notes"] = notes_html
content_snippet = snippet_generator.snippet_from_doc(actual_doc) except Exception: # pragma: no cover
if content_snippet: logger.debug("Failed to generate highlights for doc %s", doc_id)
highlights["content"] = str(content_snippet)
# Try notes highlights
if "notes" in doc_dict:
if notes_snippet_generator is None:
notes_snippet_generator = tantivy.SnippetGenerator.create(
searcher,
final_query,
self._schema,
"notes",
)
notes_snippet = notes_snippet_generator.snippet_from_doc(
actual_doc,
)
if notes_snippet:
highlights["notes"] = str(notes_snippet)
except Exception: # pragma: no cover
logger.debug("Failed to generate highlights for doc %s", doc_id)
hits.append( hits.append(
SearchHit( SearchHit(
@@ -584,11 +638,69 @@ class TantivyBackend:
), ),
) )
return SearchResults( return hits
hits=hits,
total=total, def search_ids(
query=query, self,
) query: str,
user: AbstractBaseUser | None,
*,
sort_field: str | None = None,
sort_reverse: bool = False,
search_mode: SearchMode = SearchMode.QUERY,
limit: int | None = None,
) -> list[int]:
"""
Return document IDs matching a query — no highlights or scores.
This is the lightweight companion to search(). Use it when you need the
full set of matching IDs (e.g. for ``selection_data``) but don't need
scores, ranks, or highlights.
Args:
query: User's search query
user: User for permission filtering (None for superuser/no filtering)
sort_field: Field to sort by (None for relevance ranking)
sort_reverse: Whether to reverse the sort order
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
limit: Maximum number of IDs to return (None = all matching docs)
Returns:
List of document IDs in the requested order
"""
self._ensure_open()
user_query = self._parse_query(query, search_mode)
final_query = self._apply_permission_filter(user_query, user)
searcher = self._index.searcher()
effective_limit = limit if limit is not None else searcher.num_docs
if sort_field and sort_field in self.SORT_FIELD_MAP:
mapped_field = self.SORT_FIELD_MAP[sort_field]
results = searcher.search(
final_query,
limit=effective_limit,
order_by_field=mapped_field,
order=tantivy.Order.Desc if sort_reverse else tantivy.Order.Asc,
)
all_hits = [(hit[1],) for hit in results.hits]
else:
results = searcher.search(final_query, limit=effective_limit)
all_hits = [(hit[1], hit[0]) for hit in results.hits]
# Normalize scores and apply threshold (relevance search only)
if all_hits:
max_score = max(hit[1] for hit in all_hits) or 1.0
all_hits = [(hit[0], hit[1] / max_score) for hit in all_hits]
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
if threshold is not None:
all_hits = [hit for hit in all_hits if hit[1] >= threshold]
# TODO: Replace with searcher.collect_u64_fast_field("id", addrs) once
# https://github.com/quickwit-oss/tantivy-py/pull/641 lands — eliminates
# one stored-doc fetch per result (~80% reduction in search_ids latency).
return [searcher.doc(doc_addr).to_dict()["id"][0] for doc_addr, *_ in all_hits]
def autocomplete( def autocomplete(
self, self,
@@ -603,6 +715,10 @@ class TantivyBackend:
frequency (how many documents contain each word). Optionally filters frequency (how many documents contain each word). Optionally filters
results to only words from documents visible to the specified user. results to only words from documents visible to the specified user.
NOTE: This is the hottest search path (called per keystroke).
A future improvement would be to cache results in Redis, keyed by
(prefix, user_id), and invalidate on index writes.
Args: Args:
term: Prefix to match against autocomplete words term: Prefix to match against autocomplete words
limit: Maximum number of suggestions to return limit: Maximum number of suggestions to return
@@ -613,64 +729,94 @@ class TantivyBackend:
""" """
self._ensure_open() self._ensure_open()
normalized_term = ascii_fold(term.lower()) normalized_term = ascii_fold(term.lower())
if not normalized_term:
return []
searcher = self._index.searcher() searcher = self._index.searcher()
# Apply permission filter for non-superusers so autocomplete words # Build a prefix query on autocomplete_word so we only scan docs
# from invisible documents don't leak to other users. # containing words that start with the prefix, not the entire index.
# tantivy regex is implicitly anchored; .+ avoids the empty-match
# error that .* triggers. We OR with term_query to also match the
# exact prefix as a complete word.
escaped = re.escape(normalized_term)
prefix_query = tantivy.Query.boolean_query(
[
(
tantivy.Occur.Should,
tantivy.Query.term_query(
self._schema,
"autocomplete_word",
normalized_term,
),
),
(
tantivy.Occur.Should,
tantivy.Query.regex_query(
self._schema,
"autocomplete_word",
f"{escaped}.+",
),
),
],
)
# Intersect with permission filter so autocomplete words from
# invisible documents don't leak to other users.
if user is not None and not user.is_superuser: if user is not None and not user.is_superuser:
base_query = build_permission_filter(self._schema, user) final_query = tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, prefix_query),
(tantivy.Occur.Must, build_permission_filter(self._schema, user)),
],
)
else: else:
base_query = tantivy.Query.all_query() final_query = prefix_query
results = searcher.search(base_query, limit=10000) results = searcher.search(final_query, limit=searcher.num_docs)
# Count how many visible documents each word appears in. # Count how many visible documents each matching word appears in.
# Using Counter (not set) preserves per-word document frequency so
# we can rank suggestions by how commonly they occur — the same
# signal Whoosh used for Tf/Idf-based autocomplete ordering.
word_counts: Counter[str] = Counter() word_counts: Counter[str] = Counter()
for _score, doc_address in results.hits: for _score, doc_address in results.hits:
stored_doc = searcher.doc(doc_address) stored_doc = searcher.doc(doc_address)
doc_dict = stored_doc.to_dict() doc_dict = stored_doc.to_dict()
if "autocomplete_word" in doc_dict: if "autocomplete_word" in doc_dict:
word_counts.update(doc_dict["autocomplete_word"]) for word in doc_dict["autocomplete_word"]:
if word.startswith(normalized_term):
word_counts[word] += 1
# Filter to prefix matches, sort by document frequency descending; # Sort by document frequency descending; break ties alphabetically.
# break ties alphabetically for stable, deterministic output.
matches = sorted( matches = sorted(
(w for w in word_counts if w.startswith(normalized_term)), word_counts,
key=lambda w: (-word_counts[w], w), key=lambda w: (-word_counts[w], w),
) )
return matches[:limit] return matches[:limit]
def more_like_this( def more_like_this_ids(
self, self,
doc_id: int, doc_id: int,
user: AbstractBaseUser | None, user: AbstractBaseUser | None,
page: int, *,
page_size: int, limit: int | None = None,
) -> SearchResults: ) -> list[int]:
""" """
Find documents similar to the given document using content analysis. Return IDs of documents similar to the given document — no highlights.
Uses Tantivy's "more like this" query to find documents with similar Lightweight companion to more_like_this(). The original document is
content patterns. The original document is excluded from results. excluded from results.
Args: Args:
doc_id: Primary key of the reference document doc_id: Primary key of the reference document
user: User for permission filtering (None for no filtering) user: User for permission filtering (None for no filtering)
page: Page number (1-indexed) for pagination limit: Maximum number of IDs to return (None = all matching docs)
page_size: Number of results per page
Returns: Returns:
SearchResults with similar documents (excluding the original) List of similar document IDs (excluding the original)
""" """
self._ensure_open() self._ensure_open()
searcher = self._index.searcher() searcher = self._index.searcher()
# First find the document address
id_query = tantivy.Query.range_query( id_query = tantivy.Query.range_query(
self._schema, self._schema,
"id", "id",
@@ -681,13 +827,9 @@ class TantivyBackend:
results = searcher.search(id_query, limit=1) results = searcher.search(id_query, limit=1)
if not results.hits: if not results.hits:
# Document not found return []
return SearchResults(hits=[], total=0, query=f"more_like:{doc_id}")
# Extract doc_address from (score, doc_address) tuple
doc_address = results.hits[0][1] doc_address = results.hits[0][1]
# Build more like this query
mlt_query = tantivy.Query.more_like_this_query( mlt_query = tantivy.Query.more_like_this_query(
doc_address, doc_address,
min_doc_frequency=1, min_doc_frequency=1,
@@ -699,59 +841,21 @@ class TantivyBackend:
boost_factor=None, boost_factor=None,
) )
# Apply permission filter final_query = self._apply_permission_filter(mlt_query, user)
if user is not None:
permission_filter = build_permission_filter(self._schema, user)
final_query = tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, mlt_query),
(tantivy.Occur.Must, permission_filter),
],
)
else:
final_query = mlt_query
# Search effective_limit = limit if limit is not None else searcher.num_docs
offset = (page - 1) * page_size # Fetch one extra to account for excluding the original document
results = searcher.search(final_query, limit=offset + page_size) results = searcher.search(final_query, limit=effective_limit + 1)
total = results.count # TODO: Replace with collect_u64_fast_field("id", addrs) once
# Convert from (score, doc_address) to (doc_address, score) # https://github.com/quickwit-oss/tantivy-py/pull/641 lands.
all_hits = [(hit[1], hit[0]) for hit in results.hits] ids = []
for _score, doc_address in results.hits:
result_doc_id = searcher.doc(doc_address).to_dict()["id"][0]
if result_doc_id != doc_id:
ids.append(result_doc_id)
# Normalize scores return ids[:limit] if limit is not None else ids
if all_hits:
max_score = max(hit[1] for hit in all_hits) or 1.0
all_hits = [(hit[0], hit[1] / max_score) for hit in all_hits]
# Get page hits
page_hits = all_hits[offset : offset + page_size]
# Build results
hits: list[SearchHit] = []
for rank, (doc_address, score) in enumerate(page_hits, start=offset + 1):
actual_doc = searcher.doc(doc_address)
doc_dict = actual_doc.to_dict()
result_doc_id = doc_dict["id"][0]
# Skip the original document
if result_doc_id == doc_id:
continue
hits.append(
SearchHit(
id=result_doc_id,
score=score,
rank=rank,
highlights={}, # MLT doesn't generate highlights
),
)
return SearchResults(
hits=hits,
total=max(0, total - 1), # Subtract 1 for the original document
query=f"more_like:{doc_id}",
)
def batch_update(self, lock_timeout: float = 30.0) -> WriteBatch: def batch_update(self, lock_timeout: float = 30.0) -> WriteBatch:
""" """
+11 -4
View File
@@ -396,10 +396,17 @@ def build_permission_filter(
Tantivy query that filters results to visible documents Tantivy query that filters results to visible documents
Implementation Notes: Implementation Notes:
- Uses range_query instead of term_query to work around unsigned integer - Uses range_query instead of term_query for owner_id/viewer_id to work
type detection bug in tantivy-py 0.25 around a tantivy-py bug where Python ints are inferred as i64, causing
- Uses boolean_query for "no owner" check since exists_query is not term_query to return no hits on u64 fields.
available in tantivy-py 0.25.1 (available in master) TODO: Replace with term_query once
https://github.com/quickwit-oss/tantivy-py/pull/642 lands.
- Uses range_query(owner_id, 1, MAX_U64) as an "owner exists" check
because exists_query is not yet available in tantivy-py 0.25.
TODO: Replace with exists_query("owner_id") once that is exposed in
a tantivy-py release.
- Uses disjunction_max_query to combine permission clauses with OR logic - Uses disjunction_max_query to combine permission clauses with OR logic
""" """
owner_any = tantivy.Query.range_query( owner_any = tantivy.Query.range_query(
+3
View File
@@ -72,6 +72,9 @@ def build_schema() -> tantivy.Schema:
# JSON fields — structured queries: notes.user:alice, custom_fields.name:invoice # JSON fields — structured queries: notes.user:alice, custom_fields.name:invoice
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text") sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
# Plain-text companion for notes — tantivy's SnippetGenerator does not support
# JSON fields, so highlights require a text field with the same content.
sb.add_text_field("notes_text", stored=True, tokenizer_name="paperless_text")
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text") sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
for field in ( for field in (
+216 -329
View File
@@ -33,19 +33,12 @@ class TestWriteBatch:
except RuntimeError: except RuntimeError:
pass pass
r = backend.search( ids = backend.search_ids("should survive", user=None)
"should survive", assert len(ids) == 1
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert r.total == 1
class TestSearch: class TestSearch:
"""Test search functionality.""" """Test search query parsing and matching via search_ids."""
def test_text_mode_limits_default_search_to_title_and_content( def test_text_mode_limits_default_search_to_title_and_content(
self, self,
@@ -60,27 +53,20 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
metadata_only = backend.search( assert (
"document_type:invoice", len(
user=None, backend.search_ids(
page=1, "document_type:invoice",
page_size=10, user=None,
sort_field=None, search_mode=SearchMode.TEXT,
sort_reverse=False, ),
search_mode=SearchMode.TEXT, )
== 0
) )
assert metadata_only.total == 0 assert (
len(backend.search_ids("monthly", user=None, search_mode=SearchMode.TEXT))
content_match = backend.search( == 1
"monthly",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert content_match.total == 1
def test_title_mode_limits_default_search_to_title_only( def test_title_mode_limits_default_search_to_title_only(
self, self,
@@ -95,27 +81,14 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
content_only = backend.search( assert (
"monthly", len(backend.search_ids("monthly", user=None, search_mode=SearchMode.TITLE))
user=None, == 0
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TITLE,
) )
assert content_only.total == 0 assert (
len(backend.search_ids("invoice", user=None, search_mode=SearchMode.TITLE))
title_match = backend.search( == 1
"invoice",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TITLE,
) )
assert title_match.total == 1
def test_text_mode_matches_partial_term_substrings( def test_text_mode_matches_partial_term_substrings(
self, self,
@@ -130,38 +103,16 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
prefix_match = backend.search( assert (
"pass", len(backend.search_ids("pass", user=None, search_mode=SearchMode.TEXT)) == 1
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert prefix_match.total == 1 assert (
len(backend.search_ids("sswo", user=None, search_mode=SearchMode.TEXT)) == 1
infix_match = backend.search(
"sswo",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert infix_match.total == 1 assert (
len(backend.search_ids("sswo re", user=None, search_mode=SearchMode.TEXT))
phrase_match = backend.search( == 1
"sswo re",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert phrase_match.total == 1
def test_text_mode_does_not_match_on_partial_term_overlap( def test_text_mode_does_not_match_on_partial_term_overlap(
self, self,
@@ -176,16 +127,10 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
non_match = backend.search( assert (
"raptor", len(backend.search_ids("raptor", user=None, search_mode=SearchMode.TEXT))
user=None, == 0
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert non_match.total == 0
def test_text_mode_anchors_later_query_tokens_to_token_starts( def test_text_mode_anchors_later_query_tokens_to_token_starts(
self, self,
@@ -214,16 +159,9 @@ class TestSearch:
backend.add_or_update(prefix_doc) backend.add_or_update(prefix_doc)
backend.add_or_update(false_positive) backend.add_or_update(false_positive)
results = backend.search( result_ids = set(
"Z-Berichte 6", backend.search_ids("Z-Berichte 6", user=None, search_mode=SearchMode.TEXT),
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
result_ids = {hit["id"] for hit in results.hits}
assert exact_doc.id in result_ids assert exact_doc.id in result_ids
assert prefix_doc.id in result_ids assert prefix_doc.id in result_ids
@@ -242,16 +180,9 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
no_tokens = backend.search( assert (
"!!!", len(backend.search_ids("!!!", user=None, search_mode=SearchMode.TEXT)) == 0
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT,
) )
assert no_tokens.total == 0
def test_title_mode_matches_partial_term_substrings( def test_title_mode_matches_partial_term_substrings(
self, self,
@@ -266,59 +197,18 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
prefix_match = backend.search( assert (
"pass", len(backend.search_ids("pass", user=None, search_mode=SearchMode.TITLE))
user=None, == 1
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TITLE,
) )
assert prefix_match.total == 1 assert (
len(backend.search_ids("sswo", user=None, search_mode=SearchMode.TITLE))
infix_match = backend.search( == 1
"sswo",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TITLE,
) )
assert infix_match.total == 1 assert (
len(backend.search_ids("sswo gu", user=None, search_mode=SearchMode.TITLE))
phrase_match = backend.search( == 1
"sswo gu",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TITLE,
) )
assert phrase_match.total == 1
def test_scores_normalised_top_hit_is_one(self, backend: TantivyBackend):
"""Search scores must be normalized so top hit has score 1.0 for UI consistency."""
for i, title in enumerate(["bank invoice", "bank statement", "bank receipt"]):
doc = Document.objects.create(
title=title,
content=title,
checksum=f"SN{i}",
pk=10 + i,
)
backend.add_or_update(doc)
r = backend.search(
"bank",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert r.hits[0]["score"] == pytest.approx(1.0)
assert all(0.0 <= h["score"] <= 1.0 for h in r.hits)
def test_sort_field_ascending(self, backend: TantivyBackend): def test_sort_field_ascending(self, backend: TantivyBackend):
"""Searching with sort_reverse=False must return results in ascending ASN order.""" """Searching with sort_reverse=False must return results in ascending ASN order."""
@@ -331,16 +221,14 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
r = backend.search( ids = backend.search_ids(
"sortable", "sortable",
user=None, user=None,
page=1,
page_size=10,
sort_field="archive_serial_number", sort_field="archive_serial_number",
sort_reverse=False, sort_reverse=False,
) )
assert r.total == 3 assert len(ids) == 3
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits] asns = [Document.objects.get(pk=doc_id).archive_serial_number for doc_id in ids]
assert asns == [10, 20, 30] assert asns == [10, 20, 30]
def test_sort_field_descending(self, backend: TantivyBackend): def test_sort_field_descending(self, backend: TantivyBackend):
@@ -354,79 +242,91 @@ class TestSearch:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
r = backend.search( ids = backend.search_ids(
"sortable", "sortable",
user=None, user=None,
page=1,
page_size=10,
sort_field="archive_serial_number", sort_field="archive_serial_number",
sort_reverse=True, sort_reverse=True,
) )
assert r.total == 3 assert len(ids) == 3
asns = [Document.objects.get(pk=h["id"]).archive_serial_number for h in r.hits] asns = [Document.objects.get(pk=doc_id).archive_serial_number for doc_id in ids]
assert asns == [30, 20, 10] assert asns == [30, 20, 10]
def test_fuzzy_threshold_filters_low_score_hits(
self,
backend: TantivyBackend,
settings,
):
"""When ADVANCED_FUZZY_SEARCH_THRESHOLD exceeds all normalized scores, hits must be filtered out."""
doc = Document.objects.create(
title="Invoice document",
content="financial report",
checksum="FT1",
pk=120,
)
backend.add_or_update(doc)
# Threshold above 1.0 filters every hit (normalized scores top out at 1.0) class TestSearchIds:
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 1.1 """Test lightweight ID-only search."""
r = backend.search(
"invoice", def test_returns_matching_ids(self, backend: TantivyBackend):
"""search_ids must return IDs of all matching documents."""
docs = []
for i in range(5):
doc = Document.objects.create(
title=f"findable doc {i}",
content="common keyword",
checksum=f"SI{i}",
)
backend.add_or_update(doc)
docs.append(doc)
other = Document.objects.create(
title="unrelated",
content="nothing here",
checksum="SI_other",
)
backend.add_or_update(other)
ids = backend.search_ids(
"common keyword",
user=None, user=None,
page=1, search_mode=SearchMode.QUERY,
page_size=10,
sort_field=None,
sort_reverse=False,
) )
assert r.hits == [] assert set(ids) == {d.pk for d in docs}
assert other.pk not in ids
def test_owner_filter(self, backend: TantivyBackend): def test_respects_permission_filter(self, backend: TantivyBackend):
"""Document owners can search their private documents; other users cannot access them.""" """search_ids must respect user permission filtering."""
owner = User.objects.create_user("owner") owner = User.objects.create_user("ids_owner")
other = User.objects.create_user("other") other = User.objects.create_user("ids_other")
doc = Document.objects.create( doc = Document.objects.create(
title="Private", title="private doc",
content="secret", content="secret keyword",
checksum="PF1", checksum="SIP1",
pk=20,
owner=owner, owner=owner,
) )
backend.add_or_update(doc) backend.add_or_update(doc)
assert backend.search_ids(
"secret",
user=owner,
search_mode=SearchMode.QUERY,
) == [doc.pk]
assert ( assert (
backend.search( backend.search_ids("secret", user=other, search_mode=SearchMode.QUERY) == []
"secret",
user=owner,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
).total
== 1
) )
assert (
backend.search( def test_respects_fuzzy_threshold(self, backend: TantivyBackend, settings):
"secret", """search_ids must apply the same fuzzy threshold as search()."""
user=other, doc = Document.objects.create(
page=1, title="threshold test",
page_size=10, content="unique term",
sort_field=None, checksum="SIT1",
sort_reverse=False,
).total
== 0
) )
backend.add_or_update(doc)
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 1.1
ids = backend.search_ids("unique", user=None, search_mode=SearchMode.QUERY)
assert ids == []
def test_returns_ids_for_text_mode(self, backend: TantivyBackend):
"""search_ids must work with TEXT search mode."""
doc = Document.objects.create(
title="text mode doc",
content="findable phrase",
checksum="SIM1",
)
backend.add_or_update(doc)
ids = backend.search_ids("findable", user=None, search_mode=SearchMode.TEXT)
assert ids == [doc.pk]
class TestRebuild: class TestRebuild:
@@ -490,57 +390,26 @@ class TestAutocomplete:
class TestMoreLikeThis: class TestMoreLikeThis:
"""Test more like this functionality.""" """Test more like this functionality."""
def test_excludes_original(self, backend: TantivyBackend): def test_more_like_this_ids_excludes_original(self, backend: TantivyBackend):
"""More like this queries must exclude the reference document from results.""" """more_like_this_ids must return IDs of similar documents, excluding the original."""
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="Important document", title="Important document",
content="financial information", content="financial information report",
checksum="MLT1", checksum="MLTI1",
pk=50, pk=150,
) )
doc2 = Document.objects.create( doc2 = Document.objects.create(
title="Another document", title="Another document",
content="financial report", content="financial information report",
checksum="MLT2", checksum="MLTI2",
pk=51, pk=151,
) )
backend.add_or_update(doc1) backend.add_or_update(doc1)
backend.add_or_update(doc2) backend.add_or_update(doc2)
results = backend.more_like_this(doc_id=50, user=None, page=1, page_size=10) ids = backend.more_like_this_ids(doc_id=150, user=None)
returned_ids = [hit["id"] for hit in results.hits] assert 150 not in ids
assert 50 not in returned_ids # Original document excluded assert 151 in ids
def test_with_user_applies_permission_filter(self, backend: TantivyBackend):
"""more_like_this with a user must exclude documents that user cannot see."""
viewer = User.objects.create_user("mlt_viewer")
other = User.objects.create_user("mlt_other")
public_doc = Document.objects.create(
title="Public financial document",
content="quarterly financial analysis report figures",
checksum="MLT3",
pk=52,
)
private_doc = Document.objects.create(
title="Private financial document",
content="quarterly financial analysis report figures",
checksum="MLT4",
pk=53,
owner=other,
)
backend.add_or_update(public_doc)
backend.add_or_update(private_doc)
results = backend.more_like_this(doc_id=52, user=viewer, page=1, page_size=10)
returned_ids = [hit["id"] for hit in results.hits]
# private_doc is owned by other, so viewer cannot see it
assert 53 not in returned_ids
def test_document_not_in_index_returns_empty(self, backend: TantivyBackend):
"""more_like_this for a doc_id absent from the index must return empty results."""
results = backend.more_like_this(doc_id=9999, user=None, page=1, page_size=10)
assert results.hits == []
assert results.total == 0
class TestSingleton: class TestSingleton:
@@ -593,19 +462,10 @@ class TestFieldHandling:
# Should not raise an exception # Should not raise an exception
backend.add_or_update(doc) backend.add_or_update(doc)
results = backend.search( assert len(backend.search_ids("test", user=None)) == 1
"test",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 1
def test_custom_fields_include_name_and_value(self, backend: TantivyBackend): def test_custom_fields_include_name_and_value(self, backend: TantivyBackend):
"""Custom fields must be indexed with both field name and value for structured queries.""" """Custom fields must be indexed with both field name and value for structured queries."""
# Create a custom field
field = CustomField.objects.create( field = CustomField.objects.create(
name="Invoice Number", name="Invoice Number",
data_type=CustomField.FieldDataType.STRING, data_type=CustomField.FieldDataType.STRING,
@@ -622,18 +482,9 @@ class TestFieldHandling:
value_text="INV-2024-001", value_text="INV-2024-001",
) )
# Should not raise an exception during indexing
backend.add_or_update(doc) backend.add_or_update(doc)
results = backend.search( assert len(backend.search_ids("invoice", user=None)) == 1
"invoice",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 1
def test_select_custom_field_indexes_label_not_id(self, backend: TantivyBackend): def test_select_custom_field_indexes_label_not_id(self, backend: TantivyBackend):
"""SELECT custom fields must index the human-readable label, not the opaque option ID.""" """SELECT custom fields must index the human-readable label, not the opaque option ID."""
@@ -660,27 +511,8 @@ class TestFieldHandling:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
# Label should be findable assert len(backend.search_ids("custom_fields.value:invoice", user=None)) == 1
results = backend.search( assert len(backend.search_ids("custom_fields.value:opt_abc", user=None)) == 0
"custom_fields.value:invoice",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 1
# Opaque ID must not appear in the index
results = backend.search(
"custom_fields.value:opt_abc",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 0
def test_none_custom_field_value_not_indexed(self, backend: TantivyBackend): def test_none_custom_field_value_not_indexed(self, backend: TantivyBackend):
"""Custom field instances with no value set must not produce an index entry.""" """Custom field instances with no value set must not produce an index entry."""
@@ -702,16 +534,7 @@ class TestFieldHandling:
) )
backend.add_or_update(doc) backend.add_or_update(doc)
# The string "none" must not appear as an indexed value assert len(backend.search_ids("custom_fields.value:none", user=None)) == 0
results = backend.search(
"custom_fields.value:none",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 0
def test_notes_include_user_information(self, backend: TantivyBackend): def test_notes_include_user_information(self, backend: TantivyBackend):
"""Notes must be indexed with user information when available for structured queries.""" """Notes must be indexed with user information when available for structured queries."""
@@ -724,32 +547,96 @@ class TestFieldHandling:
) )
Note.objects.create(document=doc, note="Important note", user=user) Note.objects.create(document=doc, note="Important note", user=user)
# Should not raise an exception during indexing
backend.add_or_update(doc) backend.add_or_update(doc)
# Test basic document search first ids = backend.search_ids("test", user=None)
results = backend.search( assert len(ids) == 1, (
"test", f"Expected 1, got {len(ids)}. Document content should be searchable."
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
)
assert results.total == 1, (
f"Expected 1, got {results.total}. Document content should be searchable."
) )
# Test notes search — must use structured JSON syntax now that note ids = backend.search_ids("notes.note:important", user=None)
# is no longer in DEFAULT_SEARCH_FIELDS assert len(ids) == 1, (
results = backend.search( f"Expected 1, got {len(ids)}. Note content should be searchable via notes.note: prefix."
"notes.note:important",
user=None,
page=1,
page_size=10,
sort_field=None,
sort_reverse=False,
) )
assert results.total == 1, (
f"Expected 1, got {results.total}. Note content should be searchable via notes.note: prefix."
class TestHighlightHits:
"""Test highlight_hits returns proper HTML strings, not raw Snippet objects."""
def test_highlights_content_returns_html_string(self, backend: TantivyBackend):
"""highlight_hits must return HTML strings (from Snippet.to_html()), not Snippet objects."""
doc = Document.objects.create(
title="Highlight Test",
content="The quick brown fox jumps over the lazy dog",
checksum="HH1",
pk=90,
) )
backend.add_or_update(doc)
hits = backend.highlight_hits("quick", [doc.pk])
assert len(hits) == 1
highlights = hits[0]["highlights"]
assert "content" in highlights
content_highlight = highlights["content"]
assert isinstance(content_highlight, str), (
f"Expected str, got {type(content_highlight)}: {content_highlight!r}"
)
# Tantivy wraps matched terms in <b> tags
assert "<b>" in content_highlight, (
f"Expected HTML with <b> tags, got: {content_highlight!r}"
)
def test_highlights_notes_returns_html_string(self, backend: TantivyBackend):
"""Note highlights must be HTML strings via notes_text companion field.
The notes JSON field does not support tantivy SnippetGenerator; the
notes_text plain-text field is used instead. We use the full-text
query "urgent" (not notes.note:) because notes_text IS in
DEFAULT_SEARCH_FIELDS via the normal search path… actually, we use
notes.note: prefix so the query targets notes content directly, but
the snippet is generated from notes_text which stores the same text.
"""
user = User.objects.create_user("hl_noteuser")
doc = Document.objects.create(
title="Doc with matching note",
content="unrelated content",
checksum="HH2",
pk=91,
)
Note.objects.create(document=doc, note="urgent payment required", user=user)
backend.add_or_update(doc)
# Use notes.note: prefix so the document matches the query and the
# notes_text snippet generator can produce highlights.
hits = backend.highlight_hits("notes.note:urgent", [doc.pk])
assert len(hits) == 1
highlights = hits[0]["highlights"]
assert "notes" in highlights
note_highlight = highlights["notes"]
assert isinstance(note_highlight, str), (
f"Expected str, got {type(note_highlight)}: {note_highlight!r}"
)
assert "<b>" in note_highlight, (
f"Expected HTML with <b> tags, got: {note_highlight!r}"
)
def test_empty_doc_list_returns_empty_hits(self, backend: TantivyBackend):
"""highlight_hits with no doc IDs must return an empty list."""
hits = backend.highlight_hits("anything", [])
assert hits == []
def test_no_highlights_when_no_match(self, backend: TantivyBackend):
"""Documents not matching the query should not appear in results."""
doc = Document.objects.create(
title="Unrelated",
content="completely different text",
checksum="HH3",
pk=92,
)
backend.add_or_update(doc)
hits = backend.highlight_hits("quick", [doc.pk])
assert len(hits) == 0
+120
View File
@@ -1503,6 +1503,126 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
[d2.id, d1.id, d3.id], [d2.id, d1.id, d3.id],
) )
def test_search_ordering_by_score(self) -> None:
"""ordering=-score must return results in descending relevance order (best first)."""
backend = get_backend()
# doc_high has more occurrences of the search term → higher BM25 score
doc_low = Document.objects.create(
title="score sort low",
content="apple",
checksum="SCL1",
)
doc_high = Document.objects.create(
title="score sort high",
content="apple apple apple apple apple",
checksum="SCH1",
)
backend.add_or_update(doc_low)
backend.add_or_update(doc_high)
# -score = descending = best first (highest score)
response = self.client.get("/api/documents/?query=apple&ordering=-score")
self.assertEqual(response.status_code, status.HTTP_200_OK)
ids = [r["id"] for r in response.data["results"]]
self.assertEqual(
ids[0],
doc_high.id,
"Most relevant doc should be first for -score",
)
# score = ascending = worst first (lowest score)
response = self.client.get("/api/documents/?query=apple&ordering=score")
self.assertEqual(response.status_code, status.HTTP_200_OK)
ids = [r["id"] for r in response.data["results"]]
self.assertEqual(
ids[0],
doc_low.id,
"Least relevant doc should be first for +score",
)
def test_search_with_tantivy_native_sort(self) -> None:
"""When ordering by a Tantivy-sortable field, results must be correctly sorted."""
backend = get_backend()
for i, asn in enumerate([30, 10, 20]):
doc = Document.objects.create(
title=f"sortable doc {i}",
content="searchable content",
checksum=f"TNS{i}",
archive_serial_number=asn,
)
backend.add_or_update(doc)
response = self.client.get(
"/api/documents/?query=searchable&ordering=archive_serial_number",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
asns = [doc["archive_serial_number"] for doc in response.data["results"]]
self.assertEqual(asns, [10, 20, 30])
response = self.client.get(
"/api/documents/?query=searchable&ordering=-archive_serial_number",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
asns = [doc["archive_serial_number"] for doc in response.data["results"]]
self.assertEqual(asns, [30, 20, 10])
def test_search_page_2_returns_correct_slice(self) -> None:
"""Page 2 must return the second slice, not overlap with page 1."""
backend = get_backend()
for i in range(10):
doc = Document.objects.create(
title=f"doc {i}",
content="paginated content",
checksum=f"PG2{i}",
archive_serial_number=i + 1,
)
backend.add_or_update(doc)
response = self.client.get(
"/api/documents/?query=paginated&ordering=archive_serial_number&page=1&page_size=3",
)
page1_ids = [r["id"] for r in response.data["results"]]
self.assertEqual(len(page1_ids), 3)
response = self.client.get(
"/api/documents/?query=paginated&ordering=archive_serial_number&page=2&page_size=3",
)
page2_ids = [r["id"] for r in response.data["results"]]
self.assertEqual(len(page2_ids), 3)
# No overlap between pages
self.assertEqual(set(page1_ids) & set(page2_ids), set())
# Page 2 ASNs are higher than page 1
page1_asns = [
Document.objects.get(pk=pk).archive_serial_number for pk in page1_ids
]
page2_asns = [
Document.objects.get(pk=pk).archive_serial_number for pk in page2_ids
]
self.assertTrue(max(page1_asns) < min(page2_asns))
def test_search_all_field_contains_all_ids_when_paginated(self) -> None:
"""The 'all' field must contain every matching ID, even when paginated."""
backend = get_backend()
doc_ids = []
for i in range(10):
doc = Document.objects.create(
title=f"all field doc {i}",
content="allfield content",
checksum=f"AF{i}",
)
backend.add_or_update(doc)
doc_ids.append(doc.pk)
response = self.client.get(
"/api/documents/?query=allfield&page=1&page_size=3",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
# "all" must contain ALL 10 matching IDs
self.assertCountEqual(response.data["all"], doc_ids)
@mock.patch("documents.bulk_edit.bulk_update_documents") @mock.patch("documents.bulk_edit.bulk_update_documents")
def test_global_search(self, m) -> None: def test_global_search(self, m) -> None:
""" """
+177 -84
View File
@@ -38,6 +38,7 @@ from django.db.models import Model
from django.db.models import OuterRef from django.db.models import OuterRef
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
from django.db.models import QuerySet
from django.db.models import Subquery from django.db.models import Subquery
from django.db.models import Sum from django.db.models import Sum
from django.db.models import When from django.db.models import When
@@ -249,6 +250,13 @@ if settings.AUDIT_LOG_ENABLED:
logger = logging.getLogger("paperless.api") logger = logging.getLogger("paperless.api")
# Crossover point for intersect_and_order: below this count use a targeted
# IN-clause query; at or above this count fall back to a full-table scan +
# Python set intersection. The IN-clause is faster for small result sets but
# degrades on SQLite with thousands of parameters. PostgreSQL handles large IN
# clauses efficiently, so this threshold mainly protects SQLite users.
_TANTIVY_INTERSECT_THRESHOLD = 5_000
class IndexView(TemplateView): class IndexView(TemplateView):
template_name = "index.html" template_name = "index.html"
@@ -2077,19 +2085,16 @@ class UnifiedSearchViewSet(DocumentViewSet):
if not self._is_search_request(): if not self._is_search_request():
return super().list(request) return super().list(request)
from documents.search import SearchHit
from documents.search import SearchMode from documents.search import SearchMode
from documents.search import TantivyBackend
from documents.search import TantivyRelevanceList from documents.search import TantivyRelevanceList
from documents.search import get_backend from documents.search import get_backend
try: def parse_search_params() -> tuple[str | None, bool, bool, int, int]:
backend = get_backend() """Extract query string, search mode, and ordering from request."""
# ORM-filtered queryset: permissions + field filters + ordering (DRF backends applied) active = self._get_active_search_params(request)
filtered_qs = self.filter_queryset(self.get_queryset()) if len(active) > 1:
user = None if request.user.is_superuser else request.user
active_search_params = self._get_active_search_params(request)
if len(active_search_params) > 1:
raise ValidationError( raise ValidationError(
{ {
"detail": _( "detail": _(
@@ -2098,73 +2103,161 @@ class UnifiedSearchViewSet(DocumentViewSet):
}, },
) )
if ( ordering_param = request.query_params.get("ordering", "")
"text" in request.query_params sort_reverse = ordering_param.startswith("-")
or "title_search" in request.query_params sort_field_name = ordering_param.lstrip("-") or None
or "query" in request.query_params # "score" means relevance order — Tantivy handles it natively,
): # so treat it as a Tantivy sort to preserve the ranked order through
if "text" in request.query_params: # the ORM intersection step.
search_mode = SearchMode.TEXT use_tantivy_sort = (
query_str = request.query_params["text"] sort_field_name in TantivyBackend.SORTABLE_FIELDS
elif "title_search" in request.query_params: or sort_field_name is None
search_mode = SearchMode.TITLE or sort_field_name == "score"
query_str = request.query_params["title_search"] )
else:
search_mode = SearchMode.QUERY try:
query_str = request.query_params["query"] page_num = int(request.query_params.get("page", 1))
results = backend.search( except (TypeError, ValueError):
query_str, page_num = 1
user=user, page_size = (
page=1, self.paginator.get_page_size(request) or self.paginator.page_size
page_size=10000, )
sort_field=None,
sort_reverse=False, return sort_field_name, sort_reverse, use_tantivy_sort, page_num, page_size
search_mode=search_mode,
) def intersect_and_order(
else: all_ids: list[int],
# more_like_id — validate permission on the seed document first filtered_qs: QuerySet[Document],
try: *,
more_like_doc_id = int(request.query_params["more_like_id"]) use_tantivy_sort: bool,
more_like_doc = Document.objects.select_related("owner").get( ) -> list[int]:
pk=more_like_doc_id, """Intersect search IDs with ORM-visible IDs, preserving order."""
if not all_ids:
return []
if use_tantivy_sort:
if len(all_ids) <= _TANTIVY_INTERSECT_THRESHOLD:
# Small result set: targeted IN-clause avoids a full-table scan.
visible_ids = set(
filtered_qs.filter(pk__in=all_ids).values_list("pk", flat=True),
) )
except (TypeError, ValueError, Document.DoesNotExist): else:
raise PermissionDenied(_("Invalid more_like_id")) # Large result set: full-table scan + Python intersection is faster
# than a large IN-clause on SQLite.
visible_ids = set(
filtered_qs.values_list("pk", flat=True),
)
return [doc_id for doc_id in all_ids if doc_id in visible_ids]
return list(
filtered_qs.filter(id__in=all_ids).values_list("pk", flat=True),
)
if not has_perms_owner_aware( def run_text_search(
request.user, backend: TantivyBackend,
"view_document", user: User | None,
more_like_doc, filtered_qs: QuerySet[Document],
): ) -> tuple[list[int], list[SearchHit], int]:
raise PermissionDenied(_("Insufficient permissions.")) """Handle text/title/query search: IDs, ORM intersection, page highlights."""
if "text" in request.query_params:
results = backend.more_like_this( search_mode = SearchMode.TEXT
more_like_doc_id, query_str = request.query_params["text"]
user=user, elif "title_search" in request.query_params:
page=1, search_mode = SearchMode.TITLE
page_size=10000, query_str = request.query_params["title_search"]
)
hits_by_id = {h["id"]: h for h in results.hits}
# Determine sort order: no ordering param -> Tantivy relevance; otherwise -> ORM order
ordering_param = request.query_params.get("ordering", "").lstrip("-")
if not ordering_param:
# Preserve Tantivy relevance order; intersect with ORM-visible IDs
orm_ids = set(filtered_qs.values_list("pk", flat=True))
ordered_hits = [h for h in results.hits if h["id"] in orm_ids]
else: else:
# Use ORM ordering (already applied by DocumentsOrderingFilter) search_mode = SearchMode.QUERY
hit_ids = set(hits_by_id.keys()) query_str = request.query_params["query"]
orm_ordered_ids = filtered_qs.filter(id__in=hit_ids).values_list(
"pk",
flat=True,
)
ordered_hits = [
hits_by_id[pk] for pk in orm_ordered_ids if pk in hits_by_id
]
rl = TantivyRelevanceList(ordered_hits) # "score" is not a real Tantivy sort field — it means relevance order,
# which is Tantivy's default when no sort field is specified.
is_score_sort = sort_field_name == "score"
all_ids = backend.search_ids(
query_str,
user=user,
sort_field=(
None if (not use_tantivy_sort or is_score_sort) else sort_field_name
),
sort_reverse=sort_reverse,
search_mode=search_mode,
)
ordered_ids = intersect_and_order(
all_ids,
filtered_qs,
use_tantivy_sort=use_tantivy_sort,
)
# Tantivy returns relevance results best-first (descending score).
# ordering=score (ascending, worst-first) requires a reversal.
if is_score_sort and not sort_reverse:
ordered_ids = list(reversed(ordered_ids))
page_offset = (page_num - 1) * page_size
page_ids = ordered_ids[page_offset : page_offset + page_size]
page_hits = backend.highlight_hits(
query_str,
page_ids,
search_mode=search_mode,
rank_start=page_offset + 1,
)
return ordered_ids, page_hits, page_offset
def run_more_like_this(
backend: TantivyBackend,
user: User | None,
filtered_qs: QuerySet[Document],
) -> tuple[list[int], list[SearchHit], int]:
"""Handle more_like_id search: permission check, IDs, stub hits."""
try:
more_like_doc_id = int(request.query_params["more_like_id"])
more_like_doc = Document.objects.select_related("owner").get(
pk=more_like_doc_id,
)
except (TypeError, ValueError, Document.DoesNotExist):
raise PermissionDenied(_("Invalid more_like_id"))
if not has_perms_owner_aware(
request.user,
"view_document",
more_like_doc,
):
raise PermissionDenied(_("Insufficient permissions."))
all_ids = backend.more_like_this_ids(more_like_doc_id, user=user)
ordered_ids = intersect_and_order(
all_ids,
filtered_qs,
use_tantivy_sort=True,
)
page_offset = (page_num - 1) * page_size
page_ids = ordered_ids[page_offset : page_offset + page_size]
page_hits = [
SearchHit(id=doc_id, score=0.0, rank=rank, highlights={})
for rank, doc_id in enumerate(page_ids, start=page_offset + 1)
]
return ordered_ids, page_hits, page_offset
try:
sort_field_name, sort_reverse, use_tantivy_sort, page_num, page_size = (
parse_search_params()
)
backend = get_backend()
filtered_qs = self.filter_queryset(self.get_queryset())
user = None if request.user.is_superuser else request.user
if "more_like_id" in request.query_params:
ordered_ids, page_hits, page_offset = run_more_like_this(
backend,
user,
filtered_qs,
)
else:
ordered_ids, page_hits, page_offset = run_text_search(
backend,
user,
filtered_qs,
)
rl = TantivyRelevanceList(ordered_ids, page_hits, page_offset)
page = self.paginate_queryset(rl) page = self.paginate_queryset(rl)
if page is not None: if page is not None:
@@ -2174,15 +2267,18 @@ class UnifiedSearchViewSet(DocumentViewSet):
if get_boolean( if get_boolean(
str(request.query_params.get("include_selection_data", "false")), str(request.query_params.get("include_selection_data", "false")),
): ):
all_ids = [h["id"] for h in ordered_hits] # NOTE: pk__in=ordered_ids generates a large SQL IN clause
# for big result sets. Acceptable today but may need a temp
# table or chunked approach if selection_data becomes slow
# at scale (tens of thousands of matching documents).
response.data["selection_data"] = ( response.data["selection_data"] = (
self._get_selection_data_for_queryset( self._get_selection_data_for_queryset(
filtered_qs.filter(pk__in=all_ids), filtered_qs.filter(pk__in=ordered_ids),
) )
) )
return response return response
serializer = self.get_serializer(ordered_hits, many=True) serializer = self.get_serializer(page_hits, many=True)
return Response(serializer.data) return Response(serializer.data)
except NotFound: except NotFound:
@@ -3088,20 +3184,17 @@ class GlobalSearchView(PassUserMixin):
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT] docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
else: else:
user = None if request.user.is_superuser else request.user user = None if request.user.is_superuser else request.user
fts_results = get_backend().search( matching_ids = get_backend().search_ids(
query, query,
user=user, user=user,
page=1,
page_size=1000,
sort_field=None,
sort_reverse=False,
search_mode=SearchMode.TEXT, search_mode=SearchMode.TEXT,
limit=OBJECT_LIMIT * 3,
) )
docs_by_id = all_docs.in_bulk([hit["id"] for hit in fts_results.hits]) docs_by_id = all_docs.in_bulk(matching_ids)
docs = [ docs = [
docs_by_id[hit["id"]] docs_by_id[doc_id]
for hit in fts_results.hits for doc_id in matching_ids
if hit["id"] in docs_by_id if doc_id in docs_by_id
][:OBJECT_LIMIT] ][:OBJECT_LIMIT]
saved_views = ( saved_views = (
get_objects_for_user_owner_aware( get_objects_for_user_owner_aware(
+1 -1
View File
@@ -90,7 +90,7 @@ class StandardPagination(PageNumberPagination):
query = self.page.paginator.object_list query = self.page.paginator.object_list
if isinstance(query, TantivyRelevanceList): if isinstance(query, TantivyRelevanceList):
return [h["id"] for h in query._hits] return query.get_all_ids()
return self.page.paginator.object_list.values_list("pk", flat=True) return self.page.paginator.object_list.values_list("pk", flat=True)
def get_paginated_response_schema(self, schema): def get_paginated_response_schema(self, schema):