Skip to main content
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 problem HyDE solves

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:
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.

Phase 3a β€” Hypothesis-based keyword expansion

1

User types query

The query enters cli_llm.go or agent_mode.go.
2

HyDEAugmenter.Augment

3

LLM generates short hypothesis

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.

Phase 3b β€” Vector embeddings + blended ranking

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.

Architecture

Blended ranking (SearchBlended)

Three independent, complementary signals, each min-max normalized across the candidate set before the weighted sum: 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.

Supported providers

Pure-Go vector store β€” generic vindex primitive

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:
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.

Provider/dimension auto-migration

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:
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.

Lazy backfill

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)thatcostsroughlyUS0.05/M tokens) that costs roughly US 0.001 for a fully cold cache. In a normal session, most of the index is embedded on the first interaction.

Evaluation β€” proving retrieval (not assuming it)

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: 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.

Ranking tunables (no new env vars)

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):

Full configuration


/config quality surfaces state


Integration with Reflexion

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.

Caveats and tuning

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.

See also

#3 Reflexion

The lessons that HyDE retrieves with higher recall.

Bootstrap Memory

The layer underneath: how memory.Fact is populated and maintained.

Persistent Context

/context attach for explicit file contexts.

Full configuration

All envs and slashes.