Skip to content

feat(search): add hybrid search with RRF fusion#224

Merged
aliasunder merged 14 commits into
phase-2from
phase2-hybrid-search
Jun 30, 2026
Merged

feat(search): add hybrid search with RRF fusion#224
aliasunder merged 14 commits into
phase-2from
phase2-hybrid-search

Conversation

@aliasunder

@aliasunder aliasunder commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add hybrid search (FTS5 + sqlite-vec vector + RRF fusion) to vault_search tool
  • Transparent upgrade: uses hybrid ranking when embeddings are available, falls back to FTS-only when they are not
  • Three new functions: computeRrfScores (pure RRF), vectorSearch (KNN), hybridSearch (orchestrator)
  • Updated vault_search tool description to reflect hybrid search capabilities

Spike result

8/9 previously-failed queries fixed, 0 regressions, ~8ms latency (RRF-only mode).

Test plan

  • 9 computeRrfScores unit tests (table-driven: single/both systems, bonuses, custom k, sort order)
  • 12 hybridSearch integration tests (fallback, hybrid ranking, filters, limits, dedup, callouts)
  • All 1177 tests pass
  • Build and lint clean
  • Deploy to Lightsail and run 9-query eval against live vault

🤖 Generated with Claude Code

https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM

Summary by CodeRabbit

  • New Features
    • Search now uses a hybrid ranking approach that blends keyword and semantic matching for more relevant results.
    • Results can now fall back to keyword-only search when embeddings aren’t available.
    • Search snippets and result metadata are more complete for semantic matches, with improved relevance ordering.

Combine FTS5 keyword search with sqlite-vec vector similarity via
Reciprocal Rank Fusion (RRF). vault_search transparently upgrades to
hybrid ranking when embeddings are available, falling back to FTS-only
when they are not.

New functions:
- computeRrfScores: pure RRF with k=60 + top-rank bonuses
- vectorSearch: embed query → KNN → best chunk per note
- hybridSearch: orchestrates FTS + vector + RRF fusion

Spike result: 8/9 previously-failed queries fixed, 0 regressions, ~8ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Comment thread src/vault-mcp/search/search-index.ts
Comment thread src/vault-mcp/search/search-index.ts Outdated
Comment thread src/vault-mcp/search/search-index.ts
aliasunder and others added 3 commits June 29, 2026 21:00
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Tighten assertion quality across computeRrfScores and hybridSearch tests:
decomposed assertions collapsed to toEqual, position-agnostic .find()
replaced with positional checks, loose matchers (toBeTruthy, toBeDefined)
replaced with exact fixture values, conditional assertion on leading_callout
made unconditional. Add coverage for notePassesFilters related/properties
branches and snippet_tokens truncation behavior. Use onTestFinished for
spy cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Defensive fix: Buffer.from(float32.buffer) ignores byteOffset and
byteLength when the Float32Array is a subarray view. Use the three-arg
form so vector blobs are always the correct slice, even if a future
embedder returns a view instead of a fresh copy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
@aliasunder

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a hybrid search path to search-index.ts combining FTS5 keyword results with sqlite-vec KNN vector results via Reciprocal Rank Fusion (RRF), with automatic fallback to FTS-only when embeddings are unavailable. The MCP vault_search tool handler is updated to call hybridSearch instead of fullTextSearch, and ~770 lines of new tests cover the RRF logic and hybrid search behaviors.

Changes

Hybrid FTS+Vector Search (RRF)

Layer / File(s) Summary
RRF scoring, VectorHit type, and SQL statements
src/vault-mcp/search/search-index.ts
Exports computeRrfScores for fusing two ranked lists (reciprocal rank + top-rank bonus). Adds prepared SQL for KNN over note_vectors/note_chunks and note metadata lookup. Fixes embedding Buffer construction to use explicit byteOffset/byteLength.
vectorSearch and hybridSearch implementation
src/vault-mcp/search/search-index.ts
Adds vectorSearch (embed query → KNN → deduplicate per note → return [] on failure). Adds hybridSearch (inflated FTS + vectorSearch → RRF merge → filter vector-only rows via notePassesFilters → cap by limit). Adds buildSnippetFromChunkText helper. Wires hybridSearch into the returned API object.
MCP tool description and handler update
src/vault-mcp/mcp-core/tools/search-tools.ts
Updates VAULT_SEARCH tool description to document hybrid RRF semantics, fallback behavior, revised example and Returns/When-to-use docs. Switches handler call from fullTextSearch to hybridSearch.
Tests for computeRrfScores and hybridSearch
src/vault-mcp/search/__tests__/search-index.test.ts
Adds computeRrfScores test block (scoring, rank bonuses, custom k, empty/disjoint inputs) and a comprehensive hybridSearch suite covering fallback modes, fusion ranking, vector-only metadata/snippets, all filter types, deduplication, limit enforcement, and snippet truncation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • aliasunder/vault-cortex#215: Adds sqlite-vec extension loading and initial vector search support in search-index.ts, which this PR builds the hybrid RRF path on top of.
  • aliasunder/vault-cortex#217: Adds the embedding pipeline, vector tables, and embedNote wiring in search-index.ts that vectorSearch in this PR consumes.
  • aliasunder/vault-cortex#218: Modifies the background embedding pass and rebuild API in search-index.ts, which controls when vector results become available to the hybrid search introduced here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding hybrid search with RRF fusion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase2-hybrid-search

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/vault-mcp/search/search-index.ts (1)

871-878: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Replace changed as assertions with runtime row guards.

These new DB/JSON paths assert row shapes instead of narrowing them, which violates the TypeScript guideline and can crash hybrid search if a row or persisted JSON value is malformed. Add small validators for vector rows, note rows, and parsed string arrays before using them.

As per coding guidelines, “Do not use any, as, or non-null assertions (!); use runtime guards or schema validation to narrow types instead.”

Also applies to: 1303-1324, 1896-1916

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vault-mcp/search/search-index.ts` around lines 871 - 878, The hybrid
search and persisted JSON handling currently rely on `as` assertions instead of
validating the data shape at runtime, which can break on malformed rows or JSON.
Replace the assertions in the search flow (including the `knnSearchStmt.all`
result and the related DB/JSON paths around the other affected blocks) with
small runtime guards or schema checks that narrow rows to the expected vector
row, note row, and string-array shapes before use. Keep the existing search
logic in place, but make the row parsing explicit and safe by validating the
fields first and only proceeding with typed values after the guard passes.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vault-mcp/search/__tests__/search-index.test.ts`:
- Around line 3328-3366: The vector-only search test is too weak because it does
not prove the snippet comes from the intended note. Update the mocked embedText
behavior in search-index.test.ts so each embed call maps deterministically to
the intended note/query embedding, then assert the returned result includes the
expected note path and the exact snippet value from that note’s chunk text. Use
the existing hybridSearch, embedNote, and createSearchIndex flow to keep the
test focused on the intended vector-only behavior.
- Around line 3748-3761: The truncated snippet assertion in the hybrid search
test is too loose even though the output is deterministic for this fixture. In
the `hybridIndex.hybridSearch` test case that inspects `verboseResult`, replace
the token-count/contains checks with a whole-value assertion against the exact
snippet produced by `buildSnippetFromChunkText`, so the test verifies the
precise truncated string for `verbose.md` rather than any five-word output with
ellipsis.

In `@src/vault-mcp/search/search-index.ts`:
- Around line 1271-1284: The RRF merge in search-index.ts is combining
unfiltered vectorHits from vectorSearch with ftsResults before applying
folder/tag/property constraints, so matching semantic results can be missed and
non-matching vectors can skew scores. Update the vector path in the search flow
around vectorSearch, computeRrfScores, and the later vector-only filtering to
apply the same filters to vector candidates before ranking, or pass the filters
into vectorSearch so only eligible notes contribute to RRF.

---

Nitpick comments:
In `@src/vault-mcp/search/search-index.ts`:
- Around line 871-878: The hybrid search and persisted JSON handling currently
rely on `as` assertions instead of validating the data shape at runtime, which
can break on malformed rows or JSON. Replace the assertions in the search flow
(including the `knnSearchStmt.all` result and the related DB/JSON paths around
the other affected blocks) with small runtime guards or schema checks that
narrow rows to the expected vector row, note row, and string-array shapes before
use. Keep the existing search logic in place, but make the row parsing explicit
and safe by validating the fields first and only proceeding with typed values
after the guard passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e50beed6-d73a-4c02-8e62-79c731690a67

📥 Commits

Reviewing files that changed from the base of the PR and between c2a2a59 and 8e696da.

📒 Files selected for processing (3)
  • src/vault-mcp/mcp-core/tools/search-tools.ts
  • src/vault-mcp/search/__tests__/search-index.test.ts
  • src/vault-mcp/search/search-index.ts

Comment thread src/vault-mcp/search/__tests__/search-index.test.ts Outdated
Comment thread src/vault-mcp/search/__tests__/search-index.test.ts Outdated
Comment thread src/vault-mcp/search/search-index.ts
aliasunder and others added 10 commits June 29, 2026 21:46
Assert specific note path and snippet content for the vector-only
snippet test. Use exact whole-value assertion for truncated snippet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
…NABLED

The vault_search tool description now reflects the actual search mode:
hybrid (RRF + semantic) when EMBEDDING_ENABLED=true, keyword-only
(FTS5 BM25) when false. Prevents misleading clients about semantic
matching capabilities when embeddings are disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Assert hybrid search terms (RRF, semantic, career aspirations example)
appear when EMBEDDING_ENABLED=true, and are absent when false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Returns "hybrid" when RRF fusion with vector similarity was used, "fts"
when only keyword matching was available. Lets clients interpret scores
correctly (BM25 vs RRF) and know whether semantic search is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Replace the mild "career aspirations" example with "dealing with
burnout recovery" — a clearer vocabulary gap that teaches LLM clients
what hybrid search can do (finds notes about mental health without
the word "burnout").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Replace personal burnout example with a technical one — "how the
server watches for file changes" bridging to chokidar/file watchers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
…per reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Log searchMode ("hybrid" or "fts") in the structured info log for
both the RRF fusion path and the FTS-only fallback path. The fallback
previously returned silently without logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
Both fullTextSearch and hybridSearch mapped NoteRow fields to
SearchResult with identical conditional-spread logic for created,
bytes, and leading_callout. Shared helper eliminates the duplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZbaHwP28ASYTpQnZk6MqM
@aliasunder
aliasunder merged commit d7bc630 into phase-2 Jun 30, 2026
12 checks passed
@aliasunder
aliasunder deleted the phase2-hybrid-search branch June 30, 2026 02:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant