Keyword plus vector: the cheap path

Problem

Embeddings are good at “how do I run this headless in CI” and bad at --append-system-prompt. The golden baseline showed it plainly: the query “what does CLAUDE_CODE_SYNC_SKILLS control” surfaced the right pages but the sentence defining the variable sat below rows about skills in general; the Windows install query was dominated by troubleshooting rows even after the irm … | iex snippet was in the mirror. A retriever that only has cosine similarity cannot prefer the line that contains the literal token.

The obvious fix is a lexical index. The constraint was cost: the facts layer is 56,489 units across the estate, every one already embedded, and a second vector model was out of the question. So the second index had to be free to build, free to query, and live in the same store.

Inputs

Commands

# cwd: ~/.global-ai-hub
# build the keyword table for one docset's facts layer (no embedding call)
.venv/bin/python scripts/docset_indexer.py keyword-index codeclaudecom__codeclaudecom --layer facts

# query it: any-term OR (default), all-term AND, exact phrase, or raw FTS5 syntax
.venv/bin/python scripts/docset_indexer.py keyword codeclaudecom__codeclaudecom "CLAUDE_CODE_SYNC_SKILLS" --layer facts --mode any --top 5
.venv/bin/python scripts/docset_indexer.py keyword codeclaudecom__codeclaudecom "append-system-prompt" --layer facts --mode phrase

# the same through MCP (the pipeline's index stage now builds the kw rows for every layer)
#   hub_query_docset(key, q, mode="keyword")   # BM25 only
#   hub_query_docset(key, q, mode="hybrid")    # RRF over the vector and keyword legs

Outputs

The keyword layer is one FTS5 virtual table, kw(docset, url, seq, text), created beside the vector rows in docsets.db. keyword_query runs SELECT url, seq, snippet(kw, …), bm25(kw) FROM kw WHERE docset=? AND kw MATCH ? ORDER BY bm25(kw); the ChromaStore delegates to its registry SqliteStore so both backends answer the same way.

The part that took thought is fts_match. FTS5 treats -, _ and . as operators or separators, so a naïve MATCH '--append-system-prompt' is a syntax error and X-Markdown-Tokens becomes three loose tokens. Every user term is therefore double-quoted, which turns a token like --append-system-prompt into a phrase of its sub-tokens (append system prompt, in order, adjacent) — exactly what a reader means by it. mode="all" joins the quoted terms with AND, any with OR, phrase quotes the whole query, and raw passes the caller’s own FTS5 syntax through.

The hybrid mode fuses the two legs with reciprocal-rank fusion keyed on (url, seq) — the same unit reached by both legs scores higher than a unit reached by one — and reports a legs count on each hit so a caller can see whether a result was corroborated. The keyword rows travel in docsets.db, so the other boxes receive them on the next replication push without re-embedding anything.

On the golden set the misses that the lexical leg addresses are exactly the exact-token ones: CLAUDE_CODE_SYNC_SKILLS (question 3), --append-system-prompt (question 7) and the plugin marketplace add command (question 8) each land the defining row first in keyword mode. Question 1 (Windows install) remains a ranking problem in the vector leg and is the case the hybrid mode exists for.

What the lint found

The lint’s P11 (retrieval readiness) is a live pass: it probes the facts file with the exact tokens its own descriptions name and expects a hit. Before the keyword layer, a P11 probe for X-Markdown-Tokens or describedby against the llms.txt topical file depended on the embedding treating a hyphenated header name as meaningful; after it, the probe is a BM25 lookup and hits deterministically. P3/D2 (descriptions name the exact tokens the reader will search for) is the producer-side half of the same rule: if the index does not contain the token, no index can be searched for it.

Lessons

Reproduce

hub/scripts/docset_indexer.py (fts_match, SqliteStore.keyword_query, ChromaStore.keyword_query) is vendored here with hub/tests/test_docset_keyword.py. Recipes 03 (keyword via MCP) and 04 (hybrid via MCP) in the examples cookbook show the call shapes; recipe 07 covers the facts-to-RAG path and the embedding-model trap.