Skip to content
Documentation Evaluations ▾

Install & self-host

Docs / How it works / Evaluations

Evaluations

How retrieval and answers are measured, on what data, and which results exist so far.

Full run · 2026-10-15

All four retrieval configurations and the end-to-end answer evaluation were run on 2026-10-15 with OpenAI text-embedding-3-small, gpt-5.4-mini and Jev 1.13 (Jev served all 44 reranks; the fallback was never needed). Raw rows: evals/results/2026-10-15-openai-*. No number on this page is estimated.

Dataset

2,233synthetic messages, 5 channels
44hand-labeled queries
6of them unanswerable
68evidence groups
  • Corpus: data/synthetic/corpus.jsonl, Slack-style messages from a fictional student team (“Beacon”). Ingestion produces 2,233 message chunks and 158 thread chunks.
  • Queries: evals/queries.yaml. 38 answerable and 6 unanswerable (nothing in the corpus answers them). Split into dev (23: 20 answerable, 3 unanswerable) and test (21: 18 answerable, 3 unanswerable).
  • Evidence groups: each answerable query lists the parts of its answer (for example “who owns it” and “when is it due”), each with every message verified to support that part. 68 groups in total (dev 41, test 27).
  • Graded relevance: each query also has a relevant map with grades 1 to 3. Grade 1 messages mention the topic but were not verified as supporting the claim.

Configurations

scripts/run_eval.py calls the production functions in retrieval/service.py (retrieve, rerank, answer). It only wraps the API clients to record token usage, raw output, and which rerank model answered. Questions are sent exactly as /ask sends them, with no date or channel filters.

ConfigWhat runs
a) Dense-onlyretrieve(q, limit=20, mode="dense")
b) Lexical-onlyretrieve(q, limit=20, mode="lexical")
c) Hybrid RRFretrieve(q, limit=20)
d) Hybrid + rerankrerank(q, retrieve(q, limit=20))

In a and b, chunk scores are still summed per message with 1/(rank+10), as in production. End-to-end answers use config d: the top 12 reranked messages go to answer(), the same evidence /ask uses.

Metrics

Retrieval is scored on the 38 answerable queries; latency is timed on all 44.

MetricDefinition
Recall@kPer query, the share of its evidence groups with at least one message in the top k; then the mean over queries. 3 groups with 2 hit scores 0.667.
Group recall@k (micro)Groups hit in the top k divided by all 68 groups. Queries with more parts weigh more.
Full coverage@kShare of queries where every group is hit in the top k.
MRR@20Mean of 1 / rank of the first retrieved message in any evidence group, or 0 if none is in the top 20.
Latency p50 / p95Wall-clock time of the retrieval call (plus rerank for d), in ms, with linear interpolation. Includes the query embedding request for a, c, d.
Citation validity rateShare of model outputs whose cited IDs are all among the 12 evidence messages.
Answer rateShare of the 38 answerable questions whose final response has at least one verified source.
Correct abstention rateShare of the 6 unanswerable questions whose final response has no sources.
Citation precisionCited IDs that are in the query’s evidence groups, divided by all cited IDs (strict, micro).
LLM-judged correctnessgpt-5.4-mini compares the answer with the reference: 1, 0.5, or 0. An LLM opinion, not a human grade.

Reranking only reorders the top 10, so Recall@10 and Recall@20 are identical for c and d by construction.

Results

Retrieval, 38 answerable queries

ConfigRecall@10Recall@20MRR@20p50 msp95 ms
a) Dense-only
pgvector cosine top 30 chunks, no lexical arm
0.8490.8820.657637.01188.0
b) Lexical-only
ts_rank top 30 chunks over the query’s content words joined with OR
0.6160.6750.46015.721.8
b) Lexical-only, before fix
Previous websearch_to_tsquery: every word ANDed, stop words kept
0.0000.0000.00012.916.5
c) Hybrid RRF
Production default: both arms fused with score += 1/(rank+10)
0.7680.8290.625617.3740.2
d) Hybrid + rerank
Decisions API rerank of the top 10 (Jev 1.13 → GPT-6 Luna Decisions → RRF order)
0.7680.8290.7821425.02443.7

By split

ConfigSplitQueriesRecall@10Recall@20MRR@20p50 msp95 ms
a) Dense-onlydev200.8380.8750.614623.31168.7
a) Dense-onlytest180.8610.8890.706648.61172.8
b) Lexical-onlydev200.5960.6080.54016.221.8
b) Lexical-onlytest180.6390.7500.37215.419.3
c) Hybrid RRFdev200.7330.7750.681658.7740.3
c) Hybrid RRFtest180.8060.8890.564611.2710.9
d) Hybrid + rerankdev200.7330.7750.7571445.72459.4
d) Hybrid + reranktest180.8060.8890.8091389.42127.6

The test split is the fairer number: the dev split was available while the pipeline was built. Raw rows are in evals/results/2026-10-15-openai-retrieval.jsonl and 2026-10-15-openai-e2e.jsonl.

End-to-end answers (config d)

MetricAllDevTest
Citation validity rate1.0001.0001.000
Answer rate, answerable1.0001.0001.000
Correct abstention rate, unanswerable (strict: formal refusal)0.1670.3330.000
Unanswerable handled correctly (LLM-judged: says it is not in the chat)0.8330.6671.000
Citation precision, strict (micro)0.5740.6300.521
Citation precision, lenient (micro)0.7340.7830.688
LLM-judged correctness, answerable (mean score)0.8030.8250.778

Rerank usage: Jev 1.13 answered all 44 rerank requests, so the GPT-6 Luna fallback and the plain-RRF path were never used.

What the numbers say

  • Reranking is the biggest win. It moves the right message to the top: MRR@20 rises from 0.625 to 0.782, and the first result is relevant for 74% of questions instead of 53%. It only reorders the top 10, so recall does not change.
  • Hybrid does not beat dense-only yet. Dense-only reaches Recall@10 0.849 against 0.768 for hybrid. With equal RRF weights, keyword hits push some good vector hits out of the top 10. Down-weighting the lexical arm, or using it only for exact tokens such as error codes, is the next thing to test.
  • Citations are always valid, not always the best ones. Every citation pointed at retrieved evidence, but only 57% (strict) to 73% (lenient) of cited messages are in the hand-labelled evidence.
  • Abstention mostly happens in prose. For 5 of the 6 unanswerable questions the answer says the chat does not contain the information, but only one took the formal “I couldn’t verify an answer” path; the rest still attach citations. One answer added an unsupported detail before saying the information was missing.

The lexical fix

Before 2026-10-15 the lexical arm returned zero chunks for all 44 questions. The query went through websearch_to_tsquery('simple', q), which joins every word with AND and keeps stop words, so a chunk had to contain “which”, “the”, and every other word of the question. In effect, “hybrid” retrieval ranked exactly like dense-only.

lexical_query() now drops common English stop words and ORs the remaining terms, while ts_rank still ranks chunks that match more terms higher. On the same 38 answerable questions, with no API calls:

Lexical-onlyRecall@10Recall@20MRR@20
Before0.0000.0000.000
After0.6160.6750.460

How the two arms are fused is described in Model architecture.

Limitations

  • The data is synthetic: one fictional team, written by an LLM from a planted fact list. Real Slack history is messier.
  • 44 queries is small. One query moves Recall by about 2.6 points overall and 5 points within the dev split. Six unanswerable questions give a very coarse abstention rate.
  • The queries and gold evidence were written by the same project that built the system. It is not a blind benchmark.
  • Gold evidence was verified with gpt-5.4-mini, so labels can contain model errors.
  • Generation and reranking are remote, non-deterministic calls. A single run is reported.
  • Latency was measured from one laptop against local Docker Postgres and will differ in deployment.
  • Filters in the query file are not applied, matching /ask today.

Reproduce

Shell
docker compose up -d db
uv run python -m recallhq.cli migrate
uv run python -m recallhq.cli ingest data/synthetic/corpus.jsonl
uv run python scripts/validate_queries.py

# Full run: 4 retrieval configs, end-to-end answers, LLM judge
uv run python scripts/run_eval.py

# Cheaper variants
uv run python scripts/run_eval.py --no-judge
uv run python scripts/run_eval.py --no-e2e
uv run python scripts/run_eval.py --configs lexical --no-e2e   # no API calls

A full run needs OPENAI_API_KEY with access to text-embedding-3-small and gpt-5.4-mini, and OPEN_ROUTER for reranking. Outputs go to evals/results/<stamp>-*.jsonl and a summary JSON with all metrics and token usage.