Retrieval for catalogs & noisy queries
Most of RedHop’s evidence comes from long, diverse questions over prose (contracts, multi-hop QA, manuals). This guide is about the opposite regime, the one a product catalog or a dealer-order pipeline lives in:
- Short queries — 2 to 5 tokens, not 15-token questions.
- A large, high-cardinality, near-duplicate corpus — one brand has dozens of price / size / flavor variants that differ by a token or two.
- Transcription noise —
laysarrives as1ays,kurkureaskurkur, from OCR, voice, or hurried typing.
The levers that win on prose behave differently here. The honest, reproducible evidence is in CATALOG_REGIME; this guide is the practical version. Two clean wins, one honest null.
1. Transcription typos → the char-ngram tier
Section titled “1. Transcription typos → the char-ngram tier”A short token carries no redundancy, so one mangled character zeroes token-exact
BM25: the typo’d brand contributes no matching term, and the right SKU sinks
below every same-product SKU from other brands. A dense model won’t save you
either, because it can’t rescue a 1 to 2 token query (the zero-dependency
semantic ceiling
is ~0.56). The right lever is subword lexical matching, no model: character
n-grams, so lays and 1ays still share ays and ys .
import redhop
# default grams 3–5; tune the range with language="char_ngram:2-4"doc = redhop.Document.from_text(catalog, options=redhop.DocumentOptions(language="char_ngram"))ctx = doc.context("1ays 20") # typo'd brand still finds the SKUconst { Document } = require("redhop");const doc = Document.fromText(catalog, { language: "char_ngram" });const ctx = doc.context("1ays 20");let mut doc = redhop::text(catalog, &redhop::LoadOptions { language: Some("char_ngram".into()), // or "char_ngram:2-4" ..Default::default()})?;let ctx = doc.context("1ays 20")?;On brand-typo’d queries this held early precision near 0.98 at every catalog size, while plain word-BM25 cratered to ~0.10.
The catch, and why it’s a tier and not the default. Char-ngram is a recall booster, not a drop-in. It gives the near-duplicates distinct gram scores (it actually reranks the family word-BM25 leaves tied), and at scale those fine distinctions push some family members out, so its clean set-coverage erodes as the corpus grows. Pair it with word-BM25 (use it as the recall leg of a hybrid), don’t replace your prose analyzer with it.
2. “Did I get the whole variant set?” → set-coverage
Section titled “2. “Did I get the whole variant set?” → set-coverage”In a catalog, one query legitimately maps to a set: a shopper asking for
“acme chips” needs all the price/size variants offered back so they can pick.
recall@k against a single gold chunk is blind to this. It reads a healthy 0.67
while a family is two-thirds retrieved and therefore un-offerable. In testing, a
recall@20 of a perfect 1.000 coexisted with strict set-coverage as low as
0.25.
Score the real thing with gold_families:
r = redhop.evaluate( query, ctx, gold_families=[["sku_a1", "sku_a2"], ["sku_b1", "sku_b2"]],)print(r.set_coverage) # 1.0 only if every family is fully retrieved# aggregate over a workload:redhop.summarize(reports).mean_set_coverageconst r = evaluate(query, ctx, { goldFamilies: [["sku_a1", "sku_a2"], ["sku_b1", "sku_b2"]] });console.log(r.setCoverage);let r = redhop::evaluate(&query, &ctx, None, redhop::EvalGold::AllOf(&[&["sku_a1", "sku_a2"], &["sku_b1", "sku_b2"]]), None, redhop::EvalConfig::default());let _ = r.set_coverage; // Option<f32>Make set-coverage the metric you sweep against when you tune anything below. A
config that lifts recall@k while dropping set-coverage has made
disambiguation worse.
3. Near-duplicate discrimination → field weights (an honest lever)
Section titled “3. Near-duplicate discrimination → field weights (an honest lever)”BM25 indexes three fields (text / source / heading) at equal weight. The
intuition is to boost the field carrying the discriminating token (put the brand
or title in heading, then boost it). You can:
# [text, source, heading]doc = redhop.Document.from_text(catalog, options=redhop.DocumentOptions(bm25_field_weights=[1.0, 1.0, 2.0]))const doc = Document.fromText(catalog, { bm25FieldWeights: [1.0, 1.0, 2.0] });use redhop::retrieval::{Bm25Retriever, FieldWeights};let r = Bm25Retriever::new()? .with_field_weights(FieldWeights { text: 1.0, source: 1.0, heading: 2.0 });The default (all 1.0) is bit-for-bit the equal-weight behavior, so the knob is
zero-regression. But here is the honest part: on our synthetic catalog,
boosting a structured field produced no set-coverage lift at all, and a heavy
boost slightly hurt. The reason is the rule worth keeping: a field boost helps
only when the boosted field separates the answer from its near-duplicates. A
clarify query like “acme chips” matches the boosted field identically for the
whole family and its siblings, so the boost scales them together and reorders
nothing. It can demote cross-field competitors (other brands), but not the
same-key siblings that usually crowd a catalog’s hard cases.
So treat field weights as a domain lever to sweep, not assume: try it on a held-out set, watch set-coverage, and keep it only if it earns its place. Equal weight stays the right default.
The axis behind all three: corpus size
Section titled “The axis behind all three: corpus size”The cleanest result in the regime is that, holding content constant, the best retriever can invert as the corpus grows. Char-ngram and word-BM25 tie at a small catalog; at a large one word-BM25 holds the recall floor that char-ngram loses. There’s no auto-selector yet, so size is a manual decision: run both arms on a held-out sample at your real catalog size and pick on set-coverage. A choice made at 100 SKUs is not safe to carry to 100,000 unmeasured.
A starting recipe
Section titled “A starting recipe”- Index with
language="char_ngram"if your queries are noisy or very short; keep plain word-BM25 as a second arm to compare. - Build a small gold set of
(query, families)and measure set-coverage, not just recall@k. - Only if a specific field clearly discriminates your hard cases, sweep
bm25_field_weightsand keep it if set-coverage rises. - Re-run the comparison at your production catalog size before you freeze the choice.
Everything here is a synthetic, single-domain re-derivation, so the numbers are
direction, not promises. Reproduce or extend it:
cargo run -p redhop-examples --example catalog_regime_probe --release. Related
reading: Choosing a configuration,
Options, Evaluation in production.