perf: prefilter BM25 channel candidates with Postgres FTS - #251
Closed
nuemaan wants to merge 1 commit into
Closed
Conversation
path_channel and content_channel loaded every scoped chunk with non-empty search text into Python before ranking, so cost grew with namespace size on every retrieve step. term_channel already pushed its query filter into SQL; these two did not, even though the GIN-indexed content_search_tsv and path_search_tsv columns were already selected by the scoped-corpus CTE. Both channels now prefilter on the tsvector column and hand a bounded candidate pool to the existing Python BM25 ranker, which stays the final per-channel ranker. Ordering by ts_rank_cd only decides which candidates survive the limit. The tsquery is built inside SQL from a text[] of ranker tokens: Postgres lexes each token with the same simple configuration that generates the tsvector columns, then ORs the resulting lexemes. That keeps the prefilter aligned with the stored lexicon, and tsquery operators in user input stay data instead of becoming syntax. OR semantics match how the Python ranker admits a row on any token intersection, so an AND-style query cannot narrow recall. Falls back to the previous unfiltered scan when no token yields a lexeme or the prefilter matches nothing, so recall cannot regress. Candidate pool size is configurable via RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT, default 2000. Closes Ontos-AI#195
This was referenced Aug 11, 2026
Contributor
|
Thanks for this — classic BM25 candidate loading is a real issue we want to keep fixing (classic mode is a long-lived path even though map-nav is now the default). We're landing a combined fix in #252 that keeps your strongest pieces:
It also absorbs SQL-side Closing this PR as superseded by #252 so we don't merge two conflicting approaches for #195. Please keep contributing — follow-ups welcome (classic large-namespace soak tests, metrics, etc.). |
Contributor
EricNGOntos
added a commit
that referenced
this pull request
Aug 11, 2026
Classic path/content channels loaded the full scoped corpus into Python before BM25. Prefetch via simple FTS (server-side OR tsquery), apply section exclusions before the candidate LIMIT, and fall back to a full scan when FTS matches nothing so recall does not regress. Combines the approaches from #244 and #251; closes #195. Co-authored-by: Ray Tien <ray.tien0907@gmail.com> Co-authored-by: nuemaan <anonnumaan@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
EricNGOntos
added a commit
that referenced
this pull request
Aug 11, 2026
* refactor: update retrieval architecture and agentic mode handling - Reorganized the retrieval flowchart to enhance clarity and structure. - Introduced new retrieval modes: classic top-K and map-nav, with clear descriptions for each. - Updated the `use_agentic` parameter to default to map-nav, simplifying user experience. - Removed legacy agentic components and related tests to streamline the codebase. - Adjusted documentation to reflect changes in retrieval modes and internal structures. * perf: bound classic BM25 candidates with Postgres FTS Classic path/content channels loaded the full scoped corpus into Python before BM25. Prefetch via simple FTS (server-side OR tsquery), apply section exclusions before the candidate LIMIT, and fall back to a full scan when FTS matches nothing so recall does not regress. Combines the approaches from #244 and #251; closes #195. Co-authored-by: Ray Tien <ray.tien0907@gmail.com> Co-authored-by: nuemaan <anonnumaan@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix: green CI for mapnav PR (lint, pyright, demo classic path) Exclude vendored nav/ from pyright, tighten mapnav plan typing, and force demo contract retrieval onto classic so CI does not hit a live LLM. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Ray Tien <ray.tien0907@gmail.com> Co-authored-by: nuemaan <anonnumaan@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
path_channelandcontent_channelloaded every scoped chunk with non-empty search text into Python before ranking, so cost grew with namespace size on every retrieve step.term_channelalready pushed its query filter into SQL; these two did not, even though the GIN-indexedcontent_search_tsvandpath_search_tsvcolumns were already selected by the scoped-corpus CTE.ts_rank_cdonly orders which candidates survive the limit.RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT, default 2000.On tsquery construction: the query is built inside SQL from a
text[]of ranker tokens. Postgres lexes each token with the samesimpleconfiguration that generates the tsvector columns, then ORs the resulting lexemes. Two reasons for doing it server-side rather than assembling tsquery text in Python:simpleparser splitsfoo_barinto two lexemes but keepsa.b.cwhole, and lexesgpt-4asgptand-4. Reproducing those rules in Python would drift from what is actually indexed.&,|, or!cannot change the shape of the filter.OR semantics match how the Python ranker admits a row on any token intersection, so the prefilter cannot narrow recall relative to current behaviour. When no token yields a lexeme, or the prefilter matches nothing, the channel falls back to the previous unfiltered scan.
Verification
Commands run:
uv run python -m pytest apps/api/tests/unit/test_bm25_channel_tsquery.py— 8 passeduv run python -m pytest apps/api/tests/contract/test_bm25_fts_prefilter_contract.py— 7 passed, against a real Postgres 16uvx ruff checkon all changed files — cleanThe contract tests run against real Postgres rather than a fake, because a fake would not catch a mismatch between the query configuration and the one the generated columns use. They cover the English and CJK paths, OR semantics across tokens, exclusions still applying under the prefilter, the no-match fallback, and that tsquery operators in a query do not change the filter shape.
Measured on a seeded corpus of 500 scoped chunks where 2 match the query:
Not tested: production-scale corpora, and the interaction with agentic multi-step retrieval under load. Both would need a dataset I do not have locally.
Deployment Notes
RETRIEVAL_POSTGRES_FTS_CANDIDATE_LIMIT(int, default 2000). Existing deployments need no change.content_search_tsv,path_search_tsv, and their GIN indexes were added ind4e5f6a7b8c9_add_checkerboard_search_fields.Checklist