Repository navigation
Local hybrid retrieval index (Vectorize + BM25) over extracted chunks #14
Description
Context
Grounded chat (memwalChat, apps/api/src/worker.ts ~3498) delegates semantic recall entirely to the MemWal relayer (MemWalMcpClient.recallSiteContext) with a keyword-only fallback and a fixed-ish topK; there is no local vector index. The relayer is flaky enough that packages/memwal already wraps it in retries. Meanwhile buildChunks already produces stable, citable chunks at build time (worker ~2186/2790) that are never embedded or indexed.
Goal / user story
As a user chatting against a namespace, I want fast, reliable hybrid retrieval (vector + keyword) over the namespace's own chunks, so answers are well-grounded even when the MemWal relayer is slow or down.
Acceptance criteria
- A Vectorize binding is added to
wrangler.jsonc/WorkerEnv, with an index dimension matching the chosen Workers AI embedding model. - At build time (queue consumer, where
buildChunksalready runs) each chunk is embedded via Workers AI (@cf/baai/bge-*) and upserted to Vectorize keyed by${namespace}:${chunkId}with metadata{ namespace, chunkId, routePath, url }; chunk text is persisted (D1 or R2) for retrieval-time hydration. -
memwalChatperforms hybrid retrieval: embed the query → Vectorize topK (namespace-filtered) fused with a keyword/BM25 pass via reciprocal-rank fusion, then MMR de-dup, with a configurabletopK. - MemWal relayer recall becomes a fallback used only when the local index returns nothing, removing it from the hot path.
- The chat
sources[]shape (url/routePath/quote/blobId) is preserved so the existing UI keeps rendering per-turn provenance.
Implementation notes
- Touch
apps/api/cloudflare/wrangler.jsonc,apps/api/src/worker.ts(build/queue consumer +memwalChat), plus a small D1 migration for acontextmem_chunkstext store (or reuse R2chunks.ndjson). - Pick model + dims explicitly:
@cf/baai/bge-base-en-v1.5(768) orbge-small(384). Batch embeddings to respect Workers AI rate limits. - BM25: a lightweight keyword score over the candidate set is sufficient; full inverted index is optional. RRF (
1/(k+rank)) keeps fusion simple and tuning-free. - Gotchas: Vectorize upserts are eventually consistent (don't query immediately after upsert in the same run); use metadata filtering for per-namespace isolation; account for Vectorize/AI cost per build. This index is off-chain edge infra — verifiability still comes from the receipt +
chunkGraphDigest+ stored ciphertext, not the index.
Sui Overflow angle
A visibly fast, well-cited grounded-chat answer is the centerpiece of the live demo; replacing the flaky external relayer with a local hybrid index makes that moment reliable on stage and showcases the quality of on-Walrus context.
Dependencies
Builds on "Token-aware chunking with overlap" (better chunks → better embeddings). Decision needed: commit to Cloudflare Vectorize binding/cost vs. continue delegating recall to MemWal.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add a Cloudflare Vectorize index (
contextmem-chunks, 768-dim cosine, model@cf/baai/bge-base-en-v1.5) and embed every chunk at build time inside the queue consumer, wherebuildChunksalready runs andstoreNamespaceImportalready persists/context/chunks.ndjsonto R2. The key design decision is to not duplicate chunk text in a new D1 table — chunk text is already stored verbatim in the R2/context/chunks.ndjsonartifact, so Vectorize stores only the embedding + tiny metadata{namespace, chunkId, routePath, url}, and retrieval hydrates full text from that R2 file.memwalChatthen does hybrid retrieval (Vectorize ANN + in-memory BM25 over the loaded ndjson → RRF → MMR de-dup) and only falls back to the MemWal relayer when the local index returns zero hits. All hybrid/BM25/RRF/MMR logic lands in a new pure-function module so it's unit-testable without bindings.Prerequisites & dependencies
npx wrangler vectorize create contextmem-chunks --dimensions=768 --metric=cosinenpx wrangler vectorize create-metadata-index contextmem-chunks --property-name=namespace --type=string(required — namespace filtering fails without it)contextmem-chunks-staging).env.AI, wrangler.jsonc:36-38; used at worker.ts:3582). No new secret.buildChunks(packages/core/src/chunks.ts:13,MAX_CHUNK_CHARS=4000) already emits stablechunkIds we key on. Ship independently; quality improves when chunking lands.chunkGraphDigest+ stored ciphertext, per issue notes).Step-by-step
Add the Vectorize binding + types.
apps/api/cloudflare/wrangler.jsonc: add a top-level"vectorize": [{ "binding": "VECTORIZE", "index_name": "contextmem-chunks" }](next toaiat line 36-38) and a mirrored block in theenv.stagingsection (after the stagingaibinding, ~line 86) pointing atcontextmem-chunks-staging.apps/api/src/worker.ts: extendWorkerEnv(type at :32-55) withVECTORIZE?: VectorizeIndexLike;afterAI?(:47). Define a localVectorizeIndexLikeinterface (mirroring the existing*Likepattern at :70/:82/:99) withupsert(vectors: Array<{id:string; values:number[]; metadata?:Record<string,string>}>): Promise<unknown>andquery(values:number[], opts:{topK:number; filter?:Record<string,unknown>; returnMetadata?:boolean|"all"}): Promise<{matches:Array<{id:string; score:number; metadata?:Record<string,string>}>}>.WorkersAiBindingtype (:57-59) with an embeddings overload:run(model:string, options:{text:string|string[]}): Promise<{data:number[][]}>(current signature only allows the chatmessagesshape).Create
apps/api/src/retrieval.ts(new, pure functions — no bindings). Houses the testable core:parseChunksNdjson(text:string): ContextChunk[]— split on newlines,JSON.parseeach (inverse ofrenderChunksNdjson,packages/core/src/chunks.ts:45).bm25(query:string, chunks:ContextChunk[]): Array<{chunkId:string; score:number}>— tokenize, compute IDF + BM25 (k1=1.2, b=0.75) over the in-memory candidate set (a namespace is ~hundreds of chunks, fine in-memory; no inverted index needed per issue note).reciprocalRankFusion(lists: Array<Array<{chunkId:string}>>, k=60): Array<{chunkId:string; score:number}>—1/(k+rank)summed across the vector list and BM25 list.mmr(ranked: ContextChunk[], queryTerms, lambda=0.7, topK): ContextChunk[]— token-overlap (Jaccard) diversity de-dup so near-identical chunks don't dominate.vectorIdFor(namespace, chunkId)helper (see Risks: hash to respect the 64-byte id cap).Embed + upsert at build time. Add
async function indexNamespaceChunks(env, namespace, chunks: ContextChunk[]): Promise<void>inworker.ts:if (!env.VECTORIZE || !env.AI) return;(best-effort — never fail a build on index errors).textinto groups of ~96, callenv.AI.run("@cf/baai/bge-base-en-v1.5", { text: batch })→data: number[][].{ id: vectorIdFor(namespace, chunk.chunkId), values, metadata: { namespace, chunkId: chunk.chunkId, routePath: chunk.routePath, url: chunk.url ?? "" } }andawait env.VECTORIZE.upsert(...)in batches (≤1000/call).processExtractionJob(worker.ts:1951) immediately afterstoreNamespaceImportreturns (~:1977), wrapped intry/catchand routed throughrequestContext.waitUntil?.(...)so indexing doesn't block job completion and a Vectorize/AI outage can't fail the extraction. Recompute chunks there viabuildChunks(...)on the samepages(or thread the already-builtchunksout ofextractNamespaceContextto avoid recompute — both build paths already callbuildChunksat :2186 and :2790). The chunk text needs no extra persistence:/context/chunks.ndjsonis already written to R2 bystoreNamespaceImport(:1786).Hybrid retrieval in
memwalChat(worker.ts:3498). Replace the relayer-first grounding block (:3521-3535) with a local-first path. Addasync function hybridRecall(env, namespace, query, topK): Promise<ContextChunk[]>:/context/chunks.ndjsonvianew CloudflareNamespaceStore(env).readArtifact(namespace, "/context/chunks.ndjson")(:454) →parseChunksNdjson→Map<chunkId, ContextChunk>. If absent/empty, return[].env.AI.run("@cf/baai/bge-base-en-v1.5", { text: query })→env.VECTORIZE.query(vec, { topK: topK*3, filter: { namespace }, returnMetadata: "all" })→ orderedchunkIds (frommatch.metadata.chunkId). Drop matches whose chunkId isn't in the current ndjson map (handles orphan vectors from prior versions).reciprocalRankFusion([vectorList, bm25List])→ take top candidates →mmr(...)→ firsttopK.memwalChat, readtopKfrom the request body (default 6 to match today), callhybridRecall, and map results into the existingrecallHitsshape ({ text, distance? }).Preserve
sources[]shape. WherememwalChatbuildssources(:3602-3604), emit from the hydrated chunks:{ url: chunk.url ?? "", routePath: chunk.routePath, quote: chunk.text.slice(0, 280), blobId: undefined }. This keeps the exact{url, routePath, quote, blobId}contract the web UI renders. Also setsourceto a clear local label (e.g."local-index"/ keep"mixed"when facts also contribute) and surface ausedProviderlikelocal-hybrid:bge-base+bm25for demo/debug visibility.Demote MemWal to fallback-only. Keep the existing
MemWalMcpClient.recallSiteContextcall (:3525) but only invoke it whenhybridRecallreturns zero hits (and a delegate is present viaresolveMemwalCreds, :3341). This removes the flaky relayer from the hot path while preserving graceful degradation for namespaces not yet indexed.Backfill note (optional, P2). Existing namespaces built before this lands have no vectors. Add a tiny authed
POST /api/hosted/namespaces/:ns/reindex(or a CLI one-shot) that loads/context/chunks.ndjsonand runsindexNamespaceChunks. Not required for acceptance; the demo namespace can simply be rebuilt once.New / changed files
apps/api/cloudflare/wrangler.jsonc— addvectorizebinding (top-level + staging env).apps/api/src/worker.ts—WorkerEnv+VectorizeIndexLike+WorkersAiBindingembedding overload;indexNamespaceChunks+ call site inprocessExtractionJob(:1951/~:1977); rewrite ofmemwalChatgrounding/sources (:3498, :3521-3535, :3602-3604);hybridRecall.apps/api/src/retrieval.ts— new:parseChunksNdjson,bm25,reciprocalRankFusion,mmr,vectorIdFor.apps/api/src/retrieval.test.ts— new: unit tests for the pure functions.apps/api/src/worker.tsreindex route + tiny handler./context/chunks.ndjson). If the team prefers D1, addapps/api/migrations/0007_chunk_index.sqlwithcontextmem_chunks(namespace, chunk_id, route_path, url, text, version_id)— documented as the alternative.Test plan
bun test, runs inbun-check.yml): deterministic asserts onbm25(ranking order for a known doc set),reciprocalRankFusion(fused order from two synthetic rank lists),mmr(de-dups two near-identical chunks),parseChunksNdjsonround-tripsrenderChunksNdjson(buildChunks(...)), andvectorIdForstays ≤64 bytes for a longwalrus:testnet:0x…namespace.hybridRecall/indexNamespaceChunks): inject fakeVectorizeIndexLike+WorkersAiBinding(return canned embeddings) + an in-memory R2 stub; assert a build upserts N vectors with correct ids/metadata, and a query returns the expectedsources[]hydrated from ndjson. (Note: Miniflare/wrangler devdoes not emulate Vectorize, so CI must use mocks — see Risks.)wrangler deploy --env staging— CI doesn't ship the Worker), build theseal-docs.wal.appdemo namespace, wait for eventual consistency, thenPOST /api/memwal/chat. Pass criteria: (a)usedProviderreportslocal-hybrid…, (b)sources[]are populated with realurl/routePath/quotefrom chunks, (c) with MemWal creds unset/removed, chat is still grounded — proving the relayer is off the hot path, (d) a namespace with no index falls back to MemWal/facts without erroring.Risks & gotchas
query({filter:{namespace}})silently returns cross-namespace or empty results unlesscreate-metadata-index --property-name=namespacewas run. Per-namespace isolation depends on it.${namespace}:${chunkId}overflows for long walrus/target namespaces.vectorIdFormust hash: e.g.${sha256Hex(namespace).slice(0,24)}:${chunkId}(keep realnamespace+chunkIdin metadata for filtering/hydration).chunkIds are location-stable so upserts overwrite, but deleted pages leave stale vectors. Retrieval already drops any matched chunkId not present in the current ndjson, so stale vectors are inert; periodic cleanup is optional (P2).waitUntilso limits/outages never fail a build.--dimensionsmust match the model (768 forbge-base-en-v1.5). Switching tobge-small(384) later requires a new index + full re-embed; pickbge-basenow for quality.buildChunksis called in both the combined (:2186) and single-target (:2790) builders; ensureindexNamespaceChunksruns once per job fromprocessExtractionJob, not per builder, to avoid double work.Acceptance-criteria mapping
${namespace}:${chunkId}with{namespace,chunkId,routePath,url}metadata; chunk text persisted for hydration → Step 3 (text reuse of R2/context/chunks.ndjson; id hashed per Risks but metadata carrieschunkId).memwalChathybrid retrieval: embed query → Vectorize topK (namespace-filtered) fused with BM25 via RRF, then MMR de-dup, configurable topK → Steps 2 + 4.sources[]shape (url/routePath/quote/blobId) preserved for the UI → Step 5.Effort
L — ~3-4 person-days. ~0.5d binding/types/index setup; ~1d
retrieval.ts+ unit tests; ~1d build-time indexing wiring; ~1dmemwalChatrewrite + staging round-trip verification.Implementation plan generated for execution (planning phase). Part of the roadmap (#4).