Problem
hybridSearch (lib/kb/search.ts) under-delivers. The RRF-over-3-legs skeleton is sound, but the legs are fed badly and the function has grown into a ~250-line monolith with confusing control flow. Symptoms:
query field is overloaded across all three legs, starving the semantic (vector) leg. The same args.query string is fed to websearch_to_tsquery (BM25), deriveQueryEntities split (tag), and embedQuery (dense vector). The tool schema forces the LLM to pass "5-10 space-separated entries, not the raw question" — optimizing for keyword/BM25 while feeding the dense embedding a keyword soup. Dense embeddings work best on natural language, so the most important leg is fed the worst input. This is the main reason search feels weak.
- Bloated tool prompt.
search_kb's query description is ~15 lines of "break the question apart but don't pass meta-questions" — all a symptom of cramming two responsibilities into one field.
- Two scores conflated in one field. After rerank,
rrf_score is overwritten with the reranker's 0–1 score. The same rrfScore field holds either an RRF sum (~0.01–0.05) or a rerank score (0–1); the UI (chunk-list.tsx) guesses which via a magic > 0.05 threshold. Fragile and mislabeled.
- Meaningless tag-leg ranking.
ORDER BY jsonb_array_length(c.entities) ranks by entity count, unrelated to match quality — noise into RRF.
- Weird forced fallback + messy flow. When ranked retrieval returns 0 but a scope filter is set, it silently dumps the entire scope (up to 1000 chunks). Semantics silently flip from "found N matches" to "matched nothing, here's everything." Two
scopeDump call sites (empty-query mode vs zero-result fallback) mix "mode" and "fallback" in one function.
to_tsvector('english', content) is wrong for a multilingual SaaS. English-only: no CJK segmentation (a whole Chinese run becomes one token) + English stemming applied to everything. BM25 is effectively dead for non-English users. Postgres FTS has no single multilingual config; per-language config is a rabbit hole for a mixed-tenant SaaS.
- tag leg is literal exact-match with no graph traversal.
lower() = ANY(qents) misses paraphrased entities. We have entity/relationship data (jsonB) and a frontend graph visualization, but the retrieval path never traverses it (no GraphRAG).
Approach
Full design (with LightRAG source analysis, folder layout, final signatures, schema, migrations, test plan) lives in .claude/kb-hybrid-search-audit.md. Summary:
Key insight: we don't need entity vectors to fix the weakness — our chunk embeddings (bge-m3, multilingual, 1024-dim) already are the semantic layer LightRAG builds via entity/relation vectors. The fix is feeding the legs correctly, not new infrastructure.
Refactor lib/kb/search.ts into an atomic lib/kb/search/ module (index + types + one file per leg), with a forward-compatible interface so the optional GraphRAG phase is a pure addition (no rework).
Phase A — Hybrid RAG done right (no migration, ~90% of the gain):
- Tool signature:
rewriteQuery (LLM-rewritten natural language → dense + BM25) + entities/themes (AI-provided keywords → tag leg); shorten the describe. No qvec / topK / entryTopK exposed — index.ts owns embedQuery + env-driven limits internally.
originalQuery is injected server-side (extracted from the last HumanMessage; module-local setter, mirrors the existing setKbToolUserId idiom) so multi-query retrieval can fuse the rewritten and original dense vectors via RRF (RAG-Fusion).
- Feed the dense leg the natural query (rewrite + original as two sub-legs); feed the tag leg real keywords; fix tag ordering (match count, not entity count).
- Split score into
score + scoreKind: "rrf" | "rerank"; drop the UI magic threshold.
- Linearize the pipeline; remove the forced zero-result scope-dump fallback (keep empty-query dump as the one explicit mode).
- Switch
to_tsvector('english') → 'simple' (language-neutral interim).
- Card UI surfaces
rewriteQuery / originalQuery / entities / themes chips + scoreKind badges (no magic threshold).
Phase B — GraphRAG (additive, gated behind GRAPH_ENABLED, do only if usage shows global/thematic queries):
- New
kb_relationship (+ optional kb_entity) table with vector embeddings + HNSW; embed at index time via the existing resolveEntityAliasesForDoc hook + backfill.
- Relationship/entity vector legs ("search global" = find entry nodes) + 1–2 hop graph expansion ("spread out from entry"); assemble entity/relationship descriptions into a reserved
graphContext result field (already in the result type, so Phase B = leg additions + assembly, no result-shape rework).
- Multilingual BM25 proper fix: bge-m3 sparse + pgvector
sparsevec (requires bypassing the LangChain embeddings interface for sparse output).
Scope / decisions to lock before Phase B
- Whether to build
kb_entity (entity vectors ≈ duplicate of chunk dense without graph traversal).
- Graph expansion hop count (1 vs 2).
- Phase-A fusion: keep single SQL vs uniform app-side RRF.
- BM25 this round:
'simple' only, or bge-m3 sparse too.
- Default value of new
KB_HYBRID_ENTRY_TOPK env (per-leg entry cap; chunk cap reuses KB_HYBRID_TOPK_DEFAULT).
Docs to update per repo rules: docs/KNOWLEDGE_BASE.md, docs/TOOLS.md (rule 10), docs/DB.md.
Problem
hybridSearch(lib/kb/search.ts) under-delivers. The RRF-over-3-legs skeleton is sound, but the legs are fed badly and the function has grown into a ~250-line monolith with confusing control flow. Symptoms:queryfield is overloaded across all three legs, starving the semantic (vector) leg. The sameargs.querystring is fed towebsearch_to_tsquery(BM25),deriveQueryEntitiessplit (tag), andembedQuery(dense vector). The tool schema forces the LLM to pass "5-10 space-separated entries, not the raw question" — optimizing for keyword/BM25 while feeding the dense embedding a keyword soup. Dense embeddings work best on natural language, so the most important leg is fed the worst input. This is the main reason search feels weak.search_kb'squerydescription is ~15 lines of "break the question apart but don't pass meta-questions" — all a symptom of cramming two responsibilities into one field.rrf_scoreis overwritten with the reranker's 0–1 score. The samerrfScorefield holds either an RRF sum (~0.01–0.05) or a rerank score (0–1); the UI (chunk-list.tsx) guesses which via a magic> 0.05threshold. Fragile and mislabeled.ORDER BY jsonb_array_length(c.entities)ranks by entity count, unrelated to match quality — noise into RRF.scopeDumpcall sites (empty-query mode vs zero-result fallback) mix "mode" and "fallback" in one function.to_tsvector('english', content)is wrong for a multilingual SaaS. English-only: no CJK segmentation (a whole Chinese run becomes one token) + English stemming applied to everything. BM25 is effectively dead for non-English users. Postgres FTS has no single multilingual config; per-language config is a rabbit hole for a mixed-tenant SaaS.lower() = ANY(qents)misses paraphrased entities. We have entity/relationship data (jsonB) and a frontend graph visualization, but the retrieval path never traverses it (no GraphRAG).Approach
Full design (with LightRAG source analysis, folder layout, final signatures, schema, migrations, test plan) lives in
.claude/kb-hybrid-search-audit.md. Summary:Key insight: we don't need entity vectors to fix the weakness — our chunk embeddings (bge-m3, multilingual, 1024-dim) already are the semantic layer LightRAG builds via entity/relation vectors. The fix is feeding the legs correctly, not new infrastructure.
Refactor
lib/kb/search.tsinto an atomiclib/kb/search/module (index + types + one file per leg), with a forward-compatible interface so the optional GraphRAG phase is a pure addition (no rework).Phase A — Hybrid RAG done right (no migration, ~90% of the gain):
rewriteQuery(LLM-rewritten natural language → dense + BM25) +entities/themes(AI-provided keywords → tag leg); shorten the describe. No qvec / topK / entryTopK exposed —index.tsownsembedQuery+ env-driven limits internally.originalQueryis injected server-side (extracted from the last HumanMessage; module-local setter, mirrors the existingsetKbToolUserIdidiom) so multi-query retrieval can fuse the rewritten and original dense vectors via RRF (RAG-Fusion).score+scoreKind: "rrf" | "rerank"; drop the UI magic threshold.to_tsvector('english')→'simple'(language-neutral interim).rewriteQuery/originalQuery/entities/themeschips +scoreKindbadges (no magic threshold).Phase B — GraphRAG (additive, gated behind
GRAPH_ENABLED, do only if usage shows global/thematic queries):kb_relationship(+ optionalkb_entity) table with vector embeddings + HNSW; embed at index time via the existingresolveEntityAliasesForDochook + backfill.graphContextresult field (already in the result type, so Phase B = leg additions + assembly, no result-shape rework).sparsevec(requires bypassing the LangChain embeddings interface for sparse output).Scope / decisions to lock before Phase B
kb_entity(entity vectors ≈ duplicate of chunk dense without graph traversal).'simple'only, or bge-m3 sparse too.KB_HYBRID_ENTRY_TOPKenv (per-leg entry cap; chunk cap reusesKB_HYBRID_TOPK_DEFAULT).Docs to update per repo rules:
docs/KNOWLEDGE_BASE.md,docs/TOOLS.md(rule 10),docs/DB.md.