Two retrieval layers over memory.Fact: Phase 3a generates an LLM hypothesis and extracts keywords; Phase 3b adds vector embeddings (Voyage / OpenAI / Bedrock Titan-Cohere) + pure-Go cosine search with blended ranking (semantic+lexical+temporal), a cosine floor, auto-migration and an evaluation harness (recall@k/MRR/nDCG). Reusable vindex primitive, JSON-persisted, zero CGO.
HyDE (Hypothetical Document Embeddings) is the classic technique of generating a hypothetical answer to the userβs question and using that answer as an additional retrieval signal. In ChatCLI, HyDE operates in two complementary phases: 3a expands keywords via LLM hypothesis, 3b adds vector cosine search.
HyDE is opt-in (CHATCLI_QUALITY_HYDE_ENABLED=true) to keep the steady-state with no additional cost. Phase 3a costs +1 cheap LLM call; Phase 3b requires configuring an embedding provider.
The pre-pipeline memory.Fact retrieval was keyword-only: the scorer matches tokens extracted from recent messages against tags and content of stored facts. Works well when vocabulary matches exactly β fails when the user uses synonyms or asks abstract questions.Gap example:
Without HyDE
With HyDE 3a
With HyDE 3b
User: how to do X in Go?
Extracted keywords: [do, go]
Stored fact: "use goroutines for concurrency in X pipelines" Match: β β βdoβ and βgoβ donβt literally appear in the fact.
User: how to do X in Go?
LLM hypothesis: "In Go you would use goroutines and channels for X, leveraging the concurrency primitives..."
Expanded keywords: [do, go, goroutines, channels, concurrency, primitives] Match: β
Hypothesis β embed (1024 dim via Voyage) β cosine search in vector store β top-3 semantically close facts. Match: β β even with no shared keyword.
Prompt: βWrite a 2-4 sentence plausible answer that uses the technical nouns that would appear in any matching note. Bilingual if the query mixes languages.β
4
ExtractKeywords from the hypothesis
The same extractor already used in chat mode (en+pt stop words, min 3 chars).
5
Merge unique + lower-case
Original keywords + top-N from hypothesis, cap configurable via CHATCLI_QUALITY_HYDE_NUM_KEYWORDS (default 5).
6
FactIndex.Search uses the expanded set
Existing keyword-based scorer operates over richer hints β much higher recall.
Phase 3a works without configuring an embedding provider. Itβs the recommended default if the cost of +1 light LLM call is acceptable.
Adds cosine similarity search over fact embeddings β and, since the ranking refactor, fuses the cosine score with the lexical and temporal signals into a single ranking.
What changed (PR #1027): previously the vector hits were dissolved back into keywords and the cosine score was discarded β you paid for an embedding call and threw away the very semantic signal. Now the cosine flows straight into the SearchBlended ranker, so a fact matched only by paraphrase (zero lexical overlap) can still rank.
Three independent, complementary signals, each min-max normalized across the candidate set before the weighted sum:
Signal
Source
Captures
semantic
cosine from the vector store
synonymy, paraphrase
lexical
keyword/tag overlap
exact terms, identifiers, file names
temporal
recency decay Γ access frequency
what the user actually uses
Default weights (DefaultRankWeights): semantic 0.55 Β· lexical 0.30 Β· temporal 0.15 β semantic-first, because the embedding call was already paid for. Fusion is additive (not multiplicative) on purpose: a fact with high cosine and zero keyword overlap still ranks β a product would zero it out. Min-max normalization is what makes the weights provider-agnostic: Voyage 1024-d and OpenAI 1536-d cosine both land in [0,1] after normalizing.
Cosine floor (MinCosineScore, default 0.25): embeddings over normalized text are almost always weakly positive, so the old > 0 cutoff admitted near-orthogonal noise. The floor keeps only genuinely related facts in the top-K.
Why Voyage: itβs the provider recommended by Anthropic for Claude workflows, offers high quality/$ for retrieval, and the default model (voyage-3, 1024-dim) is the general-purpose sweet spot.
export CHATCLI_QUALITY_HYDE_ENABLED=trueexport CHATCLI_QUALITY_HYDE_USE_VECTORS=trueexport CHATCLI_EMBED_PROVIDER=openaiexport OPENAI_API_KEY=sk-...# Optional: pick model and custom dimensionexport CHATCLI_EMBED_MODEL=text-embedding-3-smallexport CHATCLI_EMBED_DIMENSIONS=512 # can reduce to save storage
Models:text-embedding-3-small (default, 1536-dim, $0.02/1M tokens) or text-embedding-3-large (3072-dim, higher quality). The native dimension is auto-detected from the model β only set CHATCLI_EMBED_DIMENSIONS if you want to truncate via Matryoshka (e.g. 512 to save storage).
export CHATCLI_QUALITY_HYDE_ENABLED=trueexport CHATCLI_QUALITY_HYDE_USE_VECTORS=trueexport CHATCLI_EMBED_PROVIDER=bedrock# Reuses the chat client's credentials chain β no extra API key.# Set region/profile if not already in the environment.export BEDROCK_REGION=us-east-1export AWS_PROFILE=my-profile # optional# Optional: pick modelexport CHATCLI_EMBED_MODEL=amazon.titan-embed-text-v2:0 # defaultexport CHATCLI_EMBED_DIMENSIONS=1024 # Titan v2: 256/512/1024
Why Bedrock: same AWS credentials chain as the chat β single billing, AWS compliance (CloudTrail, guardrails), VPC endpoints. Ideal for companies already running chat through Bedrock and wanting retrieval inside the same perimeter.Supported families:
Model ID
Family
Supported dims
Native batching
amazon.titan-embed-text-v2:0 (default)
Titan v2
256, 512, 1024
No β parallelized with 8-worker pool
amazon.titan-embed-text-v1
Titan v1
1536 (fixed)
No β parallelized
cohere.embed-english-v3
Cohere v3 (en)
1024 (fixed)
Yes β one call per batch
cohere.embed-multilingual-v3
Cohere v3 (multi)
1024 (fixed)
Yes β one call per batch
Dispatch between Titan and Cohere is automatic by the model id prefix. CHATCLI_EMBED_DIMENSIONS only applies to Titan v2 (256/512/1024); invalid values are rejected in NewBedrock before any AWS call.No Converse API for embeddings: embeddings only live in InvokeModel, so each family keeps its dedicated body schema β unlike chat, where Converse covers nearly everything.
Hot-swap via /reload: changing CHATCLI_EMBED_PROVIDER / CHATCLI_EMBED_MODEL / CHATCLI_EMBED_DIMENSIONS in .env and running /reload rebuilds the provider on the spot β rewiring /context --rag semantic retrieval and the memory vector index without restarting ChatCLI. The index auto-migrates when provider/dimension change.
See AWS Bedrock for the full credentials chain config (SSO, IAM role, corporate proxy, VPC endpoint).
If CHATCLI_EMBED_PROVIDER isnβt set, NullProvider is returned. Embed() returns an error β the code checks embedding.IsNull(p) before calling, so it gracefully falls back to keyword-only. No surprises, no silent failures.
No CGO, no SQLite-vec, no external deps. Just float32[] + cosine + JSON persistence in ~/.chatcli/memory/vector_index.json.
The cosine index lives in a generic, reusable package, llm/embedding/vindex, extracted once a second consumer (the semantic /context retrieval) appeared. memory is just a thin adapter over it β no vector machinery duplicated per package:
// llm/embedding/vindex β the single, agnostic primitivetype Index struct { /* provider, dim, entries map[string][]float32, path */ }func (x *Index) Upsert(ctx, items map[string]string) errorfunc (x *Index) Search(query []float32, k int, minScore float64) []Hit // top-K via min-heapfunc (x *Index) Prune(keep map[string]struct{}) // never serve stale vectors// cli/workspace/memory/vector_store.go β fact-domain adaptertype VectorIndex struct { idx *vindex.Index; provider embedding.Provider }type ScoredFact struct { ID string; Score float64 }
Top-K selection uses a min-heap O(n log k) (not a full O(n log n) sort), so cost scales with k, not corpus size β the scale ceiling is no longer βhundreds of factsβ. For the typical chatcli case linear search completes in microseconds; no HNSW or IVFFlat.
Switching provider or dimension (Voyage 1024 β OpenAI 1536, or voyageβcohere at the same 1024) no longer needs a manual rm. On load, the index detects the mismatch β of dimension (cosine between different arities is undefined) or of provider (two embedding spaces arenβt comparable, even at the same dimension) β and auto-clears the cache, removing the file so backfill repopulates:
WARN vindex provider changed β auto-clearing for re-embed on_disk_provider=voyage provider=cohereWARN vindex dimension mismatch β auto-clearing for re-embed on_disk_dim=1024 provider_dim=1536
The providerβprovider case at the same dim (e.g. voyage 1024 β cohere 1024) was previously served silently as garbage β cosine between different spaces is meaningless. It is now detected and re-embedded.
When retrieving a fact, if it has no vector (fact predates embeddings activation), the index spawns a detached goroutine to embed the top-500 visible facts:
Backfill is bounded and configurable: at most Config.BackfillBatchMax facts per retrieve (default 500). With Voyage (~0.05/Mtokens)thatcostsroughlyUS 0.001 for a fully cold cache. In a normal session, most of the index is embedded on the first interaction.
There used to be no way to measure whether retrieval was any good. Now there is a dependency-free evaluation harness in cli/workspace/memory/eval β standard macro-averaged IR metrics:
Metric
What it measures
recall@k
fraction of relevant facts retrieved in the top-k
precision@k
fraction of the top-k that was relevant
MRR
mean reciprocal rank of the first hit
nDCG@k
normalized discounted cumulative gain
The versioned A/B (in ranking_test.go, with a deterministic embedding provider) compares keyword-only vs. blended ranking on queries phrased with synonyms absent from the fact text:
The harness runs the same way in CI (deterministic provider, reproducible) or against a real backend β it never imports a provider, so it stays neutral across all 14 supported ones. It is what turns βlooks goodβ into βis good, measuredβ, and guards against regressions.
The blended-ranking parameters live as Config fields with strong defaults β no new env vars were introduced (and the pre-existing CHATCLI_MEMORY_* vars, previously ignored on the structured path, are now applied via ConfigFromEnv + clamping):
HyDE amplifies Reflexionβs value: lessons persisted by #3 are retrieved with much higher recall when the next task doesnβt use the exact same keywords. Workflow:
1
Turn 1: auth.go refactor fails (timeout)
Reflexion persists lesson: "use Edit tool for large files", tags [go, refactor, edit-tool].
2
Turn 5 (days later): 'help me split pkg/engine'
Query doesnβt contain refactor or edit. Keyword-only would miss the lesson.
3
HyDE 3a generates hypothesis
"To split a Go package, identify logical groupings and use refactor patterns with Edit tool for surgical changes..."
Extracted keywords: [split, package, refactor, edit, patterns, β¦]
4
Match!
Lesson appears in system prompt. Coder picks Edit over write from the start.
Token cost of phase 3a: ~200 tokens per retrieval turn. In workflows with many read turns, the cost compounds. Use CHATCLI_QUALITY_HYDE_NUM_KEYWORDS=3 for tighter budget.
Privacy: the user query is sent to the embedding provider. For sensitive workloads in corporate environments, Bedrock is the preferred path β it stays inside the AWS perimeter (CloudTrail, VPC endpoint, IAM). For local workloads with no network, consider self-hosting (roadmap: Ollama-embedding provider).
Graceful fallback: if the LLM fails or the embedding provider returns an error, retrieval falls back to keyword-only silently. No turn is aborted by HyDE failure.