Repository navigation
Optional impact-ordered lexeme rank index for single-word FTS ranking - #287
samuelvkwong wants to merge 2 commits into
Conversation
Precompute each report's per-lexeme ts_rank into pgsearch_lexemerank (trigger-maintained, opt-in via HYBRID_FTS_LEXEME_RANK_INDEX + manage.py sync_lexeme_ranks) and serve the FTS arm of single-word queries from its (lexeme, rank DESC) btree instead of ranking every match at query time. Multi-word queries keep the ts_rank path: their combined score exists only per query, and the early-termination walk that would make them fast (WAND-style pruning) is not expressible in SQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDei6anDxfR5eoHhFfXBGs
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDei6anDxfR5eoHhFfXBGs
|
Live results from the staging deployment (1.7 M-report corpus, full backfill: 139,885,956 rank rows / 28 GB, ~10.5 h single worker; trigger active throughout):
The plan is the intended one: index-only scan of One operational note: run 🤖 Generated with Claude Code |
What
An opt-in fast path for the FTS arm:
pgsearch_lexemerankstores, for every report and every lexeme of its tsvector, the exactts_rankvalue a single-term query of that lexeme would score — an impact-ordered posting list. With the flag on, a single-word search reads its topHYBRID_FTS_MAX_RESULTSrows straight off the(lexeme, rank DESC, report)btree instead of computingts_rankover every matching row and sorting. Every other query shape (multiple words, phrases, boolean operators) keeps the existingts_rankpath — deliberately, see below.Ranking is exact-parity, not approximate: a row exists iff the lexeme is in the document's tsvector (membership ≡
search_vector @@ lexeme), and the stored value is the very number the fallback would compute, so the ordering is identical and RRF fusion downstream is unchanged.Measured on the staging corpus (1.7 M reports), against the per-arm timings of #286:
fraktur(414,572 matches)fraktur & befund(befund: 1.6 M matches)¹ spike measurement of the posting-list join; this PR does not even attempt it (see limitations).
Why this works for single-term queries
ts_rank's cost problem is when it runs: it can only score a document given the full tsvector, so the ranked query detoasts and ranks every match beforeORDER BY … LIMITcan cut the list (11–13 s for common German terms; the GIN index can confirm membership cheaply but cannot return rows in rank order).For a single-term query, though, the score is a pure function of
(document, lexeme)— nothing else in the query influences it. That makes it precomputable at write time: the sync trigger unnests the tsvector and storests_rank(search_vector, 'lexeme')per lexeme once per report write (amortized ~µs–ms per report; the spike built all 414 kfrakturrows in 5.6 s). At query time "top N documents for this word" is then a bounded walk of the(lexeme, rank DESC)btree — the index is the sort.Why this cannot work for multi-term queries
A multi-term score is a per-query aggregate: for
fraktur & befund, a document's rank is the combination of both contributions, and it only exists for documents satisfying the boolean expression. Precomputing it for every possible term combination is combinatorially impossible, so it must be assembled at query time:frakturposting list with the 1.6 M-rowbefundlist, combine ranks, sort, take the top N. Measured: ~4.8 s — barely better thants_rank's 6.4 s, because the cost was never the per-document arithmetic; it is touching every candidate before the limit.f.rank + max(remaining b.rank) < threshold", and PostgreSQL's executor has no operator that prunes one scan using another scan's running top-N."a <-> b"cannot be verified from it;NOT/ORchange membership per query. All of these shapes short-circuit to thets_rankpath before touching the table.This boundary is exactly where hand-rolling ends and #285 (pg_textsearch) begins: the extension keeps impact-ordered postings and implements Block-Max WAND in C, generalizing this PR's 27 ms to arbitrary boolean/multi-term queries, with real corpus IDF on top. The numbers above double as evidence for it — they demonstrate the achievable ceiling on this corpus and why SQL alone cannot reach it beyond one term.
Design notes
HYBRID_FTS_LEXEME_RANK_INDEX=falseby default; nothing changes for existing deployments (table is created empty, no trigger, no write cost)../manage.py sync_lexeme_ranksonce (installs the sync trigger, backfills in batches, resumable;--rebuild,--removeprovided).pgsearch.E006failsmigratewhen the flag is on but nothing maintains the table; at runtime the provider probes for the trigger once per process and falls back tots_rankwith a WARNING rather than serve from a stale table.ts_rankbecause they have few matches.UPDATE … SET embedding) do not fire the trigger.Tests
10 new tests in
test_lexeme_rank.py: exact order/count parity with thets_rankpath (single-language and all-configs), stored value ==ts_rankvalue, dispatch (multi-term/phrase/stopword/flag-off never touch the table), trigger freshness on create/update, missing-trigger fallback + warning,--remove. Fullpgsearch+searchsuites: 248 passed.🤖 Generated with Claude Code
https://claude.ai/code/session_01VDei6anDxfR5eoHhFfXBGs