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. |
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