feat(knowledge): opt-in hybrid lexical + vector retrieval for KB search - #6124
Conversation
KB search ranked purely on pgvector cosine distance, which retrieves exact tokens (error codes, ticket keys, identifiers, rare product names) poorly. Add a full-text leg over the already-present generated `embedding.content_tsv` column and its GIN index — no migration, no re-indexing — and fuse it with the vector leg by reciprocal rank. Both legs run concurrently and share the same visibility and tag-filter predicates; the lexical leg is best-effort and falls back to vector-only on failure. Hybrid is the default for every caller. `searchMode: 'vector'` on the internal and v1 contracts (and an advanced Retrieval Mode dropdown on the Knowledge block) restores the previous behavior. Both search routes now share one `executeKnowledgeSearch` dispatch instead of duplicating the three-branch retrieval logic.
Every existing caller — workflow block, v1 API, copilot, guardrail RAG — keeps its current ranking. Hybrid retrieval is now requested explicitly via `searchMode: 'hybrid'`. Also routes the copilot knowledge tool through the shared `executeKnowledgeSearch` dispatch so all four callers share one retrieval path, and documents `searchMode` on the public v1 search endpoint in the OpenAPI spec.
Regenerates the knowledge integration reference for the new searchMode tool param, and adds a Retrieval Mode section to the knowledge base workflow guide explaining when hybrid beats vector-only.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Internal and v1 search routes, the workflow Knowledge block (advanced Retrieval Mode dropdown), the knowledge tool, and copilot query now route through a shared Contracts, OpenAPI, and docs describe Reviewed by Cursor Bugbot for commit f1a8e9d. Configure here. |
Greptile SummaryAdds opt-in hybrid knowledge retrieval while preserving vector search as the default.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the current fusion implementation resolves both previously reported tie-balancing defects and has direct regression coverage for them.
|
| Filename | Overview |
|---|---|
| apps/sim/app/api/knowledge/search/utils.ts | Adds lexical retrieval, reciprocal-rank fusion with balanced tie handling, and the shared search dispatcher; the previously reported fusion defects are fixed at HEAD. |
| apps/sim/app/api/knowledge/search/route.ts | Routes internal knowledge searches through the shared dispatcher while retaining embedding, filtering, reranking, and billing orchestration. |
| apps/sim/app/api/v1/knowledge/search/route.ts | Adds retrieval-mode forwarding to the v1 endpoint through the shared search dispatcher. |
| apps/sim/app/api/knowledge/search/utils.test.ts | Covers lexical search, hybrid dispatch, reciprocal-rank ordering, first-leg starvation, and shared-hit contribution accounting. |
| apps/sim/blocks/blocks/knowledge.ts | Adds the advanced Retrieval Mode control to the Knowledge workflow block. |
| apps/docs/openapi.json | Documents the optional vector-or-hybrid search mode for the public knowledge search API. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Caller[Knowledge search caller] --> Dispatch[executeKnowledgeSearch]
Dispatch -->|vector| Vector[Vector retrieval]
Dispatch -->|hybrid| Vector
Dispatch -->|hybrid| Keyword[Full-text keyword retrieval]
Vector --> Fusion[Reciprocal-rank fusion]
Keyword --> Fusion
Fusion --> Results[Top-K results]
Vector -->|vector mode| Results
Reviews (7): Last reviewed commit: "perf(knowledge): stop the keyword leg de..." | Re-trigger Greptile
Rank n in one leg always ties rank n in the other, so ordering the fused list by score alone let whichever leg was scored first take every tied slot. At topK=1 that meant a hybrid search returned exactly the vector-only result and discarded the exact keyword match the mode exists to recover. Selection now orders by score and drains each tie group round-robin, taking from whichever leg has contributed fewest rows so far. The lexical leg is passed first so it wins a total tie, since a chunk the vector leg ranked below its distance threshold is the case hybrid was opted into for.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 930a1d4. Configure here.
Attributing a row found by both legs to a single leg left the round-robin owing the other leg a slot it had already been served. With a shared rank-1 hit and topK 2, that evicted the lexical-only row — the exact match hybrid was enabled to recover — in favor of the vector-only one. A shared row satisfied every leg that returned it, so every one of them is now charged for it. Tie-breaking prefers the candidate whose least-served leg has been served least, which also removes the arbitrary best-rank attribution.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 318b236. Configure here.
The shared dispatch treats a whitespace-only query as absent and throws when no tag filters accompany it, where the previous vector-only call would have embedded the blank string and searched. Tighten the existing guard so the tool returns its normal message instead.
|
@cursor review |
The vector leg caps candidates per base once getQueryStrategy sets useParallel, but the keyword leg always ran one global query with a single LIMIT. Searching several bases at once let whichever one ranks strongest lexically consume every slot, so an exact-token hit in a smaller base never reached fusion — the case hybrid exists to serve. The keyword leg now uses the same strategy: per-base queries under the same parallel limit, re-ranked globally on a selected ts_rank_cd. Both legs draw candidates the same way, so fusion combines rankings over the same pool.
|
@cursor review |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 03eda34. Configure here.
Selecting the cosine distance in the ranking query made Postgres detoast the 1536-dimension embedding and compute a distance for every full-text match before the LIMIT applied, so cost tracked how common the query term was rather than topK. On a 20k-chunk base with a term matching every row that was 61,055 buffer hits against 1,030 for the same query without the projection. Rank on ids and ts_rank_cd alone, then hydrate only the rows that survive the limit. Same results, and the worst case drops to ~27ms end to end.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit f1a8e9d. Configure here.
Summary
embedding.content_tsvgenerated column and its GIN index, fused with the vector leg by reciprocal rank. No migration, no re-indexingsearchModedefaults tovector, so the block, v1 API, copilot, and guardrail RAG all keep their exact current ranking.searchMode: 'hybrid'turns it on, plus an advanced Retrieval Mode dropdown on the Knowledge blockexecuteKnowledgeSearchdispatch instead of duplicating the retrieval branchsearchModein the v1 OpenAPI spec and the knowledge base guideType of Change
Testing
Verified against a local Postgres with a 20,003-chunk corpus:
PROJ-1234returns nothing undervector, ranks 2 of 25 underhybridEXPLAIN ANALYZEconfirmsBitmap Index Scan on emb_content_fts_idx(no seq scan)-negation, all-stopword queries, and hostile input (') OR 1=1 --) all behave correctlyChecklist