Every recommendation cited to a review

Retrieval-augmented generation over 30,000 Wine Enthusiast reviews

The reranker held up on 30 requests it had never seen

Ask for "a bold red under $25 for a steak dinner" and the request goes through dense and BM25 retrieval and a cross-encoder reranker, then to Claude, which may recommend only the reviews it was handed and cites each with [n]. Against dense retrieval alone, the reranked pipeline lifted nDCG@10 from 0.67 to 0.83 on the original 12 queries and from 0.59 to 0.76 on 30 new queries frozen before any system ran on them.

At a glancemeasured 4 and 5 November 2026, files in eval/
Task
Turn a plain-English wine request into 2 or 3 bottles from the reviews, each cited.
Eval sets
12 queries from July (src/eval.py) and 30 held-out queries committed before their first run (eval/queries_v2.json). Relevance is a written rubric per query, with no human labels.
Baseline
Dense vector search alone: nDCG@10 0.67 (12 queries), 0.59 (30).
Result
Hybrid plus rerank: 0.83 and 0.76. On the 30, the gain is +0.16 with a 95% bootstrap interval of 0.06 to 0.27; reranking won 19 queries, lost 6, tied 5.
Cost per query
Retrieval $0, all local. Generation $0.13 median, the API-equivalent cost the Claude CLI reports (n = 20).
Latency
Retrieval 196 ms median, 257 ms p95 (n = 126, Apple M4). Generation 8.3 s median, 11.2 s p95 (n = 20).
Main failure
Of the 76 misses on the 30-query set, 20 are the wrong grape or color and 19 ignore a price stated in the sentence, which only a CLI flag can set.

One recorded run

A bold red under $25

With a $25 price cap passed as a filter, the hybrid retriever and reranker returned four bottles, and Claude led with a Chilean Carmenère and explained why it passed over one of the four. Every bottle, score and price in the answer comes from a retrieved review.

The request

"a bold red under $25 for a steak dinner"

--max-price 25

The pick

Misiones de Rengo Carmenère91 pts · $20 · [3]

"a juggernaut red that cries out for steak"

Set aside: the Soos Creek, as "a touch gentler than the bold profile you're after." From the run described in writeup.md.

The measurement

Three retrievers, two frozen query sets

Each query has a written rubric: wine family or grape, country or region, price cap, descriptor words. A review counts as relevant if it meets every clause. The first 12 queries are mostly about style ("a crisp, mineral white to go with fresh oysters"). The 30 added on 4 November name more grapes and places ("a Mosel Riesling with a touch of sweetness") and were committed to the repo before any system ran on them. Pick a set and a metric to follow it through the pipeline.

The three setups as steps of one pipeline, from eval/results.csv (12 queries) and eval/summary.csv (30 queries). The other metrics are drawn faint behind the one you pick.
SystemP@10Recall@10MRRnDCG@10
12 queries, July set
Dense (vector only)0.680.0300.820.67
Dense + BM25 (RRF)0.670.0260.770.68
+ cross-encoder rerank0.820.0350.920.83
30 held-out queries, frozen 4 November
Dense (vector only)0.580.0440.730.59
Dense + BM25 (RRF)0.660.0540.820.68
+ cross-encoder rerank0.750.0680.870.76

With 12 queries the reranker's nDCG@10 gain over dense is +0.16, 95% bootstrap interval 0.07 to 0.27; with 30 it is +0.16, interval 0.06 to 0.27. Neither interval reaches zero. Recall@10 is low for every system because most rubrics match hundreds of reviews and ten results hold only ten.

These rows use exact dense search: every review's embedding is scored against the query, so any rebuild of the index gives the same numbers. The app searches Chroma's HNSW index instead, which is approximate and comes out different on every build, so for a few queries it returns different neighbors. Rebuilt 20 times from the same embeddings (eval/rebuild_spread.csv), the HNSW runs put dense MRR on the 12 queries anywhere from 0.82 to 0.88 and hybrid nDCG@10 from 0.67 to 0.69; on the 30, hybrid nDCG@10 ranged from 0.678 to 0.685. The reranked rows moved by at most 0.0003. This page used to show one such build (dense MRR 0.88, nDCG@10 0.69), and the fresh rebuild on 4 November that gave hybrid P@10 0.675 and nDCG@10 0.689 was another.

Query by query

The reranker lost 6 of 42 queries to plain dense search

An average can hide a system that wins big on a few queries and loses on the rest. Each row is one query: the hollow square is dense search, the filled square the reranked pipeline, on nDCG@10. Rows are sorted by the change. Two queries score zero for both, and the error analysis below says why.

Dense Hybrid + rerank

Hover or focus a row for its numbers.

BM25 fusion

Fusion hurt on the first 12 queries and helped on the next 30

On the July set, fusing a BM25 keyword index into the dense ranking left precision flat and dropped MRR from 0.82 to 0.77: the first relevant wine landed lower. On the 30 new queries the same fusion raised MRR from 0.73 to 0.82 and nDCG@10 from 0.59 to 0.68. The new queries name grapes and places such as Grüner Veltliner, Mosel and Mendoza, exact words BM25 matches; the July queries mostly describe a style, where shared words point to the wrong kind of wine as often as the right one. So the July finding was a property of those queries, and the honest reading of both sets is that fusion depends on how people phrase requests, while the reranker helped on both.

Fusion can still promote a weak match. Reciprocal Rank Fusion rewards any review that shows up in both lists, even far down them: a review ranked 61st in both outscores the one dense search put first. The calculator uses the formula from src/hybrid.py (k = 60, 80 candidates from each list in the benchmark).

Where does a review land after fusion?

Reciprocal Rank Fusion, as the code computes it

score = 1/(60 + dense rank) + 1/(60 + BM25 rank)

40
40

The cross-encoder (ms-marco-MiniLM-L-6-v2) reads the request and each review together and re-scores the top fused candidates. It is also the slow stage: its median of 145 ms is most of the 196 ms median for a whole query.

Error analysis

76 misses in 300 top-ten slots, and what kind they are

A miss is a wine in the reranked top ten that fails its query's rubric. Every top ten is saved in eval/top10.json, and eval/error_analysis.py sorts each miss by the first rubric clause it breaks. On the 30-query set there are 76; on the July set, 22 of 120.

Wrong grape or color20 (7 queries) · 11 (5)

"a white Burgundy with hazelnut and minerality": first place went to a Russian River Valley Sauvignon Blanc.

Price in the sentence ignored19 (5) · 11 (2)

"a smooth, easy-drinking red under $15": all ten results cost more, led by a $29 Santa Barbara Syrah. P@10 is 0 for every system.

Wrong region or country17 (7) · 0

"an Oregon Pinot Noir under $40": seven of the ten were Californian, the first from Monterey.

Right wine, rubric says no16 (3) · 0

For "an Amarone" and "a Chianti Classico", most hits are exactly that, but the dataset files them as "Red Blend", so the grape clause fails. These count against all three systems.

Style words missing4 (3) · 0

A $32 Willamette Valley Pinot that fits the request except that its review never says earthy.

The price group is a design gap: price, country and grape filters exist, but only as CLI flags. Nothing reads "under $15" out of the sentence, and the benchmark runs with no flags, so the retriever ranks a $29 Syrah as the best smooth red. The fourth kind is the rubric's own error: 16 of 76 misses are wines a sommelier would accept, so all the 30-query scores are a little low.

The answers stayed inside the retrieved reviews

eval/generation.py ran the full chain on 20 of the queries and checked every answer in code: no [n] citation outside the six sources, no score that isn't in them, and no dollar amount that isn't a source price, the guest's budget, or the difference between the two (six amounts such as "$10 over your limit"). In the two runs where retrieval found nothing that met the request, the answer said so in its first line: "None of these six wines is under $15. The cheapest is $19." Twenty answers is a small sample, and these checks catch invented numbers and say nothing about whether a pick is well argued.

Cost and latency

Retrieval takes a fifth of a second; the answer takes eight

Retrieval was timed on an Apple M4 (embedding and reranker on the MPS GPU), replaying the default path (60 fused candidates, top 6) for all 42 queries three times after a warm-up pass: 126 timings. Generation is 20 runs through the Claude CLI on 4 November, the same way src/llm.py calls it, with --output-format json added so the CLI reports time, tokens and cost. Cost is the API-equivalent figure the CLI reports; on a Claude subscription nothing is billed per call.

StageMedianp95n
Embed the request (MiniLM)6.7 ms8.7 ms126
Dense search (Chroma)2.8 ms36.2 ms126
BM25 over 30,000 reviews39.5 ms52.3 ms126
Cross-encoder rerank, 60 pairs145 ms179 ms126
Retrieval total196 ms257 ms126
Generation, Claude CLI8.3 s11.2 s20
Generation cost, API-equivalent$0.132$0.13620

The slowest generation took 26.6 seconds, on the under-$15 request where nothing fit. The CLI's default model on that day was claude-opus-5-5. Each call carried about 28,000 input tokens, while the request, six reviews and instructions come to 450 to 620 words: nearly all the cost is the CLI's own context, which a direct API call with ANTHROPIC_API_KEY would not send. That path was not measured. Raw numbers: eval/latency_retrieval.csv, eval/generation_runs.jsonl.

The grounding

It cannot recommend a bottle it did not retrieve

The system prompt tells Claude to recommend only from the retrieved reviews, to cite each pick by its [n] index, and to say so if none of them fit. Price, country, grape and minimum score can go into the vector query as metadata filters, so a $25 cap never spends a retrieval slot on an $80 bottle. The answer prints with the reviews it cites underneath.

Pipeline diagram: a plain-English request is embedded with sentence-transformers, retrieved with dense search plus BM25 fused by RRF and reranked by a cross-encoder against a 30,000-review index, then Claude writes a grounded answer citing Misiones de Rengo, 91 points, $20.
The pipeline as benchmarked.

Limitations

42 queries, scored by rules

Relevance comes from keyword-and-metadata rules, with no human judges. A rule that rewards grape and price favors retrievers that match on grape and price, and the error analysis shows the rules also reject some right answers. The rules are in src/eval.py and eval/queries_v2.json, the comparison is fair between systems, and the absolute numbers would need a hand-labeled set to mean more. The 30 new queries were written after the July results were known, so they are held out from the systems but not from the person who wrote them.

The index holds the 30,000 highest-scored reviews (--limit 30000), all rated 91 or above, not all 129,971; --all indexes everything. The reviews come from one magazine and carry its coverage gaps. Constraints such as price are set with CLI flags; the system does not yet read "under twenty bucks" out of the sentence.

How it was built

Stack

Corpus
The winemag-data-130k-v2 Wine Enthusiast dataset, de-duplicated; 30,000 reviews indexed by default
Embeddings
all-MiniLM-L6-v2 (384 dimensions) via sentence-transformers, on Apple MPS, CUDA or CPU
Vector store
Chroma, cosine similarity, persisted to disk
Sparse retrieval
BM25 (rank-bm25), fused with Reciprocal Rank Fusion, k = 60
Reranking
Cross-encoder ms-marco-MiniLM-L-6-v2 over the fused shortlist (60 candidates by default, 80 in the benchmark)
Generation
Claude through the Claude CLI by default; ANTHROPIC_API_KEY switches to the API
Evaluation
python -m src.eval (12 queries) and python eval/run_eval.py (12 + 30, per query); eval/latency.py, eval/generation.py, eval/error_analysis.py
Related
The same reviews are analyzed in Wine Score Inflation, and this retriever is a tool inside the Wine Pairing Agent

Rerank first; whether fusion helps depends on how people ask