Skip to content

[Refactor]: strengthen hybridSearch — fix leg inputs, split scores, atomic module, forward-compatible for GraphRAG #45

Description

@FireTable

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:

  1. 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.
  2. 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.
  3. 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.
  4. Meaningless tag-leg ranking. ORDER BY jsonb_array_length(c.entities) ranks by entity count, unrelated to match quality — noise into RRF.
  5. 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.
  6. 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.
  7. 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 exposedindex.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions