Retrieval quality evaluation

How Kioku measures whether search_notes in keyword, semantic, and hybrid modes actually returns the right notes, and how to compare configurations (embedding model, thresholds, scoring changes) with numbers instead of gut feeling.

Scope

This evaluation harness measures retrieval quality only. Kioku can expose optional local generation workflows when the generation capability is enabled and a generation model is configured, but those workflows are outside this harness.

The metrics here score whether retrieval returns the right notes. Generation-side metrics such as RAGAS faithfulness, answer relevancy, or groundedness require a separate evaluation design and are not inferred from retrieval scores.

Metrics implemented in src/Kioku.Mcp.Server/Domain/RetrievalMetrics.cs:

Metric Question it answers
Precision@k Of the k results returned, how many are relevant?
Recall@k Of all relevant notes, how many made it into the top k?
MRR How high does the first relevant note rank?
NDCG@k Is the full ordering close to ideal, weighting by graded relevance?

The golden set

A golden set is a list of real queries annotated with the notes a good search should return, with a graded relevance (1 = somewhat relevant, 2 = relevant, 3 = exactly what the query asks for). Format (src/Kioku.Mcp.Server.Tests/Fixtures/golden-set.json):

{
  "queries": [
    { "id": "q01", "query": "notas sobre burnout laboral",
      "relevant": [ { "path": "Salud/Burnout Laboral.md", "grade": 3 } ] },
    { "id": "q23-no-answer", "query": "quantum entanglement research papers",
      "relevant": [] }
  ]
}

Authoring guidance:

  • Use queries you actually type, including typos and paraphrases that share no keywords with the target note (those exercise the semantic leg).
  • Include queries answered by tags, by title, by aliases, and by content buried deep in a long note.
  • Include 2-3 queries with an empty relevant list (“no-answer probes”): a good configuration returns few or no results for them, so they measure noise/threshold quality.
  • Paths are vault-relative with / separators; grades 1-3.

The checked-in fixture vault (src/Kioku.Mcp.Server.Tests/Fixtures/EvalVault/, 27 mixed Spanish/English notes) contains topic clusters, keyword distractors (same words, different meaning), semantic twins (same meaning, different words), alias-only matches and one very long note with a unique fact near the end (a truncation probe: whole-note embeddings get cut at the model context window — heading-aware chunking, see below, is what makes it findable by semantic/hybrid search too, not just keyword).

Running the evaluation

# Keyword only — works without Ollama
dotnet run --project scripts/Kioku.Eval -- --modes keyword --label baseline

# All modes — requires Ollama with the configured embedding model
dotnet run --project scripts/Kioku.Eval -- --label baseline-nomic

# Against your real vault with your own golden set
dotnet run --project scripts/Kioku.Eval -- \
  --vault ~/vault --golden ~/vault/.kioku/golden-set.json --min-score 0.4

# Compare embedding models (cache auto-invalidates on model change)
KIOKU_EMBEDDING_MODEL=qwen3-embedding:0.6b dotnet run --project scripts/Kioku.Eval -- --label qwen3

The runner boots the same VaultIndexService + EmbeddingService + HybridSearchService stack the MCP server uses (no transport), waits for the embedding backlog to drain, and prints one Markdown table per mode. Compare tables between runs with different --labels; only keep a change if Recall@10 / NDCG@10 improve or hold.

CI regression tests

src/Kioku.Mcp.Server.Tests/RetrievalRankingTests.cs runs the golden set through all three search paths on every dotnet test, without Ollama, using a deterministic fake embedder (DeterministicEmbeddingHandler.cs: hashed bag-of-words vectors, so cosine similarity correlates with lexical overlap). Assertions are floors and invariants — never exact orderings — so they catch ranking regressions without overfitting to the fake. Real-model quality is measured only with the runner above.

Baseline

Fixture vault (27 notes), 22 scored queries + 2 no-answer probes.

keyword — naive TF scoring (pre-BM25), 2026-07-12

k Precision@k Recall@k MRR NDCG@k
5 0.227 0.621 0.784 0.722
10 0.132 0.682 0.784 0.744

No-answer probes: avg 5.0 results returned.

keyword — Okapi BM25 (k1=1.2, b=0.75, relative title/tag boosts), 2026-07-12

k Precision@k Recall@k MRR NDCG@k
5 0.245 0.652 0.788 0.741
10 0.127 0.667 0.788 0.750

No-answer probes: avg 5.0 results returned.

Net effect of BM25: better early precision and ordering (P@5, R@5, MRR, NDCG@k all up); Recall@10 dips marginally because IDF demotes one weakly-relevant match. Kept per the “improve or hold” gate — rank quality is what the hybrid RRF fusion consumes.

Semantic threshold default

search_notes with mode='semantic' now defaults to min_score = 0.4 (explicit 0 disables the filter). The value is a conservative starting point for nomic-embed-text with task prefixes; validate it against your own golden set by sweeping --min-score with the runner and watching Precision@k versus the no-answer probes.

semantic / hybrid — nomic-embed-text, before chunking (whole-note embeddings), 2026-07-14

Fixture vault, 26/27 notes embedded — Referencias/Historia de la Computacion.md failed every attempt because its content exceeded the model’s context window (n_ctx_slot = 2048 tokens) and was excluded from semantic/hybrid results entirely (a known limitation of whole-note embeddings, fixed by chunking below). min_score = 0 (the runner’s default; the server itself defaults to 0.4, see above).

k Precision@k Recall@k MRR NDCG@k
5 0.318 0.826 0.955 0.885
10 0.191 0.955 0.955 0.913

Hybrid:

k Precision@k Recall@k MRR NDCG@k
5 0.255 0.667 0.854 0.791
10 0.177 0.894 0.854 0.845

semantic / hybrid — nomic-embed-text, with heading-aware chunking, 2026-07-14

Fixture vault, 27/27 notes embedded — the long note’s oversized sections now split into per-heading chunks (each under the context limit) instead of failing outright. Results still aggregate to one score per note (max-pooling across a note’s chunks), so the golden set — annotated at note level — needed no changes. min_score = 0.

k Precision@k Recall@k MRR NDCG@k
5 0.327 0.871 1.000 0.930
10 0.191 0.985 1.000 0.954

Hybrid:

k Precision@k Recall@k MRR NDCG@k
5 0.264 0.712 0.877 0.812
10 0.173 0.902 0.877 0.867

No-answer probes: avg 10.0 results returned for both semantic and hybrid, before and after (unaffected — min_score = 0; expected noise given no threshold, see above).

Net effect of chunking: every metric improves or holds except hybrid P@10, which dips within noise (0.177 → 0.173). Semantic MRR reaches a perfect 1.000 — the previously-missed q11-long-note-tail query (the fact buried at the end of the long note) is now found by both semantic and hybrid, not just keyword. Kept per the “improve or hold” gate.

dotnet run --project scripts/Kioku.Eval -- --label chunking

Design decisions (what was deliberately not built)

  • LLM contextual enrichment (Anthropic contextual retrieval): adds an LLM call per chunk on every re-index. Obsidian vault notes change constantly, so the enrichment cost repeats forever; the deterministic breadcrumb prefix (note name + heading path) used by chunking above captures most of the benefit for free.
  • Late chunking: needs token-level embeddings; Ollama’s embeddings API returns pooled vectors only. Not implementable against Ollama.
  • Cross-encoder reranking: no local cross-encoder runtime available. Revisit only if eval numbers show Precision@5 is the bottleneck.
  • ANN index: brute-force SIMD cosine over a whole vault is sub-10ms at typical vault sizes. Revisit above ~100k vectors.