Skip to content

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 noiselays arrives as 1ays, kurkure as kurkur, 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 SKU

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_coverage

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

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

  1. Index with language="char_ngram" if your queries are noisy or very short; keep plain word-BM25 as a second arm to compare.
  2. Build a small gold set of (query, families) and measure set-coverage, not just recall@k.
  3. Only if a specific field clearly discriminates your hard cases, sweep bm25_field_weights and keep it if set-coverage rises.
  4. 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.