Skip to content

DOC-6809 Docs-only MCP server: spec + v0 prototype#3585

Open
andy-stark-redis wants to merge 25 commits into
mainfrom
DOC-6809-investigate-docs-only-mcp-server
Open

DOC-6809 Docs-only MCP server: spec + v0 prototype#3585
andy-stark-redis wants to merge 25 commits into
mainfrom
DOC-6809-investigate-docs-only-mcp-server

Conversation

@andy-stark-redis

@andy-stark-redis andy-stark-redis commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Note: not finished - just using the PR for Bugbot feedback so far.

What

Investigation (DOC-6809) into a read-only docs MCP server — a knowledge-plane server that exposes the Redis documentation corpus as agent-queryable tools, distinct from the existing data-plane redis/mcp-redis (which reads/writes a live Redis instance). It's read-only, needs no database or credentials, and returns citations to our docs.

It reuses the feed we already publish (Hugo per-page index.jsongenerate_ndjson.pydocs.ndjson), so there's no new content pipeline.

Contents

  • build/docs-mcp-server/SPEC.md — design: 5-tool surface (search_docs, get_page, get_section, get_examples, get_command), deployment/search-backend matrix, phased plan.
  • build/docs-mcp-server/node/ — a working v0 prototype (TypeScript, @modelcontextprotocol/sdk, stdio) with a self-contained BM25 index and two tools: search_docs + get_page. Includes an offline smoke test (npm run smoke).

Findings from running v0 against the live feed (2,531 pages)

  • Index build ~0.3 s, queries ~75–125 ms → search compute is not the bottleneck; Rust/WASM would be premature.
  • Lexical ranking is the real limitation: no stemmer ("append" misses XADD's "appends"), and canonical command pages lose to release-notes / operator / client-library pages that repeat the same terms. This — not performance — is what motivates the vector-search (RediSearch/RedisVL) upgrade path in the spec.

Status

Investigation / prototype. Not wired into any published site. search_docs/get_page only; get_examples, get_section, get_command are v1.

🤖 Generated with Claude Code


Note

Low Risk
New build-only prototype and docs; read-only public corpus, no auth or production wiring in this PR.

Overview
Adds build/docs-mcp-server/ as a new investigation package: a knowledge-plane MCP server over existing docs.ndjson, separate from data-plane mcp-redis.

Design (SPEC.md) defines a five-tool surface, stdio vs hosted deployment, lexical v1 vs vector-on-Redis v2, and defers version filtering until the feed has a real multi-version model.

Shipped prototype (node/) is a TypeScript stdio MCP server with search_docs and get_page: in-memory BM25 (Porter stemming, field/page-type boosts), NDJSON feed load, ambiguous id / partial-url handling, MCP isError on failed lookups, and refusal to start on an empty index. With REDIS_URL, search_docs switches to hybrid mode (lexical + Redis section-vector KNN + weighted RRF, query embed via fastembed-js); scripts/load-index.mjs builds the vector index.

Measurement harnesses add vector-eval/ (offline numpy + bge-small, fusion sweep) and redis-eval/ (Redis KNN parity, Node embed parity, native FT.HYBRID comparison, vector dump for loader seeding), plus npm run eval / eval:hybrid on a 35-case suite.

.claude/state/assess-comments.coverage.md is updated with evidence from PR #3585 (capability counts, bugbot drip-feed / fix-quality notes)—ledger maintenance only, not product code.

Reviewed by Cursor Bugbot for commit 753fd94. Bugbot is set up for automated code reviews on this repo. Configure here.

Investigation into a read-only "docs MCP server" — distinct from the existing
data-plane redis/mcp-redis — that exposes the documentation corpus as
agent-queryable tools over the docs.ndjson feed we already publish. Spec plus a
working v0 (search_docs + get_page, self-contained BM25, no datastore). Two
findings from running it against the live feed shaped the design: indexing
2,531 pages takes ~0.3s and queries ~100ms, so search compute is a non-issue
and Rust/WASM would be premature optimisation; but untuned lexical ranking
mis-ranks natural-language queries — with no stemmer, "append" misses XADD's
"appends", and canonical command pages lose to release-notes, operator
custom-resources, and client-library pages that repeat the same terms. That
ranking gap, not speed, is what justifies the vector-search upgrade path in the
spec.

Learned: measured perf rules out WASM; lexical ranking (not speed) is the real limit
Constraint: docs MCP server stays read-only, no DB connection, no creds (unlike mcp-redis data plane)
Rejected: Rust/WASM for search speed | ~0.3s index + ~100ms queries on 2,531 pages, compute isn't the bottleneck
Directive: don't over-tune lexical BM25 weights (overfits to sample queries); fix ranking via stemming/analyzer or vector search
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 2, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

DOC-6809

@jit-ci

jit-ci Bot commented Jul 2, 2026

Copy link
Copy Markdown

🛡️ Jit Security Scan Results

CRITICAL HIGH MEDIUM

✅ No security findings were detected in this PR


Security scan by Jit

Comment thread build/docs-mcp-server/node/src/search.ts
Comment thread build/docs-mcp-server/node/src/index.ts Outdated
… Codex)

Address Cursor Bugbot findings on PR #3585 plus a Codex follow-up review.
The feed's `id` is the last URL path segment and is NOT unique (~213 ids map
to several pages), so `byId` is now a multimap and `get_page` resolves by the
unique `url` first; an ambiguous `id` or partial `url` returns candidate urls
instead of silently returning the wrong page. The MCP handler now sets
`isError` when a tool result carries an `error`. Smoke test rewritten as
assertions (incl. a colliding id="install" fixture pair) so this can't regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread build/docs-mcp-server/node/src/search.ts Outdated
Comment thread build/docs-mcp-server/node/src/search.ts Outdated
Comment thread build/docs-mcp-server/node/src/tools/get-page.ts Outdated
andy-stark-redis and others added 4 commits July 2, 2026 16:09
test/mcp-client.mjs drives the built stdio server through the real MCP client
(spawn -> initialize -> tools/list -> tools/call) against the fixture, so the
transport path and isError signalling are exercised end-to-end, not just the
tool logic. Live-feed testing surfaced that the spec's assumed section roles
(syntax/examples) don't match the feed (it uses content/parameters/example/
returns, no syntax), so a roles filter can silently return nothing — recorded
in SPEC open questions to resolve before building get_examples/get_command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 Bugbot findings on the get_page/search resolution logic were churn:
the round-1 duplicate-id fix introduced two of them. Rather than three point-
patches, consolidate resolution into one convergence model (per Codex design
review): collect candidate pages across all supplied handles (exact url, id,
boundary-anchored suffix url) and resolve only when they converge on exactly
one page — so an ambiguous url still tries id, and url/id pointing at different
pages reports conflicting handles instead of silently applying precedence.

- Partial-url suffix matching is now anchored to "/" path segments, so "get"
  matches .../commands/get but not .../config-get or .../arget (was 19 false
  matches on the live feed, now 2 legitimate ones). [Bugbot High]
- Ambiguous url no longer short-circuits the id fallback. [Bugbot Medium]
- Removed the `version` param: it advertised a `latest` default it never
  enforced. Deferred until a committed URL/version model exists. [Bugbot Medium]
- Smoke test extended with boundary and conflicting-handles assertions (17 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icient

npm run eval scores search_docs retrieval against a curated question set
(test/eval/cases.json — command lookups phrased without the command name),
reporting recall@k / MRR with a data-integrity check that flags expected urls
missing from the feed. Feed read from DOCS_NDJSON or a gitignored local cache.

Baseline on the live feed (22 cases): recall@1 32%, @3 45%, @5 59%, @10 73%,
MRR 0.42. Confirms with data what was hypothesised: lexical retrieval alone is
not good enough — canonical command pages lose to sibling commands (zadd ->
zincrby), operator pages (del -> remove-node), and concept pages (ft.create ->
a full-text concept page), and there is no stemming (append !-> appends). This
is the measured case for the stemming/boost/vector work in SPEC §6/§10 and lets
future ranking changes be compared against a baseline instead of tuned blind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two principled ranking changes, measured against the eval baseline:
- Porter stemmer (src/stem.ts) applied to both index and query tokens so word
  forms conflate (append/appends, prepend/prepending, expires/expire). Stopwords
  are filtered on raw tokens before stemming.
- Page-type weighting on the final score: demote release-notes/REST-API (x0.5)
  and operator (x0.7) pages, modestly boost /commands/* (x1.5) — targeting the
  observed failures where operator/concept pages outranked command pages.

Eval (22 command-lookup cases): recall@1 32->50, @3 45->68, @5 59->86,
@10 73->95, MRR 0.42->0.65; misses 6->1. Caveat recorded in README/SPEC: the
eval is command-heavy so the /commands/* boost partly flatters it — concept/
how-to cases still needed before calling lexical sufficient generally, and the
residual miss (del/unlink beaten by flushdb for "remove a key") is a semantic
gap that motivates vector search. Smoke still green (17 assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread build/docs-mcp-server/node/src/tools/get-page.ts Outdated
Bugbot High: collectCandidates always merged id matches even when url already
resolved to an exact page, so get_page(url=<exact>, id=<its non-unique id>) —
the natural flow when an agent echoes both fields from a search hit — returned
an ambiguous error instead of the page. The consolidation's "always converge"
rule over-corrected: an exact url is unique and should win.

Fix: an exact url short-circuits to that page, EXCEPT when a supplied id points
somewhere else entirely (idPages don't include the exact page) — still a
genuine conflict, so still reported. This reconciles the Bugbot finding with
Codex's earlier conflict-detection ask: exact-url + its-own-id resolves;
exact-url + a different id conflicts. Smoke covers both (17 assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread build/docs-mcp-server/node/src/index.ts
Comment thread build/docs-mcp-server/node/test/eval/run.mjs Outdated
andy-stark-redis and others added 3 commits July 8, 2026 10:54
De-bias the retrieval eval (was 22 command-only cases): tag each case
command/concept and add 13 concept/how-to cases with feed-verified ground truth
(incl. pages under /operate/, to test the demotion). Runner now reports recall
per kind.

Result under the shipped weighting: command recall@5 86% / MRR 0.65 but
**concept recall@5 46% / MRR 0.29** — concept retrieval is ~half as good. The
/commands/* x1.5 boost is the cause: it ranks command pages above the canonical
concept page when both compete (persistence -> bgrewriteaof, replication ->
cluster-replicate, keyspace-notifications -> expire), and the blanket /operate/
demotion drags down legitimate concept pages. Ablation (neutralise command
boost, demote only rest-api/release-notes/references): concept @5 46->62,
MRR .29->.45; command @5 86->73. Command-optimised vs balanced is a workload
call — left as an open decision in SPEC, weighting unchanged for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the concept-case eval finding, switch page weighting from command-optimised
to balanced: demote only release-notes/REST-API/references (x0.5); drop the
/commands/* x1.5 boost and the blanket /operate/ x0.7 demotion. This trades
command recall@5 86->73 for concept 46->62 and the best overall MRR (0.53),
and stops burying legitimate /operate/ concept pages (persistence, replication).
Command ranking should be lifted by better signal (vectors), not the boost.
Eval + smoke green; SPEC decision marked resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ical

A no-Redis experiment (vector-eval/) to decide whether vector search is worth
the hosted infra before building it: embed the corpus with bge-small
(fastembed/ONNX), rank in numpy, and score vector + hybrid (RRF fused with the
production lexical ranker, dumped via dump-lexical.mjs) against the same 35-case
eval. Verdict: hybrid clearly wins — overall recall@5 69->86, MRR .53->.66;
command @5 73->91 — so the RediSearch/RedisVL build is justified, and it should
be a HYBRID ranker (vector alone only modestly beats lexical). Concept queries
stay weak (vector ties lexical; hybrid lifts @5 62->77 but not top-1/3), most
likely an artefact of the coarse page-level chunking used here.

A first section-level attempt (21.5k chunks) stalled on this machine: embedding
runs only ~6 chunks/s (unoptimised CPU ONNX), so it ran >1h and the CPU
collapsed with no checkpoint written. The committed version is the slim,
instrumented rebuild: one page-level chunk per page (~2.5k), batched embedding
with progress logging, end-of-run cache. Section-level chunking over the feed's
sections[] is the next experiment and needs proper batching/caching to be
runnable here.

Learned: hybrid (lexical+vector RRF) clearly beats either alone; vector alone only modest; concept stays weak on coarse page-level chunks
Directive: build the hosted path as HYBRID not pure vector; use the feed sections[] for section-level chunking to attack the concept gap; don't read the ~6 chunks/s rate as a production latency signal (unoptimised local ONNX)
Gaps: section-level chunking and real production embedding latency not yet measured; concept n=13 so those numbers are directional
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 25c1d25

…erdict

Add section-level chunk mode (one chunk per feed section + a page anchor,
~15.3k chunks) with checkpoint/resume, alongside the page-level mode. Re-run on
the same 35-case eval sharpens the vector picture:

- Section-level chunking is the big lift, and it fixes concept: vector MRR
  .47->.64 (concept), .60->.72 (overall). The coarse page-level chunks were the
  concept bottleneck, as hypothesised.
- Surprise: with strong section-level embeddings, equal-weight RRF hybrid now
  *dilutes* the top ranks. Pure VECTOR is best by MRR/@1-@3 (overall .72 vs
  hybrid .67; concept .64 vs .53); HYBRID is best by recall@5/@10 (concept @5
  92% / @10 100%). Fusing a strong retriever with a weaker one pulls its
  confident #1 hits down.

Refined decision (SPEC §6/§10): build section-level embeddings + a WEIGHTED
fusion favouring vector (not equal-weight RRF), or ship pure vector; tune
against this eval before hosting. Checkpoint/resume proved out — this run
resumed cleanly from 5120/15300 after the prior session's process exited.

Learned: section-level chunks fix vector's concept gap; equal-weight RRF dilutes a strong vector retriever's top ranks (precision vs recall trade-off)
Directive: use section-level chunking + weighted fusion (favour vector) for the hosted build; don't equal-weight-RRF a strong vector with weak lexical
Gaps: weighted/score fusion not yet measured; small eval (35 cases); single small model (bge-small); sections truncated ~1200 chars
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at a276698

…wins

With section embeddings cached, sweep fusion methods on the eval (no
re-embedding): pure vector, equal RRF, and weighted RRF favouring vector at
2x/3x/5x. Result: weighted RRF (~2-3x vector) recovers the top-1 precision that
equal-weight RRF lost AND keeps hybrid's top-k recall — overall MRR .73 (vs .72
pure vector, .69 equal RRF), command MRR .80, concept @5 92% / @10 100%. So the
earlier "RRF dilutes the top ranks" was specifically *equal* weighting; weight
vector above lexical and it's the best of both. n=35 so 2x vs 3x is noise.

Resolves the open "which fusion" question (SPEC §6/§10): the hosted build is
section-level embeddings + vector-weighted RRF.

Learned: equal-weight RRF dilutes a strong retriever; weighting it ~2-3x above the weak one recovers top-1 precision while keeping top-k recall
Directive: hosted build = section-level embeddings + vector-weighted RRF (not equal-weight); don't read 2x-vs-3x as significant at n=35
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 0c3e7df

Comment thread build/docs-mcp-server/node/src/search.ts Outdated
Add a Paice/Lancaster stemmer (src/stem-paice.ts, standard Lancaster rule
table) switchable via STEMMER=paice (default stays Porter, zero runtime impact
when unused), and bake it off on the eval. Verdict: Porter wins or ties —
Porter overall @5 69% / MRR .53 vs Paice 63% / .52. Paice edges @1 (aggressive
conflation occasionally nabs the exact top hit; command @1 45% vs 41%) but is
worse at @3-@10 and on concept, and over-stems (organization->org,
maximum->maxim), which pulls in false matches and costs mid-rank recall. Kept
Porter as the default analyzer.

Small margins at n=35, so "Paice isn't better here", not "Paice is bad". And it
matters least where we're headed: in the vector-weighted-RRF hybrid recipe
lexical is the minority signal, so the stemmer mainly affects the pure-lexical
(stdio/no-datastore) deployment — where Porter is the better default.

Learned: Paice/Lancaster over-stems this technical corpus (organization->org); marginally better @1, worse mid-rank/concept than Porter; net keep Porter
Directive: keep Porter as default; STEMMER=paice env flag left in for re-runs; stemmer choice is second-order given hybrid weights lexical low
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at f54a8bb

…ge-small

Embed the section-level corpus with bge-base-en-v1.5 (per-model cache) and
compare at the chosen weighted-RRF 3:1 recipe. bge-base overall MRR .74 vs
bge-small .73, command .82 vs .80, concept .61 vs .62 — recall@5 identical
(91/91/92). The only real gain is command (already strong); concept (the weak
spot) is flat within n=13 noise. bge-base costs ~2x the vector size + compute
and embedded at half the speed (~3 vs ~6-11 chunks/s), so it isn't justified.
Keep bge-small for the hosted build.

Learned: a bigger embedding model (bge-base) gives only a rounding-error overall gain here, concentrated on already-strong command queries, nothing on concept — bge-small is good enough
Directive: use bge-small for the hosted build; revisit a bigger model only if the eval grows and concept is still short
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 0001afe

Comment thread build/docs-mcp-server/vector-eval/eval_vector.py Outdated
…ake-off

The bge-base bake-off (0001afe) committed only the README documenting the
result; the eval_vector.py change that actually enabled running a second model
was left uncommitted in the working tree. Restore it so the tree matches the
documented state.

Makes the embedding model selectable via EMBED_MODEL (default stays
bge-small-en-v1.5), gives non-default models their own per-model cache file
(default model keeps embeddings-{mode}.npz for back-compat), and detects the
embedding dimension from the first batch instead of hard-coding DIM=384 (bge-base
is 768-dim, so the old constant would have mis-shaped the array).

Learned: a documented experiment can leave its enabling code uncommitted if only the write-up is staged; check the tree matches the claim
Directive: default path is byte-for-byte unchanged; re-run other models with EMBED_MODEL=BAAI/bge-base-en-v1.5
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…line eval

First step of the hosted-phase prototype (redis-eval/, sibling to vector-eval/).
Loads the cached bge-small section vectors into a Redis 8 FLAT/COSINE index and
checks that KNN + app-layer weighted RRF reproduces the offline numpy ranking,
holding embedding constant (same cached corpus + Python query vectors on both
sides) so the only variable is Redis vs numpy. It does: command metrics are
bit-identical (MRR .795), KNN p50 4.1ms at K=200. The lone concept-MRR delta
(.638->.599) is a single query where redis-py/connect and ioredis/connect carry
an exactly-equal cosine score (gap 0.00e+00) that numpy's stable argsort and
Redis's internal order tie-break differently — parity to tie-breaking, not a
ranking defect (diag_divergence.py proves it). That tie also exposed a corpus
quirk, not a Redis one: client "connect" pages embed identically because their
section anchor text is templated per-client, so the embedding can't separate
redis-py from ioredis for a "Python" query — a chunking lever for later, not now.

Learned: RediSearch FLAT reproduces numpy cosine ranking to within tie-breaking; equal-score chunks are the only divergence and don't move recall@k/MRR except at an exact tie
Constraint: keep app-layer weighted RRF favouring vector ~2-3x — command parity depended on fusing the Redis vector ranking with the dumped lexical exactly as the offline recipe did
Directive: store owner (page URL) as an unindexed hash field returned via RETURN, NOT in the index SCHEMA — redis-py rejects a non-indexed non-sortable field, and owner never needs indexing
Gaps: parity used Python-embedded query vectors; Node/ONNX embedding parity is Step 2 and still unverified
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 27084d0

…n fastembed

Confirms that embedding queries in-process in the Node MCP server is safe. Node
`fastembed` (v2.1.0, the Node port of the same Qdrant fastembed the Python eval
uses) embeds bge-small identically to Python: cosine 1.00000 across all 545
compared texts (35 queries + a 510-chunk corpus sample), 0 below 0.999. So the
cached Python corpus vectors and every offline number stay valid regardless of
which language does the embedding. Input drift is eliminated by having Python
export the exact strings both sides embed. The eval pure-vector metrics are
identical; the only movement is wrrf-v3 command MRR .795->.789 on one query —
the same RRF tie-break sensitivity noted in Step 1, where cosine-1.0 is not
bit-identical so a couple of near-tied chunks reorder in the tail and fusion
flips one fused rank while the vector-only metric doesn't move. Not an embedding
discrepancy.

Learned: fastembed-js reproduces Python fastembed bge-small vectors to cosine 1.0, so embed-in-process-in-Node is safe and cross-language corpus/query mixing is valid
Directive: embed with plain embed() and prepend the BGE query prefix manually — do NOT use queryEmbed/passageEmbed, whose built-in prefix wording can differ from the eval's and silently break parity
Constraint: fastembed-js downloads a ~128M model cache to node/local_cache/ (gitignored) — keep it out of git and out of the npm package
Reversibility: clean
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at ee3e586

… weighted RRF

Resolves the fusion fork: keep BM25 lexical + weighted-RRF fusion in the app
layer, use Redis only for vector KNN. Redis 8.8 has native FT.HYBRID (8.4.4+),
but it does not reproduce the validated recipe (vector ~3x lexical, overall MRR
.73) for two independent reasons, and native_hybrid.py decomposes both on the
35-case eval. First, its COMBINE RRF exposes only CONSTANT/WINDOW — no
per-retriever weights, so it is equal-weight RRF (the variant the fusion sweep
already found dilutes the top ranks); COMBINE LINEAR can weight but fuses raw
scores, a different algorithm, and lands at .501. Second and more decisive, its
lexical side is Redis's own BM25 over a body-text index (MRR .117, 0% command
recall@5) versus our Porter-stemmed, field-boosted Node ranker (.530): a
command query like "append an entry to a stream" carries no xadd token, so plain
body-text BM25 ranks streams tutorials above the XADD page, and only the Node
ranker's title/slug boosts + page-type weighting fix that. app-wrrf over our
lexical + Redis vector reproduces Step 1 at .731; the same fusion over Redis's
own BM25 collapses to .431. A corollary worth remembering: RRF gives every input
list fixed rank-reciprocal mass regardless of that list's quality, so it injects
a bad retriever's distractors — which is why app-wrrf-over-Redis-BM25 (.431)
scored below native LINEAR (.501) on the same signals; RRF only wins when both
retrievers are good. This matches SPEC section 6 — lexical has to live app-side
for the stdio no-datastore mode anyway.

Learned: native FT.HYBRID can't express our recipe — RRF is equal-weight-only and its raw BM25 lexical is far weaker than our tuned Node ranker (0% command recall@5); the all-in-Redis showcase costs ~.23 MRR
Constraint: lexical BM25 + weighted-RRF fusion stay app-side; Redis is the vector-KNN backend only — do not route search through FT.HYBRID
Directive: RRF is only safe when every fused list is a good retriever (fixed rank-reciprocal mass injects a bad list's distractors); if a weak signal must be fused, prefer LINEAR score fusion with a low weight
Directive: FT.HYBRID call gotchas are in redis-eval/README — VSIM @field $param, KNN/COMBINE counts are k/v-pair counts, no DIALECT, project with LOAD not RETURN
Gaps: native BM25 was queried naively (OR of terms, body-only index); a Redis index mirroring the Node analyzer would narrow the lexical gap but not enable weighted fusion
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 72b91c8

…N + BM25)

First change to the shipped server: a hosted hybrid search mode behind a
REDIS_URL feature flag, implementing the recipe the measure-first work (Steps
1-3) validated. When REDIS_URL is set, search_docs fuses the existing in-memory
BM25 lexical ranker with vector KNN from Redis via weighted reciprocal-rank
fusion (vector ~3x); the query is embedded in-process with fastembed-js. Without
REDIS_URL the server stays lexical-only and datastore-free (the stdio mode), and
that default path is deliberately untouched. Redis is the vector backend only —
lexical and fusion stay app-side, per the Step 3 finding that native FT.HYBRID
can't express the weighted recipe. New modules: embed.ts, chunk.ts (a faithful
port of the Python build_chunks so Node corpus vectors match the offline ones),
vector-store.ts, hybrid.ts; a build-time loader (scripts/load-index.mjs) and a
through-the-tool eval (test/eval/run-hybrid.mjs).

Verified live against local Redis 8.8: hybrid overall MRR .704 / command .783 /
concept .570 (concept @10 100%), within tie-break noise of the offline .731 —
the gap stacks the Step 1 KNN and Step 2 embedding tie-breaks, not a ranking
difference. Lexical-only default unchanged (.525) and smoke passes. Net payoff:
overall MRR .53 -> .70, concept @10 77% -> 100%.

Two things bit and are worth flagging. Making search_docs async (the hybrid path
must await embedding + Redis) silently broke every synchronous caller —
run.mjs, dump-lexical.mjs, smoke.ts — which failed only at runtime, not at
compile time, because they discarded the now-Promise return. And node-redis v6
renamed its schema enums (SCHEMA_FIELD_TYPE / SCHEMA_VECTOR_FIELD_ALGORITHM, not
SchemaFieldTypes / VectorAlgorithms) — that one does fail at compile time. For
iteration speed the loader seeded vectors from the cached Python dump rather than
re-embedding 15k chunks in Node at ~4/s; Step 2 proved the two are cosine-1.0
identical, so it is equivalent, and the production --embed path is still there.

Learned: hybrid lifts overall MRR .53->.70 and concept @10 77%->100% live through the real tool; the ~.03 gap under offline .73 is stacked FLOAT32 KNN + embedding tie-break noise, not a ranking regression
Constraint: the no-REDIS_URL lexical-only path must stay datastore-free and behaviourally unchanged — it is the stdio deployment (SPEC §6); the feature flag is the only thing that turns on Redis
Directive: making a tool handler async ripples to every caller — after such a change grep for sync callers (eval/dump/smoke scripts), they fail at runtime not compile time
Directive: the loader --vectors seed path is a dev shortcut (valid because Step 2 proved Node==Python vectors); production must run load-index without it so corpus embedding goes through fastembed-js
Gaps: no HTTP/SSE endpoint, rate-limiting or deploy yet (stopped at the vertical slice for review); FLAT index only (HNSW deferred); package-lock is gitignored so deps float on ^ranges
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 0ccf6c3

Comment thread build/docs-mcp-server/node/src/hybrid.ts Outdated
…ast top-k

Two safe fixes from the Bugbot review of the hybrid work. (1) main() now throws
if loadFeed returns zero pages, instead of advertising readiness over an empty
index and then serving only empty results / not-found errors (Medium 3542934577).
(2) search() scores into an interim array and defers the per-section
matchingSections() re-analysis until after the top-k slice, so it runs for the
~10 returned pages rather than every positive-score page in the corpus (Low
3638621205).

Verified the search refactor preserves ranking exactly: lexical eval still
overall MRR .525 / command .571, hybrid still .704 / .783, smoke all green. Those
eval numbers are the regression oracle for any change to the scoring loop.

Learned: the 35-case eval (npm run eval / eval:hybrid) is the regression oracle for search() changes — lexical .525 / hybrid .704 must still hold after touching the scoring loop
Directive: matchingSections is display-only metadata and must not influence ranking — compute it after sort+slice, never let it move results
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at a8b2731

Comment thread build/docs-mcp-server/node/src/index.ts Outdated
Tidies the Step-4 hybrid module boundary in response to two Bugbot findings, both
about the feature-flag boundary not being clean. (1) index.ts no longer statically
imports HybridSearcher/VectorStore — it `await import()`s them inside the
`if (REDIS_URL)` block, so the default lexical-only stdio mode no longer loads
fastembed (native onnxruntime) or the redis client at startup. This restores the
invariant the Step-4 note claimed but the implementation broke: the no-REDIS_URL
path is genuinely dependency-free, and can't crash on a platform lacking the native
ONNX binary when it isn't even doing vector search (Medium 3644667453). (2) The
normalizeUrl helper, previously copy-pasted byte-identically into search.ts,
chunk.ts, hybrid.ts and vector-store.ts, is now a single export in url.ts — the
hybrid path matches pages across those modules by normalized url, so a divergent
copy would silently break cross-module findability (Low 3644535220).

Lexical .525 / hybrid .704 and smoke unchanged after the refactor.

Learned: my own Step-4 static imports violated the dep-free-default-path constraint I'd documented — a feature flag gates *use* but a static import still loads the module graph; only dynamic import actually defers the cost
Constraint: the no-REDIS_URL lexical path must not statically import hybrid.ts/vector-store.ts/embed.ts (they pull in native onnxruntime + redis) — load them via dynamic import inside the REDIS_URL branch only
Constraint: normalizeUrl has ONE definition (url.ts); the chunker, vector store, hybrid fusion and lexical index all match pages by its output, so a second copy that drifts breaks findability silently
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at defc966

Comment thread build/docs-mcp-server/node/scripts/load-index.mjs Outdated
…d path

The --vectors seed path built a Float32Array view directly over the readFile
Buffer (`new Float32Array(buf.buffer, buf.byteOffset, ...)`). A pooled Node
Buffer's byteOffset isn't guaranteed 4-byte aligned, and an unaligned offset
makes the typed-array view throw RangeError. It worked in every test only
because our ~23MB vectors.f32 gets a dedicated allocation at offset 0 — not a
contract. Now copies into a fresh 0-offset ArrayBuffer first (Low 3644788302).
Verified: loader still seeds 15300 vectors and hybrid eval holds at MRR .704.

Learned: a Float32Array view over a Node Buffer can throw on a non-4-aligned pooled byteOffset — large files hide it (offset 0); copy into a fresh ArrayBuffer before viewing, don't trust the direct view
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at dbd7987

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dbd7987. Configure here.

Comment thread build/docs-mcp-server/node/src/embed.ts Outdated
Comment thread build/docs-mcp-server/node/scripts/load-index.mjs
Two Low findings from Bugbot's round-4 re-scan, both in Step-4 code. (1) embed.ts
memoized the FlagEmbedding.init() promise with `??=`, which permanently caches a
*rejected* init — a transient first-run failure (model download timeout, fs
perms) would poison the singleton and break hybrid mode until process restart.
Now clears the cache on rejection so the next embedQuery retries (3645222048).
(2) load-index.mjs accessed chunks[0].vec.length with no empty guard, throwing an
opaque "Cannot read properties of undefined" on an empty/invalid feed; now it
fails with a clear message, and fromEmbed() bails before loading native ONNX to
embed nothing — which also removes an ugly libc++abi teardown crash on the empty
path (3645222055).

Verified: smoke green, seed path still loads 15300 vectors, hybrid MRR .704
unchanged, empty-feed now errors cleanly on both embed and seed paths.

Learned: a promise memoized with `??=` caches rejections too — for lazy init of a fallible singleton (model load, DB connect) always clear the cache in .catch() so a transient failure doesn't permanently wedge the feature
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 2bdf854

… validation

Independent Codex review of the Step-4 hybrid code surfaced a deeper class of
issue than Bugbot's line-level drip — resource lifecycle, error suppression,
resilience, input validation. Fixes the four highest-value findings; two
production-grade items (atomic index swap on reload; FT.INFO schema/population
verification at startup) are left as in-code TODOs, deferred as beyond the
offline single-instance prototype.

- Graceful degradation (hybrid.ts): a transient embedding or Redis KNN failure
  no longer fails search_docs — it logs and returns the already-computed lexical
  results. Hybrid is an enhancement over a working lexical base, so it should
  fall back to it, not collapse.
- Redis lifecycle (index.ts): the store is now closed on SIGINT/SIGTERM and on
  connection close, and on startup failure — previously the open socket could
  keep the stdio process alive after the client disconnected.
- dropIndex error scoping (vector-store.ts): only the "index doesn't exist"
  error is swallowed; auth/network/permission errors propagate instead of being
  silently ignored and letting the loader reuse a stale index.
- Seed validation (load-index.mjs): the --vectors seed is checked for dim ==
  EMBED_DIM, owners length == n, and byteLength == n*dim*4 before loading, so a
  truncated/mismatched dump fails loudly instead of "loading" and being silently
  misindexed by Redis.
- EMBED_DIM moved to a dep-free constants.ts so vector-store and the loader's
  seed path can validate the dimension without importing embed.ts (which pulls
  in fastembed's native ONNX runtime) — same dep-free-default-path principle as
  the dynamic-import fix.

Verified: smoke green, lexical MRR .525 and hybrid .704 both unchanged, validated
seed still loads 15300 vectors.

Learned: the independent Codex sweep caught a whole class Bugbot's per-line scan missed (lifecycle/resilience/validation) in one pass — worth running once on a large new subsystem rather than waiting out the bot's round-by-round drip
Constraint: hybrid search MUST degrade to lexical-only when the vector path (embedding or Redis KNN) fails — never fail search_docs when lexical results exist
Constraint: the Redis backend must be closed on shutdown (signals + connection close) so a live socket can't keep the stdio server alive after disconnect
Directive: EMBED_DIM lives in constants.ts (dep-free) — import it there, not from embed.js, in any module that must stay off the fastembed/native-ONNX graph (vector-store, loader seed path)
Ticket: DOC-6809
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 0df2793

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants