Capability clusters
C3 · Semantic layer & search
Hybrid (keyword + semantic) note search and the embeddings pipeline that powers it: one 1536-dim vector per note, lexical and vector passes run in parallel, and the two rankings fused with weighted Reciprocal Rank Fusion — the same orchestrator across local and SaaS modes.
Notes are embedded as single 1536-dim vectors (openai/text-embedding-3-large via the Vercel AI Gateway, dimensions forced to 1536 in lib/ai/embeddings.ts) by three producers — a 30s-debounced auto-embed queue fired on every note save, a batch HTTP route driven by a settings-page indexer card, and a standalone backfill CLI — and stored per mode: a note_embeddings SQLite side table of Float32 BLOBs locally, the notes.embedding pgvector column in SaaS. At query time an FTS pass (SQLite FTS5/bm25 locally, Postgres tsvector RPC in SaaS) and a vector KNN pass (brute-force cosine locally, notes_knn pgvector RPC with HNSW in SaaS) are fused with weighted Reciprocal Rank Fusion (k=60, weights fts 0.7 / vector 0.3) and exposed via POST /api/search/hybrid plus debug explorer UIs at /search (local) and /w/[workspace]/search (SaaS).
Modes: local (FTS5 + BLOB brute-force cosine) and saas (tsvector + pgvector HNSW), same orchestrator · Key dirs: lib/search/, lib/ai/ (embeddings, auto-embed-queue, chunking), lib/storage/local/ + lib/storage/supabase/ (search, embeddings, knn), app/api/search/hybrid/, app/api/ai/embed/, components/search/, components/ai/, scripts/ · Depends on: C1 storage adapters + schema, C4 AI gateway/key resolution, C5 MCP rate limiter, C7 auth/RLS in SaaS · Feeds: C4 RAG chat/research retrieval, C5 agent search tools, C2 search UX entry points
Capabilities
| Capability | What it does | Entry points | Modes |
|---|---|---|---|
| Hybrid search API (FTS + vector + RRF) | POST accepts {q (1..1000 chars), limit (1..100, default 20), workspaceId? (uuid), kind? ('fts'|'semantic'|'hybrid')}; returns hits with fusedScore, per-source contributions, ftsRank, vectorRank, title, path, snippet. Local: no auth, rate key 'local'. SaaS: Supabase session required (401), row isolation via RLS (no explicit membership check). 429 + retry-after on rate limit. | POST /api/search/hybrid (app/api/search/hybrid/route.ts) | both |
Pipeline selector (kind) | 'fts' skips the query-embedding call entirely (zero gateway cost); 'semantic' skips the FTS pass so an un-FTS-indexed corpus still yields vector hits; 'hybrid' runs both and fuses (lib/search/hybrid.ts:72-89). | request body kind; Hybrid/FTS/Semantic tabs in components/search/hybrid-explorer.tsx:114-129 | both |
| Hybrid search debug UI (local) | /search page (notFound() unless ELI_MODE==='local', app/(local)/search/page.tsx:20). Query input, 3 pipeline tabs that re-run on switch, results table with FTS rank / vector rank / fused score (5 decimals) / per-source RRF contributions, row tinting (blue = FTS-only, amber = vector-only), note links to /v/n/<path>. Always POSTs limit: 20. | /search, components/search/hybrid-explorer.tsx | local |
| Hybrid search debug UI (SaaS) | /w/[workspace]/search resolves slug → workspace id via the user-scoped (RLS) client — non-members get notFound (app/(saas)/(app)/w/[workspace]/search/page.tsx:24-30) — and mounts the same explorer with workspaceId; hits link to /w/<slug>/n/<path>. | /w/[workspace]/search | saas |
| Batch embed API | POST {workspaceId?, noteIds? (max 5000), all?, batchSize? (1..500, default 50), provider?}. all:true embeds every non-trashed note missing an embedding (limit 5000). SaaS: workspaceId required (400), session (401), memberships check (403), per-user rate limit; RLS-scoped reads, service-role writes to notes.embedding vector(1536). Local: active vault required (400), writes to the note_embeddings side table. Partial failure returns 502 with the processed-so-far count. Response {processed, modelId, source}. | POST /api/ai/embed (app/api/ai/embed/route.ts) | both |
| Embedding progress API | GET returns {total, indexed, model} for the active vault (zeros when none). 400 in SaaS (app/api/ai/embed/route.ts:237-249). | GET /api/ai/embed | local |
| Semantic-index settings card | Shows "indexed/total notes" + percent bar; "Index N pending" button (disabled at 0 / while running) POSTs {all:true, workspaceId?}, toasts "Indexed N notes" with model id + key source, then router.refresh() (components/ai/embed-indexer-card.tsx:34-107). | components/ai/embed-indexer-card.tsx (local /ai-key page, SaaS /w/[workspace]/settings/ai) | both |
| Auto-embed on save (debounced queue) | saveNoteAction fire-and-forgets scheduleEmbed({noteId, workspaceId?, versionId}) after every successful save (app/_actions/notes.ts:96-105). 30s debounce per noteId; repeated saves reset the timer. On fire: re-reads the note, skips if missing or < 50 trimmed chars, embeds title + '\n\n' + body truncated to 30,000 chars as ONE vector, persists per mode. deleteNoteAction calls cancelEmbed (notes.ts:236-237). Failures are console.warn only — no retry, no persistence. | app/_actions/notes.ts, lib/ai/auto-embed-queue.ts | both |
| Embed backfill CLI | pnpm embed:backfill — --vault <path> XOR --workspace <uuid> (one required), --batch N (1..500, default 32), --dry-run, --help. Sets ELI_MODE/ELI_VAULT before importing lib code; calls the stores directly, bypassing HTTP and rate limits by design. Idempotent re-runs; no retry (a mid-batch throw aborts the process). | pnpm embed:backfill, scripts/embed-backfill.mjs, scripts/embed-backfill-args.mjs | both |
| RRF fusion library | Pure functions: rrf() (equal weights), weightedRrf(lists, weights, k), hybridFuse(fts, vector, {topK, weights, k}), toRankedHits(scores, {rankBy}). Defaults k=60, weights {fts: 0.7, vector: 0.3}; throws on k<=0; skips rank<=0; deterministic id-ascending tie-break. Fully unit-tested (lib/search/rrf.test.ts). | lib/search/rrf.ts | both |
| Token-aware chunking helper | chunkText(input, {chunkChars=3500, overlapChars=250}): paragraph-boundary greedy packing → sentence split for oversized paragraphs → hard cut; each chunk after the first is prefixed with the previous chunk's tail + a '\n…\n' sentinel (lib/ai/chunking.ts:34-76). Not used by the embedding pipeline — consumed only by the C4 AI platform chat/research routes. | lib/ai/chunking.ts | both |
| FTS bench CLI | pnpm bench-search <vault-dir>: boots a LocalAdapter against a seeded vault, runs ~100 FTS queries (cycled vocabulary incl. phrase queries), prints p50/p90/p95/max, exits 3 when p95 exceeds ELI_PERF_P95_BUDGET_MS (default 200 ms; spec P1-PERF-02: p95 < 200 ms over 10k notes). FTS-only — the vector side is not benchmarked. | pnpm bench-search, scripts/bench-search.mjs | local |
The RRF formula (as implemented in lib/search/rrf.ts:86-119): for each document d, score(d) = Σ over lists L of w_L × 1 / (k + rank_L(d)) where rank_L(d) is d's 1-based rank in list L. Constants: RRF_DEFAULT_K = 60 (Cormack et al. 2009, rrf.ts:32) and HYBRID_DEFAULT_WEIGHTS = { fts: 0.7, vector: 0.3 }(spec §7.3 "OpenClaw" BM25-70/vector-30 split, rrf.ts:35). RRF is score-scale invariant (only ranks matter), so bm25 scores and cosine similarities never need normalising; a doc appearing in only one list still scores from that list. Ties are broken by id ascending for determinism (rrf.ts:114-118); k <= 0 throws; rank <= 0 entries are skipped defensively (rrf.ts:102).
System view — simplified
This cluster is eli's retrieval brain: an indexing side that turns every saved note into exactly one 1536-dim embedding, and a query side that runs lexical and semantic retrieval in parallel and fuses the two rankings with weighted RRF. Standalone, it is already a complete search product for a markdown corpus: point the backfill CLI at a vault, and POST /api/search/hybrid plus the /search explorer give ranked, snippet-annotated, explainable results with per-source rank attribution — no chat, no agents, no SaaS required. It degrades gracefully along two axes: with no AI key or no embeddings, every query silently falls back to FTS-only; with kind='fts' it costs zero gateway calls. The same orchestrator (lib/search/hybrid.ts) serves both storage modes by branching on the adapter class, so callers never see the local/SaaS divergence.
System view — detailed
| Component | Role | Files |
|---|---|---|
| RRF fusion core | Pure rank-fusion math: weighted RRF with k=60 and fts 0.7 / vector 0.3 defaults, per-list contribution tracking, deterministic tie-break; fully unit-tested | lib/search/rrf.ts, lib/search/rrf.test.ts |
| Hybrid search orchestrator | Mode-aware caller: adapter FTS at limit*2, embeds the query, candidate-restricted KNN then global top-up, fuses via hybridFuse, hydrates metadata (FTS hit or per-id getNote); degrades to FTS-only on any vector-side error | lib/search/hybrid.ts |
| Hybrid search HTTP route | Zod-validated POST; local = no auth + 'local' rate key, SaaS = session auth + per-user rate key; 429 with retry-after; force-dynamic | app/api/search/hybrid/route.ts |
| Search debug pages + explorer | Local /search (mode gate) and SaaS /w/[workspace]/search (slug→id via RLS) shells around the client explorer with pipeline tabs, rank/score/contribution table, FTS-only/vector-only row tinting | app/(local)/search/page.tsx, app/(saas)/(app)/w/[workspace]/search/page.tsx, components/search/hybrid-explorer.tsx |
| Embedding generator | embedTexts: AI SDK embedMany via the Vercel AI Gateway, model openai/text-embedding-3-large forced to 1536 dims (providerOptions.openai.dimensions), Bearer key from resolveAiKey (saas-byok → local-byok → AI_GATEWAY_API_KEY); throws on no key or wrong dim | lib/ai/embeddings.ts, lib/ai/resolver.ts, lib/ai/models.ts |
| Auto-embed queue | 30s-debounced, globalThis-pinned in-memory Map of per-note timers; enqueued by saveNoteAction, cancelled by deleteNoteAction; whole-note single-vector embed on fire; best-effort (warn-only failure, no retry, no persistence) | lib/ai/auto-embed-queue.ts, lib/ai/auto-embed-queue.test.ts, app/_actions/notes.ts |
| Batch embed route | POST /api/ai/embed (noteIds[] or all:true, batchSize default 50) with per-mode auth/rate-limit and per-mode persistence; GET returns local indexer progress | app/api/ai/embed/route.ts |
| Local embedding store | note_embeddings SQLite side table (note_id PK cascade, model, dim, Float32 BLOB, updated_at); vectorToBytes/bytesToVector (explicit little-endian DataView), cosine, transactional bulk upsert, missing-embedding lister (5000 cap), brute-force knnByEmbedding with model+dim filter and optional FTS-candidate restriction — deliberately NOT sqlite-vec | lib/storage/local/embeddings.ts, lib/db/schema.sqlite.ts |
| SaaS embedding store + KNN | writeNoteEmbeddings: service-role per-row UPDATE of notes.embedding in pgvector '[a,b,...]' text format scoped by id+workspace_id; knnNotes wraps the notes_knn SECURITY INVOKER RPC (similarity = 1 − cosine distance, workspace + non-trashed + not-null + optional candidate array, HNSW-indexed) | lib/storage/supabase/embeddings.ts, lib/storage/supabase/knn.ts, lib/db/migrations/pg/0013_notes_knn.sql |
| Backfill CLI | Standalone tsx script + extracted testable arg parser; local (ELI_VAULT env) or saas (service role) full-corpus embedding with --batch/--dry-run/--help, \r progress line, no rate limiting | scripts/embed-backfill.mjs, scripts/embed-backfill-args.mjs |
| FTS5 index (local FTS side) | notes_fts virtual table (title, body_md, type) synced by AFTER INSERT/UPDATE/DELETE triggers on notes; bm25() ranking with sign-flipped score and <mark> snippets; fts.test.ts pins table shape, all three triggers, and the 'rebuild' command | lib/db/fts.test.ts, lib/storage/local/search.ts |
| Rate limiter | In-memory dual-window token bucket (60/min + 10k/day) keyed by 'local' or user:<id>/PAT id; globalThis-pinned; pluggable backend interface with redis stubbed (throws if ELI_RATELIMIT_BACKEND=redis). Shared with C5 | lib/mcp/ratelimit.ts |
| Chunking helper | Paragraph/sentence/hard-cut splitter with overlap sentinel; used by AI research + chat routes (C4) only, not by embeddings | lib/ai/chunking.ts, lib/ai/chunking.test.ts |
| Embed indexer settings card | Client card showing indexed/total with a progress bar; "Index N pending" fires the all:true embed and refreshes RSC counts | components/ai/embed-indexer-card.tsx |
| FTS bench CLI | Seeds-adjacent latency benchmark: ~100 FTS queries through LocalAdapter.search, p50/p90/p95/max report, CI-failing exit when p95 > budget | scripts/bench-search.mjs |
Data flow — simplified
The load-bearing runtime flow is a hybrid query: the route validates and rate-limits, then hybridSearch runs the lexical pass first (fetching limit*2 candidates so fusion has depth), embeds the query text through the same embedTexts helper used for indexing, and runs KNN twice — restricted to the FTS candidate ids for precision, then globally when results run short, so purely-thematic matches still surface. Fusion is pure rank math (no score normalisation), and vector-only hits get their title/path hydrated per-id before the wire response.
Data flow — detailed
(a) Note save → auto-embed → vector stored — the indexing side, with local/SaaS persistence divergence:
(b) Hybrid query → FTS candidates + KNN → RRF fuse → ranked hits — the retrieval side, with local/SaaS branches at auth, FTS, and KNN:
Works separately / works together
Standalone
With every other cluster turned off, this cluster is a self-contained search engine over a markdown corpus. The backfill CLI (pnpm embed:backfill --vault <path>) indexes a vault with zero HTTP surface; the FTS5 index is maintained autonomously by SQL triggers whenever the notes table changes; POST /api/search/hybrid serves ranked results to any HTTP client; and the /search explorer makes ranking behaviour inspectable (per-source ranks, contributions to 5 decimals, FTS-only vs vector-only tinting). Even without an AI key the cluster still works: kind='fts' never touches the gateway, and hybrid queries silently degrade to FTS-only (lib/search/hybrid.ts:143-148). lib/search/rrf.ts is a dependency-free pure library usable in isolation. pnpm bench-search provides an offline FTS latency gate.
Composed
- C2 Authoring UX → this cluster:
saveNoteActionis the sole enqueuer of the auto-embed queue (app/_actions/notes.ts:96-105→scheduleEmbed);deleteNoteAction→cancelEmbed(notes.ts:236-237).createNoteActiondoes notenqueue — new notes get embedded only after their first save, an "Index pending" run, or the CLI. The command palette's quick search is plain FTS via thesearchNotesActionserver action (limit 20; empty query lists the 20 most-recent notes vialistNotes,app/_actions/notes.ts:16-29) — it never touches the hybrid pipeline — plus a palette nav item that jumps to/search(components/command-palette.tsx:118,193)./api/search/hybridis the only route underapp/api/search/; there is no plain/api/searchendpoint. - C1 Core data & storage → this cluster: the storage adapters supply the FTS lists —
LocalAdapter.search()(FTS5/bm25,lib/storage/local/search.ts) andSupabaseAdapter.search()(notes_searchRPC);hybridSearchbranches onadapter instanceof LocalAdapter / SupabaseAdapter. The FTS5 triggers that keepnotes_ftsin sync ride on C1's note writes and the chokidar reindex path. - C4 AI platform → this cluster:
lib/ai/resolver.ts,gateway-config.ts,gateway-opts.ts, andlib/ai/models.tssupply keys, persisted gateway options, and the pinned embed model to everyembedTextscall. - This cluster → C4 AI platform: RAG chat calls
hybridSearch(lastUserTurn, topK=8)for retrieval (app/api/ai/chat/route.ts); the OpenAI-compatible/v1/chat/completionsroute does the same witheli_top_kdefault 8 (0 disables RAG) and inlines note bodies sliced to 4000 chars (app/v1/chat/completions/route.ts:174-190);buildVaultToolsregisters asearch_notesAI-SDK tool (hybrid, default limit 10, max 50) for the chat tool loop (lib/ai/tools.ts:127-156). The chunking helper owned here is consumed by/api/ai/research(maxChunks 30) and/api/ai/chat[/stream](cap 24 chunks) for context-window management — never for embeddings. - C5 Agent surface ↔ this cluster: the MCP rate limiter (
lib/mcp/ratelimit.ts, built for PAT limits) is reused verbatim by/api/search/hybridand/api/ai/embed, so web search/embed traffic and MCP PAT traffic share one in-memory backend (different keys). The MCPsearchtool takeskind: fts|semantic|hybrid(defaultfts= plainstorage.search);hybrid/semanticdynamic-importhybridSearch, forwardauthInfo.workspaceIdin SaaS, and returnstructuredContenthits withfused_score/fts_rank/vector_rank/contributions(lib/mcp/tools/search.ts:54-103).memory_searchuses plainstorage.searchwith a cap×5 over-fetch then path-scope filter (lib/mcp/tools/memory.ts:86-87). - C7 SaaS collaboration → this cluster:
lib/auth/supabase.tsprovides the user-scoped client (RLS gating for FTS reads andnotes_knn) and the service-role client (embedding writes);/api/ai/embedadditionally does an explicit memberships-table check (403). - Settings UI → this cluster: the AI settings page consumes
GET /api/ai/embed(local progress) and rendersEmbedIndexerCard, which drivesPOST /api/ai/embed.
Load-bearing details
- One vector per note, not per chunk. Input is
title + '\n\n' + bodytruncated to 30,000 chars in every producer (lib/ai/auto-embed-queue.ts:90,app/api/ai/embed/route.ts:128-130and:204,scripts/embed-backfill.mjs:109and:157).lib/ai/chunking.tsis never used for embeddings — content past 30k chars is invisible to semantic search. - RRF constants pinned: k=60 (
lib/search/rrf.ts:32, test-pinned atrrf.test.ts:188-190), weights fts 0.7 / vector 0.3 (rrf.ts:35);score = Σ w × 1/(k + rank); id-ascending tie-break (rrf.ts:114-118). - SaaS hybrid short-circuit: with
kind='hybrid', zero FTS hits, and a non-Local adapter,hybridSearchreturns[]before running the vector side (lib/search/hybrid.ts:79-81) — SaaS hybrid can never surface vector-only matches on an FTS-empty query, while local can. The Semantic tab (kind='semantic') bypasses this. - Two-phase KNN in both modes: candidate-restricted KNN first (precision), then a global top-up appended when results < limit; both calls use
limit*2(hybrid.ts:98-141). - Local vector store deliberately avoids sqlite-vec:brute-force cosine over Float32 BLOBs, justified as "~30ms for ≤50k notes on M1" (
lib/storage/local/embeddings.ts:16-21); bytes decoded via DataView with explicit little-endian for cross-CPU portability (embeddings.ts:45-53). SaaS uses pgvector cosine (<=>) with an HNSW index; thenotes_knnRPC is SECURITY INVOKER so RLS applies, and returns similarity = 1 − distance to match the local contract (lib/db/migrations/pg/0013_notes_knn.sql:26-40). - Local KNN asymmetry: the global-scan branch joins
notesand excludes trashed rows (local/embeddings.ts:237-245) but thecandidateNoteIdsbranch does not filtertrashed_at(:227-235); rows with mismatched model or dim are silently skipped (:249-252). - Auth split on
/api/search/hybrid: local has NO auth (rate key literal'local',route.ts:41-42); SaaS requires a session (401) but no explicit membership check — isolation relies on RLS through the user-scoped client used by both the FTS RPC andnotes_knn(route.ts:43-49,knn.ts:9-11)./api/ai/embedis stricter in SaaS: session (401) AND memberships check (403) before rate limiting (embed route.ts:76-97); reads via RLS, writes via service role to avoid an embedding-column UPDATE RLS policy (supabase/embeddings.ts:6-14). - Rate limiting:
checkRateLimit, 60/min + 10,000/day in-memoryglobalThis-pinned buckets (lib/mcp/ratelimit.ts:37-38, 101-105); local mode uses the single shared key'local'on both routes, so local search and embed calls drain one common budget.ELI_RATELIMIT_BACKEND=redisthrows "not shipped yet" (ratelimit.ts:186-192). - Query embeddings never use workspace BYOK: SaaS workspace BYOK needs
workspaceIdANDproviderANDELI_KMS_KEY(resolver.ts:41), buthybridSearchpasses onlyworkspaceId(hybrid.ts:91-93) — query embedding falls to local BYOK orAI_GATEWAY_API_KEY.POST /api/ai/embeddoes accept aproviderparam and can use BYOK for indexing.embedTextsforcesdimensions: 1536viaproviderOptions.openaiand throws if the returned vector is not 1536-dim (lib/ai/embeddings.ts:60-87). - Auto-embed is best-effort: errors only
console.warn(auto-embed-queue.ts:64-70), no retry, no persistence (a worker recycle loses at most one cycle, module docstring lines 28-31). Sub-50-char notes are never embedded (:91). - Every batch write is an idempotent upsert: local
ON CONFLICT(note_id) DO UPDATEin one transaction (local/embeddings.ts:126-141); SaaS per-row UPDATE (supabase-js has no multi-row distinct-payload update,supabase/embeddings.ts:34-46) which throws on the first row error. - Backfill pagination reality: local caps at
listNotesMissingEmbedding's default 5000 per invocation (embed-backfill.mjs:94,local/embeddings.ts:162) — >5000-note vaults need re-runs; SaaS does one unpaginated SELECT with no.limit/.range(embed-backfill.mjs:134-138), implicitly capped by PostgREST max-rows. Batch defaults differ: CLI 32 (embed-backfill-args.mjs:37) vs HTTP route 50 (embed route.ts:131, 205); both max 500. Missing-detection is also asymmetric: local is model-aware (missing = nonote_embeddingsrow for the current embed model, so a model change re-embeds everything), while SaaS treats any falsynotes.embeddingas missing regardless of model (embed-backfill.mjs:143) — a model migration never triggers SaaS re-embeds. - FTS local side:
notes_ftsis a contentless FTS5 virtual table (content='notes',content_rowid='rowid', columns title/body_md/type,tokenize='unicode61 remove_diacritics 2') synced by AFTER INSERT/UPDATE/DELETE triggers onnotes, with a'rebuild'maintenance command — all pinned bylib/db/fts.test.ts:71-139; tags are deliberately not indexed inline — filtered via anote_tagsEXISTS join to keep tag changes O(1) (lib/db/migrations/sqlite/0001_fts.sql:3-18,local/search.ts:72-76). Search orders bybm25(notes_fts)with sign-flipped score ("bm25 is negative-better; flip sign",lib/storage/local/search.ts:85-102) and<mark>-wrapped snippets (snippet(notes_fts, -1, '<mark>', '</mark>', '…', 12)); trashed rows excluded. - FTS query building (local):
ftsQuerystrips every FTS5 metacharacter (" * ( ) : ^ ~ + - ,→ space), splits on whitespace, and turns each token into a quoted prefix match"token"*joined withAND(typeahead-friendly,local/search.ts:25-33) — so hyphens/apostrophes become token splits and user-supplied phrase queries are impossible. Nothing usable left →null→ empty result set without touching SQLite; a fully-empty query instead returns the 20 most-recently-updated non-trashed notes; limit clamped 1..100, optionaltype=/tag=filters (local/search.ts:41-77). - FTS SaaS side:
notes.ftsis a STORED generated tsvector —to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body_md,''))— with a GIN index, recomputed by Postgres on every row write (no trigger code,lib/db/migrations/pg/0000_initial.sql:75-78,103). Thenotes_search(p_workspace_id, p_query, p_limit, p_type, p_tag)SECURITY INVOKER RPC parses viawebsearch_to_tsquery('english', …), ordersts_rank_cddesc thenupdated_atdesc, filters tags viafrontmatter->'tags' ? p_tag, clamps limit 1..100 — it replaced an injectable literal-interpolation search path (pg/0010_notes_search.sql). The surfaced SaaS score is row-position-derived (1/(idx+1)), not rawts_rank_cd, and the snippet is a plain 200-charbody_mdprefix — no<mark>highlighting, unlike local (lib/storage/supabase/queries.ts:359-387). - Wire shape:
{query, hits:[{noteId, title, path, snippet, fusedScore, contributions, ftsRank, vectorRank}]}(hybrid route.ts:65-77); the explorer renders contributions to 5 decimal places and tints rows blue (FTS-only) / amber (vector-only) (hybrid-explorer.tsx:154-219). - Vector-side failure is invisible:
hybridSearchswallows ALL vector-side errors with a barecatchand degrades to FTS-only (hybrid.ts:143-148); forkind='semantic'the same catch yields an empty result set rather than an error. - Validation limits: hybrid
qmax 1000 chars,limitmax 100 (route.ts:18-24); embednoteIdsmax 5000,all:trueSELECT limit 5000. Both routes exportdynamic = 'force-dynamic'. - Bench budget:
pnpm bench-searchexits 3 when FTS p95 exceedsELI_PERF_P95_BUDGET_MS(default 200 ms) so CI can fail on regressions (scripts/bench-search.mjs:88-93).
Gaps and partially wired
- Stale comments contradict shipped code:
lib/search/hybrid.ts:21-25andapp/(saas)/(app)/w/[workspace]/search/page.tsx:10-16both claim SaaS "doesn't yet expose knnByEmbedding" / "falls back to FTS-only", buthybrid.ts:117-141fully implements SaaS vector search via thenotes_knnpgvector RPC (migration 0013). The MCPsearchtool's agent-facing description has the same problem: it tells modelssemantic/hybridare "not yet implemented — lights up in Phase 5" (lib/mcp/tools/search.ts:36-37,44) even though both kinds work — an agent trusting the description will avoid working functionality. Verified against source. StorageAdapter.semanticSearchis a dead interface method: declared inlib/storage/adapter.ts:125but both adapters throwNotImplementedError('semanticSearch', 'Phase 5 (embeddings)')(local.ts:244-245,supabase.ts:133-134); the hybrid module never calls it, going straight to theknnByEmbedding/knnNoteshelpers instead.versionIdis a dead field:scheduleEmbed's ctx documents it as "used to skip stale embeds" (auto-embed-queue.ts:44) butrunEmbednever reads it.- No chunk-level retrieval:whole-note vectors with a 30k-char truncation mean long-note tails are unsearchable semantically; the chunking helper exists but is wired only into C4's chat/research map-reduce.
createNoteActionnever schedules an embed — brand-new notes are semantically invisible until first edited, indexed via the settings card, or backfilled.- Auto-embed has no retry and no queue persistence — a failed gateway call or a recycled worker silently drops the embed until the next save.
- Redis rate-limit backend is a stub:
ELI_RATELIMIT_BACKEND=redisthrows "not shipped yet" (lib/mcp/ratelimit.ts:186-192); everything is in-memory and per-process. - Shared local rate bucket: all local search/embed/chat traffic drains the single
'local'key — a burst of hybrid queries can 429 the same solo user's embed calls. - SaaS backfill is unpaginated: one service-role SELECT of all workspace notes with no
.limit(embed-backfill.mjs:134-138) — large workspaces are implicitly truncated by PostgREST max-rows, with no warning. Local backfill needs manual re-runs past 5000 missing notes. - Arg parser quirk:
flag()returnstruewhen a flag has no value or is followed by another--flag(embed-backfill-args.mjs:13-19), so--vault --batch 5yieldsvault === true(truthy) and kind'local'with a boolean vault path;--batchwith no value silently falls back to 32. - Trashed notes can surface in the local candidate-restricted KNN branch (no
trashed_atfilter,local/embeddings.ts:227-235), unlike the global branch and the SaaS RPC. - SaaS hybrid-with-empty-FTS returns nothing (
hybrid.ts:79-81) — an intentional asymmetry vs local, but it makes the Hybrid tab on SaaS blind to vector-only matches; users must switch to the Semantic tab. - Bench CLI covers FTS only — there is no latency benchmark for the vector or fused pipeline (
scripts/bench-search.mjscallsadapter.searchexclusively).
See also: 01-core-data-storage (adapters, schema, FTS triggers), 02-authoring-ux (save path), 04-ai-platform (gateway, keys, RAG consumer), 05-agent-surface (rate limiter, agent search tools), 07-saas-collaboration (RLS, memberships), 10-end-to-end-journeys (full save→embed→search trace), and the platform overview.