fix: give each query lexeme its own BM25 candidate budget - #279
Conversation
The FTS prefilter bounded candidates with a single global ts_rank_cd ordering. ts_rank_cd scores term density inside a chunk and ignores how rare a term is across the corpus, while BM25 weights rare terms heavily. A short chunk holding the one rare term in a query therefore sorts near the bottom of that ordering and is truncated first, even though BM25 ranks it top. Measured on 5001 chunks where 5000 densely repeat a common term and one holds a rare term: the rare chunk ranks 5001 of 5001 under ts_rank_cd and 1 of 5001 under rank_rows_by_bm25, so a 2000 candidate limit dropped the best match before BM25 ran. Each lexeme now draws from its own share of the budget through a lateral join, so a lexeme matching few chunks always contributes them. A floor keeps many-lexeme queries from dividing the budget into slivers. The same corpus now yields 1001 candidates including the rare chunk, fewer rows than the old path loaded while keeping the match that matters. Also logs a warning when the pool saturates. The debug line reported candidates == limit whether the corpus held exactly that many or far more, so silent truncation looked identical to a healthy query. The bounded-prefilter test asserted on the literal LIMIT clause as a position marker. Its intent, that scope filters land ahead of any candidate bound, is unchanged and now asserts against the per-lexeme clause. Closes Ontos-AI#278
433af12 to
6f28627
Compare
|
Heads up that CI here is sitting at Rebased onto current main just now. No conflicts, Worth separating the two things regardless of what happens to this PR: the truncation described in #278 is live on main today. If you would rather fix it differently, or fold it into other retrieval work, that is fine by me. The repro in the issue stands on its own. |
|
Thanks for the clear repro — I re-ran the two prefilters locally on Postgres 16 ( Their case still holds. 2000 dense So this is a real fix for that pattern. I still would not merge it as a general recall improvement — the same setup shows a regression on covering / multi-term hits, which global Regression 1 (deterministic). 400 dense-only chunks per term + one chunk that contains all six query terms once. Query Each term’s slice is filled by denser single-term rows. Combined cover-density still ranks the covering chunk first, which is why current main keeps it. Regression 2 (common 2-term shape, tie-break dependent). 2000 If that chunk happens to be first in the posting list it survives; if it is later, it does not. Production chunks are not inserted first. I also saw the unsaturated-budget issue: 1500 Could you add a contract test for the covering chunk (regression 1 is the clean one), and keep a global coverage slice rather than spending the whole budget per lexeme? Rare-term starvation is the actual #278 failure; a hybrid (global |
Frankie-Xu
left a comment
There was a problem hiding this comment.
Thanks for the clear write-up and for keeping this on the classic BM25 path.
The rare-term direction is correct. Per-lexeme candidate budget is the right shape for the #278 starvation case, and I independently confirmed it on this branch.
I ran the new tests locally (20 passed on the unit + contract + shared channel files, pytest-postgresql / Postgres 17.10) and also drove the live content_channel / term_channel / path_channel helpers plus merge_channels_rrf on two synthetic corpora. On 2000 dense data chunks + 1 zebra, query data zebra, limit 2000, live content_channel returned rare-zebra at rank 0 with a 1001-row pool. SQL for the old global ts_rank_cd LIMIT 2000 drops that row. Single-lexeme queries are unchanged (GREATEST(limit/1, 50) = limit). Scope filters still sit in scoped_chunks before any LIMIT, and _prepare_fts_tokens still keeps user text out of tsquery syntax. Classic-only is the right blast radius.
I would not treat the new prefilter as a general recall improvement yet — the same LATERAL slice that saves a rare term can still drop a BM25 top match of a different shape.
Where it still drops a BM25 top match
Once each term has more than per_lexeme_limit denser single-term hits, a covering chunk (all query terms, lower per-term density) loses every LATERAL slice. That is the #278 bug inverted: BM25's winner never reaches rank_rows_by_bm25. This covering miss was already sketched earlier on the thread; I independently reproduced it through the live channel helpers, with tokens that survive tokenize_for_retrieval. Function words like one/two/three are stopwords, so content_channel returns [] before SQL and would not make a useful contract test.
Corpus: 400 dense chunks per term × {invoice, payment, contract, vendor, refund, shipment} + one cover-6 holding all six terms once. Limit 2000 → per_lexeme_limit = 333 (channels.py, the lexeme_budget / LATERAL LIMIT lb.per_lexeme_limit block).
| path | cover-6 |
|---|---|
| full-corpus BM25 | rank 0 (10.30 vs 3.09) |
global ts_rank_cd LIMIT 2000 |
present |
| this PR's per-lexeme pool | absent (1998 rows) |
live content_channel top 20 |
absent; top is invoice-* |
live term_channel |
rank 0 |
default RRF (content=2.0, term=1.5, path=1.0, k=60) |
not in fused top 20 |
term_channel finding the covering chunk does not save classic fusion. A content-channel rank-0 dense hit scores 2/61 ≈ 0.033; a term-only rank-0 hit scores 1.5/61 ≈ 0.025; with path also ranking the dense rows, invoice-1 fused at 0.073. That path is bottom_discovery → merge_channels_rrf in packages/shared-python/shared/services/retrieval/search/scoring.py.
Two related edges on the SQL (channels.py lexeme_budget / LATERAL):
ORDER BY ts_rank_cd(...) DESChas no tie-break. On 2000data filler N+ 2000analysis filler N+ 1data analysis, cover has the same per-term rank as the other 2000 hits (0.1). Whether it survivesLIMIT 1000depends on heap order; inserted last, it was dropped. A secondaryidkey can save it via one of the two slices, not both, so it is not a complete fix, but the current order is not deterministic either._MIN_CANDIDATES_PER_LEXEME = 50meansRETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMITis no longer a cap. At the 50-token ceiling,GREATEST(2000/50, 50) = 50per lexeme → up to 2500 unique rows. The field description inpackages/shared-python/shared/core/config/retrieval.pystill says "Maximum rows".
Saturation warning looks at the wrong quantity
saturated = not used_fallback and candidate_count >= candidate_limitAfter this change, unique-row count vs the global limit does not mean "a lexeme slice hit its LIMIT":
- Rare-term success path:
candidates=1001,limit=2000,saturated=False, while thedataslice was truncated at 1000 of 2000 matches. - Covering-chunk miss:
candidates=1998,limit=2000,saturated=False, while every lexeme slice was full andcover-6was cut.
The warning added here would stay silent on both corpora, including this PR's own rare-term fixture (1000 common + limit 200 → unique ≈ 101). A useful signal is "any LATERAL probe returned per_lexeme_limit rows", not len(rows) >= candidate_limit.
Cost
The extra cost is not "one GIN probe per lexeme". WHERE tsv @@ q ORDER BY ts_rank_cd LIMIT n cannot use GIN for the sort. EXPLAIN ANALYZE on a 20k-row stand-in of this SQL is a nested loop of match scan + ts_rank_cd + top-N, loops = lexeme count. Each common lexeme still materializes its posting list. At _MAX_FTS_QUERY_TOKENS = 50 on a large namespace that is worth measuring before treating it as cheap.
Tests
test_rare_term_chunk_survives_a_saturated_candidate_budget is a good #278 lock (1000 rows vs limit 200 is enough for that shape). It does not catch the covering miss, the warning false-negative, or the floor exceeding the cap. The SQL-shape unit test also does not assert :fts_min_per_lexeme.
Would you be open to:
- A contract test in the same file as the rare-term one, with a covering chunk vs dense single-term rows, using tokens that survive
tokenize_for_retrieval. - Keeping a global
ts_rank_cdslice and a per-lexeme floor, then DISTINCT-unioning them. Rare-term starvation is the actual #278 bug; combined ranking is what currently keeps covering hits. Spending the whole budget per lexeme is what drops them. - Driving the saturation warning off per-lexeme LIMIT hits (and maybe logging
per_lexeme_limit/ lexeme count).
The rare-term direction is sound; I would just not fold this in as a blanket recall fix until (1) and ideally (2) are in. Happy to re-run the contract file if you push those. Fork CI is still action_required here; the 20 local tests are green.
Review showed the per-lexeme-only budget traded one starvation problem for another. A chunk covering several query terms ranks first under a global ts_rank_cd ordering, and BM25 agrees, but splitting the whole budget per lexeme fills every slice with denser single-term rows and drops it. The same split also truncated corpora that fit inside the budget, where the previous code truncated nothing. The prefilter now unions two pools. The global slice is the previous behaviour unchanged, so nothing it kept can be lost. The per-lexeme floor adds a small number of rows for each lexeme on top, which is what keeps a rare lexeme from being starved. Verified on Postgres 16 against all four corpora from the review thread: rare term under a saturated budget main misses it, union keeps it chunk covering six query terms main keeps it, union keeps it chunk covering two query terms main keeps it, union keeps it 1900 matches under a 2000 cap main 1900 rows, union 1900 rows Adds a contract test for the covering chunk. It fails against the per-lexeme-only version and passes here, so the regression stays closed. The bounding clause is LIMIT :fts_candidate_limit again, so the pre-LIMIT scope filter assertions added in Ontos-AI#244 apply unchanged and that test needed no edit.
|
Thanks, that is a much better shape and all three of your cases reproduce here. Switched to the hybrid. The prefilter now unions a global Re-ran your four corpora on Postgres 16: Regression 1 was the sharp one. Your reading was right, each term's slice fills with denser single-term rows and Two notes on the diff. The bounding clause is The saturation warning still fires on Local runs: 17 passed on the api unit and contract tests, 4 passed on the shared channel tests, ruff clean. CI on this fork still needs a maintainer to approve the workflow run, so the checks here will stay unrun until someone does. |
Summary
ts_rank_cdordering. That ordering scores term density inside a chunk and ignores corpus-wide rarity, while BM25 weights rare terms heavily, so the chunk BM25 ranks first can be the first one truncated._MIN_CANDIDATES_PER_LEXEMEkeeps many-lexeme queries from dividing the budget into slivers.candidates == limitwhether the corpus held exactly that many or far more, so silent truncation was indistinguishable from a healthy query.Measured on 5001 chunks where 5000 densely repeat a common term and one holds a rare term, querying
data zebra:The per-lexeme pool is smaller and still keeps the match BM25 wants. Cost is one GIN probe per lexeme rather than one overall, and
_MAX_FTS_QUERY_TOKENSalready caps that at 50.Verification
uv run pytest apps/api/tests/unit/test_bm25_channel_tsquery.py apps/api/tests/contract/test_bm25_fts_prefilter_contract.py— 16 passed against real Postgres 16uv run pytest packages/shared-python/shared/tests/test_retrieval_search_channels.py— 4 passeduvx ruff check packages/shared-python apps/api/tests— cleanAssertionError: assert 'common-2' == 'rare-zebra'test_content_channel_uses_bounded_or_fts_after_scope_filtersasserted on the literalLIMIT :fts_candidate_limitas a position marker for the candidate bound. That clause is nowLIMIT lb.per_lexeme_limit, so the assertions were repointed. The intent from #244, that scope filters land ahead of any candidate bound so exclusions cannot consume the budget, is unchanged and still asserted.Not tested: production-scale corpora, and lateral-probe cost at the 50 lexeme ceiling on a large namespace. Both need a dataset I do not have locally.
Deployment Notes
RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMITkeeps its meaning as the total budget, now spent per lexeme.Checklist