Commit Graph
33 Commits
Author SHA1 Message Date
Trenton Holmes 86c21826bb docs(search): document the quote-blindness trade-off in the pre-parse rewrites
_quote_date_keyword_phrases and _rewrite_bare_json_field_prefixes both
regex-match anywhere in raw_query, with no awareness of whether the match
falls inside an already-quoted phrase on an unrelated field. Unlikely in
practice and not fixed (quote-aware scanning is real work for an edge
case), but now called out explicitly like this file's other accepted
trade-offs, instead of being the one undocumented one.
2026-08-18 14:14:57 -07:00
Trenton Holmes 9e394ed914 refactor(search): log the CJK clause's skip path like the fuzzy clause's
_build_cjk_query silently swallowed a parse failure with no log line,
while _try_parse_fuzzy_query logs at debug for the same "skip this
optional clause" situation. Add the matching debug log.

Deliberately NOT narrowing except Exception to except ValueError here to
match the fuzzy path: the fuzzy blend's word string is pre-filtered to
\\w+-only tokens before it ever reaches index.parse_query, so ValueError
is the only realistic failure mode there. cjk_text has no equivalent
filter, so narrowing this catch without verifying tantivy's actual
exception behavior for CJK input would risk letting something other than
ValueError propagate uncaught - the same class of mistake as the fuzzy
blend regression this migration already fixed once, in the other
direction.
2026-08-18 14:11:11 -07:00
Trenton Holmes 432c13430a refactor(search): split error classes and build_permission_filter out of _query.py
_query.py mixed three unrelated responsibilities: the SearchQueryError
family (paperless's public error-surface API, re-exported by __init__.py),
the actual query rewrite/parse/emit/blend pipeline, and
build_permission_filter, which has nothing to do with query parsing and
is consumed only by _backend.py.

- New _errors.py: SearchQueryError, InvalidDateQuery, InvalidNumberQuery,
  MultipleSearchQueryErrors, search_query_error_messages. _query.py now
  imports these instead of defining them.
- build_permission_filter moves to _backend.py, next to its one caller
  (TantivyBackend._build_permission_filter).
- __init__.py re-exports the error classes from _errors.py instead of
  _query.py; the package's public API (documents.search import ...) is
  unchanged for every caller going through it (views.py etc.).

_query.py now reads top-to-bottom as rewrite -> parse -> emit -> blend,
matching what parse_user_query's own docstring already claimed the file
was.
2026-08-18 14:09:02 -07:00
Trenton Holmes 289b50a0ad refactor(search): underscore-prefix and Final-type the module-private field lists
DEFAULT_SEARCH_FIELDS/SIMPLE_SEARCH_FIELDS/TITLE_SEARCH_FIELDS looked
public but are only ever used inside _query.py itself, sitting next to
underscore-prefixed constants at the same scope (_CJK_ALL_FIELDS etc.).
Rename to match, and add Final like their neighbors already have.
2026-08-18 13:59:26 -07:00
Trenton Holmes f272b74b18 refactor(search): delete the _simple_query_tokens pass-through wrapper
It called simple_search_tokens() and nothing else, with a comment
duplicating that function's own docstring. Call sites now call
simple_search_tokens() directly.
2026-08-18 13:33:34 -07:00
Trenton Holmes 482a1c1780 refactor(search): extract _any_of to collapse the single-clause boolean idiom
Four call sites in _query.py each hand-rolled "no clauses -> empty, one
clause -> return it bare, many -> wrap in boolean_query" - one of them also
handling the empty case, one written as a ternary, one returning a captured
variable instead of clauses[0][1] (same value, different spelling). Extract
_any_of() so the collapsing logic and its rationale (skip a wasted
single-clause boolean_query wrap) live in one place.
2026-08-18 13:31:43 -07:00
Trenton Holmes f6866828ed refactor(search): reuse extract_cjk_text in _build_cjk_query
_build_cjk_query re-derived the same CJK-run extraction extract_cjk_text
already implements, despite a docstring claiming they mirror each other.
Call it directly so the mirroring is structural, not a copy to keep in sync.
2026-08-18 13:25:45 -07:00
Trenton HolmesandClaude Sonnet 5 194c2bce48 test(search): trim acceptance tests that pin whoosh-compat behavior, not ours
Deletes TestCommaValueLists, TestMultitokenInNestedOr, TestRfc3339TZDateRange,
TestCreatedTimezoneInvariance, and TestReversedDateRange: none of them
exercise any paperless-specific pre/post-processing code. Comma-list AND
semantics, multitoken resolution, RFC3339 T/Z UTC math, date-only timezone
invariance, and reversed-range disambiguation are all entirely
whoosh-compat's own grammar/semantics, already covered by its own test
suite. The comma_values flag paperless does own is still covered cheaply in
test_fields.py; the date_only flag is still covered in test_registry.py.

Also trims verbose docstrings/comments across _query.py and the surviving
acceptance tests: cuts references to whoosh-compat's internal
DIVERGENCES.md entry numbers and paperless v2/Whoosh-era implementation
history down to the user-facing behavior that actually matters, without
losing the substance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVj8NFy821G3YhNf68PF6X
2026-08-18 11:24:14 -07:00
Trenton HolmesandClaude Fable 5 1cb07030b0 fix(search): complete the query error surface across every endpoint
Four pieces of the same surface:

whoosh-compat's emit() documents a two-part host contract: both a parse
diagnostic and the QueryEmitError/UnsupportedQueryError pair are
user-input errors. Only the latter half was caught; QueryEmitError now
maps to SearchQueryError too. Messages pass through a cleanup that
strips the library's DIVERGENCES.md references and replaces the
fast=True host-configuration advice with user language, so no
library-internal vocabulary reaches a searching user.

The bulk selection paths (bulk edit, the legacy bulk endpoint, bulk
download) reached the backend with no SearchQueryError handler, so a
bad date or number in a selection filter raised straight to a DRF 500.
They now share the search list endpoint's exact mapping (a new
search_query_error_messages helper flattens MultipleSearchQueryErrors
in one place), returning the same 400 body for the same bad query.

QueryParserError means a whoosh-compat parser bug, not user-fixable
input, per its own contract; the list endpoint's blanket handler was
converting it to a generic 400. It now re-raises and surfaces as a 500
that monitoring can see.

All behavior is pinned test-first: bulk edit and bulk download API
tests assert 400s naming the bad value (previously unhandled
exceptions), a unit test pins the QueryEmitError mapping, three
parametrized checks assert no internal vocabulary leaks for the
unsupported query shapes, and a mocked parser-bug test asserts the 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
2026-08-18 11:05:04 -07:00
Trenton HolmesandClaude Fable 5 171b0a6f77 fix(search): rewrite bare notes:/custom_fields: prefixes to their subpaths
The v2 whoosh schema had plural notes/custom_fields TEXT fields (notes
indexed the joined note texts, custom_fields indexed joined
"name : value" strings), so "notes:foo" and "custom_fields:foo" were
valid fielded searches in released paperless and through the deleted
translation layer. On the whoosh-compat registry those names are JSON
fields addressable only via subpaths, and the bare spelling silently
demoted to an unfielded text search of the words themselves, matching
unrelated documents that merely contain "notes" or "custom".

parse_user_query now rewrites the bare prefixes live to the same
targets migration 0017 chose for the singular whoosh-era spellings:
notes: becomes notes.note: and custom_fields: becomes
custom_fields.value:, with 0017's lookbehind guard so subpath spellings
and words merely ending in the prefix are untouched. Prefix
substitution only; values ride through unchanged, and every value shape
lands in a documented outcome downstream (ranges, wildcards and exists
on JSON subpaths are typed errors, not crashes). The inherited
trade-off stands: custom_fields.value: drops the name-matching half of
v2's combined indexing, with custom_fields.name: available for it.

Acceptance tests pin the rewrite with decoy documents whose content
contains the literal prefix words, which the old demotion matched and
the fielded search must not, plus untouched-subpath controls.
docs/usage.md documents the bare prefixes as subpath shorthand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
2026-08-18 11:05:04 -07:00
Trenton HolmesandClaude Fable 5 7bab9622c8 fix(search): restore unquoted multi-word date keywords via pre-parse quoting
"added:previous month" returned HTTP 400 after the whoosh-compat
migration. The unquoted spelling was never parser-native anywhere: v2
rewrote it to explicit bracket ranges app-side before whoosh saw the
string, and the deleted translation layer consumed it itself, so users
and saved views have relied on it continuously while whoosh-compat
deliberately scopes it out of its parser (its DIVERGENCES.md entry 19)
and understands the phrases natively only as quoted values.

parse_user_query now quotes the closed six-phrase vocabulary (previous
week/month/quarter/year, this month/year) when it directly follows a
date field's colon, before parsing. Only quoting happens app-side; every
date computation stays in whoosh-compat's grammar, unlike v2's rewrite,
which computed the ranges itself. Date field names derive from
PUBLIC_FIELDS, the field name matches case-sensitively (the parser's own
field tagging is case-sensitive), the phrase case-insensitively (the
grammar accepts any case in the quoted form), and already-quoted
spellings, TEXT fields, unfielded words and bracketed ranges are
untouched.

The previously xfailed end-to-end regression test now passes as a plain
test, and a new acceptance class pins unquoted == quoted == mixed-case
result sets on a boundary fixture, no-error parsing for the whole
vocabulary across all three date fields, and that "title:previous month"
stays an ordinary text search. docs/usage.md now states the two
spellings are equivalent after a date field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
2026-08-18 11:05:04 -07:00
Trenton HolmesandClaude Fable 5 a717684a60 fix(search): build the fuzzy blend from parsed free-text tokens, not the raw query
The fuzzy blend clause handed the raw query string to tantivy's own
parser, which rejects whoosh-only grammar (date keywords, whoosh ranges,
aliases needing resolution), so any mixed query silently lost its fuzzy
clause: a typo'd word beside "added:today" stopped matching the moment
the date keyword appeared, while the same typo without it still matched.
Before the whoosh-compat migration the parser received the translated
string, so fuzzy survived mixed queries.

The clause is now built from whoosh_compat.free_text_tokens over the
already-parsed AST: the query's free-text words, analyzed, deduplicated,
with negated terms excluded so a NOT'd word cannot resurface through the
fuzzy clause. The joined word string is always plain tokens, so tantivy
always parses it; a defensive word-character filter guards any future
field whose analyzer passes punctuation through, and the ValueError skip
remains as insurance. One chosen trade-off is documented in the
docstring: a term fielded on a default search field contributes its text
unfielded, widening fuzzy recall on the 0.1-boosted secondary clause.

Two result-level acceptance tests pin the behavior: the mixed
typo-plus-date-keyword query matches its document again, and a NOT'd
word does not fuzzy-resurface (shaped so the assertion genuinely fails
under a naive all-words implementation: the excluded word's document is
the only candidate hit, so score normalization cannot mask it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMsn6DgzbvSqh1pwy66VVF
2026-08-18 11:05:04 -07:00
Trenton Holmes eaa6dc1eed fix: skip fuzzy search blend when raw query isn't tantivy-parseable
The fuzzy blend clause in parse_user_query() fed the raw, whoosh-syntax
query string directly to tantivy's own query parser. Since the
whoosh-compat migration, raw_query still contains whoosh grammar (date
keywords, whoosh-style ranges, bracket-class wildcards) that tantivy's
parser rejects with ValueError, which escaped parse_user_query and
turned into a generic HTTP 400 for the entire query whenever
ADVANCED_FUZZY_SEARCH_THRESHOLD was configured.

Deriving a clean plain-text-only extraction for the fuzzy clause was
ruled out: wc.parse() already expands unfielded terms into per-default-
field copies in the AST, so there's no "still unfielded" marker left to
walk without duplicating whoosh-compat's own expansion logic. Instead,
scope a narrow try/except ValueError around exactly the
index.parse_query() call and skip the fuzzy clause (logged at debug)
when it can't parse, leaving the exact/CJK clauses unaffected.
2026-08-18 11:05:04 -07:00
Trenton HolmesandClaude Sonnet 5 d7ccff138b feat(search): route parse_user_query through whoosh-compat
Rewires parse_user_query() to parse via wc.parse()/tantivy_emit() against
the shared FieldRegistry instead of the string-based translate_query()
pipeline, so diagnostics map to typed SearchQueryError subclasses
(InvalidDateQuery/InvalidNumberQuery/MultipleSearchQueryErrors) and every
bad field is reported, not just the first.

Marks three pre-existing tests xfail (2 in test_query.py, 1 in
test_api_search.py) for confirmed whoosh-compat grammar gaps found while
verifying this rewrite: unquoted multi-word date keywords (e.g.
`added:previous month`) and RFC3339 T/Z datetime range bounds no longer
parse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 11:05:04 -07:00
Trenton HolmesandClaude Sonnet 5 4577a0a00a refactor(search): move SearchQueryError family to _query.py, add InvalidNumberQuery/MultipleSearchQueryErrors
Move SearchQueryError and InvalidDateQuery from _translate.py to _query.py and
add two new exception classes: InvalidNumberQuery and MultipleSearchQueryErrors.
Update _translate.py to re-export the exceptions for backward compatibility
until the translation module is removed. Update __init__.py to export all
four exception classes from _query.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 11:05:04 -07:00
shamoonandGitHub cfa1d3b058 Fix: correct multi-search non-adjacent queries (#13504) 2026-08-03 15:03:03 +00:00
Trenton HandGitHub 30fe172847 Chore: Drops the search shims (#13433) 2026-07-30 14:18:35 -07:00
81795bc93a Security (beta): enforce current permissions in autocomplete (#13188)
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-07-22 08:07:43 -07:00
df1ddb15cc Performance: Tantivy indexing optimization (#13053)
* Tantivy: get permissions by chunks

-40% indexing time compared to previous commit

* Make progress bar process one by one with chunk

-15% indexing time compared to previous commit

* Prefetch FK + iterate over chunk from SQL

Prefetch additional needed data (note user, custom field content)

-20% indexing time compared to previous commit

* Reindex: increase Tantivy heap size from 128 to 512MB

Gains probably vary depending on the machine,
but it seems a sweet spot compatible with low-end hardware.

* Reindex: optimization on permission fetching and autocomplete word set

-10% indexing time compared to previous commit

* Autocomplete analyzer python->rust

Splits words with underscore compared to the python analyzer.
E.g.: "blue_print" -> ["blue", "print"]
It can still be found with the "blue_print" keyword,
as the search string is also split in two words.

-50% indexing time compared to previous commit (indexing is twice faster!)

* Index bigram for CJK content only

Inedxing time slightly longer (~3%),
but since the non-CJK content is not indexed,
bigram searchs will be slightly optimized.

* Fix group-based view_document permissions missing from bulk rebuild

_bulk_get_viewer_ids only queried UserObjectPermission, dropping the
group-permission expansion that get_users_with_perms(with_group_users=True)
performs for the non-batched per-document indexing path. A user who could
only see a document via group membership would lose search access to it
after any full reindex.

Also query GroupObjectPermission and expand group membership to user ids,
matching the existing single-document behavior.

* Yield (document, viewer_ids) pairs from _DocumentViewerStream

Previously _DocumentViewerStream.__iter__ yielded plain Document objects
while the matching viewer ids were exposed through a separate mutable
attribute (viewer_ids_by_pk), overwritten each time the generator crossed
a chunk boundary. rebuild() read that attribute out-of-band per document.

This only worked because the current iter_wrapper (a plain progress-bar
passthrough) happens to consume the stream in strict lock-step with no
lookahead. Any wrapper that buffers, batches, or reorders would silently
pair a document with the wrong chunk's viewer ids. Yield the pair directly
so the association travels with the document regardless of how iter_wrapper
consumes the stream, and drop the now-unneeded viewer_ids_by_pk attribute.

* Add --heap-size-mb CLI arg to document_index reindex

writer_heap_bytes was hardcoded at 512MB with no way to tune it. Expose it
as a manual-rebuild-only CLI arg rather than a settings/env var, per review
feedback, so lower-memory hosts can reduce it without a wider config
surface. Defaults to unset so TantivyBackend.rebuild's own default stays
the single source of truth.

---------

Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-07-17 11:33:09 -07:00
f4fa916579 Fix (beta): restore v2 (Whoosh) advanced-search query compatibility (#13010)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:32:44 -07:00
Trenton HandGitHub 889ccfd67a Fix: Fold query and autocomplete terms with Tantivy's ascii_fold so special letters match (#12868) 2026-05-29 16:42:07 -07:00
Trenton HandGitHub 98a7ed32e3 Fix: Preserve Whoosh date range swapping in Tantviy (#12866) 2026-05-29 20:21:59 +00:00
Trenton HandGitHub 97e3c75720 Fix: Handle CJK title, content and metadata searching (#12862) 2026-05-29 19:11:55 +00:00
Trenton HandGitHub 11c62757ef Fix: Restrict date query rewrites to date or datetime fields only (#12864) 2026-05-29 11:59:30 -07:00
Trenton HandGitHub 7e381f204e Fix: Sanitize dash or plus from the text search path (#12789) 2026-05-12 12:41:38 -07:00
Trenton HandGitHub 9a1e2aea50 Fix: Handle dash or plus operators in search queries (#12734) 2026-05-07 17:26:11 +00:00
Trenton HandGitHub 2296d7fa0e Fix: Rewrite Whoosh year only queries to be to Tantivy date syntax (#12725) 2026-05-06 09:26:46 -07:00
Trenton HandGitHub 493d282059 Chore: Upgrades tantivy-py to the latest release (#12605) 2026-04-29 10:09:50 -07:00
shamoonandGitHub f784a74eba Enhancement: add highlighting to title + content searches (#12593) 2026-04-20 21:28:02 +00:00
shamoonandGitHub 20aa0937e8 Fix (dev): retain backwards compatibility with natural-date keywords in tantivy (#12602) 2026-04-20 08:26:33 -07:00
3ffbb8862c 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>
2026-04-15 23:20:31 +00:00
shamoonandGitHub 566afdffca Enhancement: unify text search to use tantivy (#12485) 2026-04-03 13:53:45 -07:00
aed9abe48c Feature: Replace Whoosh with tantivy search backend (#12471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Antoine Mérino <3023499+Merinorus@users.noreply.github.com>
2026-04-02 12:38:22 -07:00