Skip to content

Commit 776712a

Browse files
authored
Merge pull request #2 from CodeYogiCo/claude/semantic-query-caching-lsh-C8Azg
Add LSH post: When collision is good
2 parents 2943425 + ad7ebe9 commit 776712a

4 files changed

Lines changed: 395 additions & 39 deletions

File tree

package-lock.json

Lines changed: 0 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
---
2+
date: 2026-05-27
3+
tag: search
4+
title: "When collision is good: semantic query caching with LSH"
5+
read: 11 min
6+
deck: "Exact-match caches waste memory storing the same products under dozens of near-identical query strings. Here's how Locality-Sensitive Hashing fixes that — by designing hash collisions on purpose."
7+
---
8+
9+
Everyone learns the same rule on day one: hash collisions are bad. Two inputs landing in the same bucket means wasted work — longer lookup chains, unpredictable performance, security headaches. The whole point of a good hash function is to scatter inputs as randomly and evenly as possible.
10+
11+
Locality-Sensitive Hashing (LSH) breaks this rule deliberately. The goal is to make similar inputs land in the *same* bucket, not different ones. Collisions are the product, not the defect.
12+
13+
This post explains why you'd want that, how MinHash makes it work, and how an LSH-backed cache can triple the effective capacity of a search cache without adding a single byte of hardware.
14+
15+
## the problem with exact-match caching
16+
17+
A search cache is simple: hash the query string, look up the result. If a user has typed this exact query before, return the cached product list and skip the expensive retrieval pipeline.
18+
19+
The problem is that users don't type the same string twice. They type variations:
20+
21+
```
22+
"nike running shoes"
23+
"nike running shoe"
24+
"running shoes nike"
25+
"nike running sneakers"
26+
```
27+
28+
Those four strings produce four different hash values, four separate cache entries, and four copies of essentially the same product list. At the scale of a large retailer — hundreds of millions of searches per day, billions of cached entries — this redundancy is not a rounding error. It's a significant fraction of your cache budget.
29+
30+
The question isn't whether this waste exists. It's whether we can do anything about it without making wrong cache hits a thing.
31+
32+
<svg viewBox="0 0 640 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Exact-match cache vs LSH cache memory comparison" style="display: block; width: 100%; max-width: 640px; height: auto; margin: 28px auto;">
33+
<style>
34+
.cv-title { fill: var(--ink); font-family: var(--mono); font-size: 11px; font-weight: 600; text-anchor: middle; }
35+
.cv-label { fill: var(--ink); font-family: var(--mono); font-size: 10px; }
36+
.cv-faint { fill: var(--ink-faint); font-family: var(--mono); font-size: 9.5px; }
37+
.cv-box { fill: var(--bg-alt); stroke: var(--rule); stroke-width: 1; }
38+
.cv-box-ac { fill: var(--accent); stroke: var(--accent); stroke-width: 1; opacity: 0.15; }
39+
.cv-box-hi { fill: var(--accent); stroke: var(--accent); stroke-width: 1; }
40+
.cv-arrow { stroke: var(--ink-soft); stroke-width: 1; marker-end: url(#arr); fill: none; }
41+
.cv-divider { stroke: var(--rule); stroke-width: 1; stroke-dasharray: 3 4; }
42+
</style>
43+
<defs>
44+
<marker id="arr" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
45+
<path d="M0,0 L6,3 L0,6 Z" fill="var(--ink-soft)" />
46+
</marker>
47+
</defs>
48+
49+
<text class="cv-title" x="155" y="22">exact-match cache</text>
50+
<text class="cv-title" x="490" y="22">LSH cache</text>
51+
<line class="cv-divider" x1="320" y1="10" x2="320" y2="270" />
52+
53+
<text class="cv-label" x="10" y="52">"nike running shoes"</text>
54+
<text class="cv-label" x="10" y="82">"nike running shoe"</text>
55+
<text class="cv-label" x="10" y="112">"running shoes nike"</text>
56+
<text class="cv-label" x="10" y="142">"nike running sneakers"</text>
57+
58+
<rect class="cv-box" x="205" y="38" width="72" height="22" rx="3"/>
59+
<text class="cv-faint" x="241" y="53" text-anchor="middle">products ①</text>
60+
<rect class="cv-box" x="205" y="68" width="72" height="22" rx="3"/>
61+
<text class="cv-faint" x="241" y="83" text-anchor="middle">products ①</text>
62+
<rect class="cv-box" x="205" y="98" width="72" height="22" rx="3"/>
63+
<text class="cv-faint" x="241" y="113" text-anchor="middle">products ①</text>
64+
<rect class="cv-box" x="205" y="128" width="72" height="22" rx="3"/>
65+
<text class="cv-faint" x="241" y="143" text-anchor="middle">products ①</text>
66+
67+
<path class="cv-arrow" d="M185,49 L204,49" />
68+
<path class="cv-arrow" d="M185,79 L204,79" />
69+
<path class="cv-arrow" d="M185,109 L204,109" />
70+
<path class="cv-arrow" d="M185,139 L204,139" />
71+
72+
<text class="cv-faint" x="160" y="180">4 cache entries — same data</text>
73+
74+
<text class="cv-label" x="335" y="52">"nike running shoes"</text>
75+
<text class="cv-label" x="335" y="82">"nike running shoe"</text>
76+
<text class="cv-label" x="335" y="112">"running shoes nike"</text>
77+
<text class="cv-label" x="335" y="142">"nike running sneakers"</text>
78+
79+
<rect class="cv-box-hi" x="570" y="78" width="58" height="52" rx="3"/>
80+
<text class="cv-faint" x="599" y="100" text-anchor="middle" style="fill: var(--ink)">products</text>
81+
<text class="cv-faint" x="599" y="116" text-anchor="middle" style="fill: var(--ink)">①</text>
82+
83+
<path class="cv-arrow" d="M510,49 Q545,49 569,89" />
84+
<path class="cv-arrow" d="M510,79 Q545,79 569,99" />
85+
<path class="cv-arrow" d="M510,109 Q545,109 569,109" />
86+
<path class="cv-arrow" d="M510,139 Q545,139 569,119" />
87+
88+
<text class="cv-faint" x="465" y="180">1 cache entry — shared</text>
89+
</svg>
90+
91+
## jaccard similarity: a way to measure "same thing"
92+
93+
Before we can build a smarter cache, we need a way to measure whether two queries are saying the same thing.
94+
95+
**Jaccard similarity** is the simplest useful measure: divide the number of words the two queries share by the total number of unique words across both.
96+
97+
```
98+
A = {"nike", "running", "shoes"}
99+
B = {"nike", "running", "shoe"}
100+
101+
intersection = {"nike", "running"} → size 2
102+
union = {"nike", "running", "shoes", "shoe"} → size 4
103+
104+
Jaccard(A, B) = 2 / 4 = 0.5
105+
```
106+
107+
Two identical queries score 1.0. Completely unrelated queries score 0.0. The score lives cleanly in [0, 1].
108+
109+
For the cases we care about, similar queries score 0.5–0.9. Genuinely different queries (different category, different brand, different intent) tend to score below 0.2.
110+
111+
## minhash: turning jaccard into a hash
112+
113+
Now the clever part. There's a family of hash functions — called **MinHash** — with a remarkable property:
114+
115+
> For any two sets A and B, if you pick a random MinHash function h, then `P(h(A) == h(B)) = Jaccard(A, B)`.
116+
117+
Read that again. The *probability* that two queries produce the same hash value equals their Jaccard similarity. If two queries are 80% similar, a random MinHash function will give them the same hash 80% of the time.
118+
119+
This is the mathematical foundation that makes everything else work. The proof is elegant but not required here — the key intuition is: MinHash works by randomly permuting the set and taking the minimum element. Two similar sets have a higher chance of sharing their minimum.
120+
121+
## votes: making the signal reliable
122+
123+
A single MinHash function with 80% agreement probability is noisy. You'd see it disagree 20% of the time even for very similar queries, and agree 20% of the time even for dissimilar ones.
124+
125+
The fix is to run many hash functions and count agreements.
126+
127+
With 36 independent MinHash functions and a vote threshold of 18:
128+
- A query pair with Jaccard 0.8 agrees on ~29 out of 36 functions on average. Getting at least 18 agreements is almost certain.
129+
- A query pair with Jaccard 0.2 agrees on ~7 out of 36 functions on average. Getting at least 18 agreements is extremely unlikely.
130+
131+
The number of agreements follows a binomial distribution. With enough functions, the tails shrink and the two populations become cleanly separated. The vote count turns a noisy per-function signal into a reliable group decision.
132+
133+
Play with the numbers:
134+
135+
<div data-widget="lsh-match-calc"></div>
136+
137+
Two things worth noticing:
138+
139+
First, the S-curve crossover falls at the threshold ratio. With 18/36 votes (50%), the crossover is at Jaccard 0.5 — queries more than 50% similar get matched, queries less than 50% similar don't. Shift the threshold to 27/36 (75%) and the crossover shifts right.
140+
141+
Second, more hash functions means a *steeper* curve — a sharper boundary between "matched" and "not matched". Fewer functions gives a softer, fuzzier boundary. The RFC default of 36 with threshold 18 gives a curve steep enough to reliably separate similar from dissimilar while staying cheap to compute.
142+
143+
## how the system is actually built
144+
145+
There are two distinct parts, running at very different timescales.
146+
147+
**The offline cluster builder (nightly batch job)**
148+
149+
Once a day, run a job over the past 30 days of query logs. For each of the top ~60M queries:
150+
151+
1. Compute all 36 MinHash values.
152+
2. For each hash, record which bucket that query lands in.
153+
3. Any two queries that land in the same bucket across multiple hash functions increment an edge weight between them.
154+
4. Prune edges below a vote threshold (e.g., 20/36).
155+
5. Find connected components — each component is a semantic cluster.
156+
6. Pick a **canonical query** per cluster (simplest: most frequent query in the cluster).
157+
7. Publish the mapping: `canonical_query → which buckets it lives in`.
158+
159+
The output is a static index: given any bucket ID, which canonical queries appear in it?
160+
161+
**The online lookup (real-time, per request)**
162+
163+
When a user query arrives:
164+
165+
1. Normalize (lowercase, trim whitespace).
166+
2. Compute 36 MinHash values.
167+
3. For each value, look up the canonical queries that appear in that bucket. Tally votes.
168+
4. If the top-voted canonical query has ≥ 18 votes: it wins. Fetch its cached result.
169+
5. If no winner: cache miss. Fall through to the full retrieval pipeline.
170+
171+
```python
172+
def get_cached_results(user_query):
173+
q = normalize(user_query)
174+
votes = Counter()
175+
176+
for h in HASH_FUNCTIONS: # 36 functions
177+
bucket = h(q)
178+
for canonical in bucket_index[bucket]:
179+
votes[canonical] += 1
180+
181+
if not votes:
182+
return CACHE_MISS
183+
184+
winner, count = votes.most_common(1)[0]
185+
return cache.get(winner) if count >= MIN_VOTES else CACHE_MISS
186+
```
187+
188+
The 36 hash lookups can run in parallel. Each lookup is a hash table read against a compact in-memory index. At that point it's not doing search — it's doing arithmetic and array access.
189+
190+
## why token weights matter
191+
192+
Plain Jaccard treats all words equally. That's not quite right for queries.
193+
194+
Consider:
195+
196+
- "nike shoes" vs "adidas shoes" → Jaccard = 0.33, but these are different brand queries with different expected results
197+
- "nike shoes" vs "nike sneakers" → Jaccard = 0.33, but these almost certainly return the same products
198+
199+
A word like "shoes" carries more semantic meaning about the product category than a brand name. If we weight tokens by their importance — category words higher, brand names and modifiers lower — we get a similarity score that better tracks "would these two queries return the same results?"
200+
201+
This is **weighted Jaccard**. Amazon's implementation uses a Named Entity Recognition model to tag tokens as category, brand, or modifier, and assigns weights accordingly. If you're building at Target, you likely already have a query tagger in the query understanding pipeline. Reuse it. If you don't, a POS tagger that boosts nouns and discounts adjectives gets you 80% of the way there.
202+
203+
The math of weighted MinHash is slightly more involved (you weight the random permutation by token weight), but any decent library handles it — `datasketch` in Python, for instance. You pass in token weights, it gives you a MinHash. The rest of the system doesn't change.
204+
205+
## the numbers
206+
207+
**Cache capacity.** If a cluster of 4–5 near-duplicate queries now shares one cache entry instead of four, and the average cluster size in your query log is 3–5 queries, you're storing 3–5x fewer entries for the same result coverage. Amazon reported ~3x improvement in effective cache capacity.
208+
209+
**Hit rate on tail queries.** This is where the gains are biggest. Head queries (the top 1000 searches) already have high hit rates under exact-match caching because users type them verbatim repeatedly. Tail queries — rare, varied, one-off phrasings — are where the cache fails today. LSH clustering effectively "borrows" hits from the canonical query to cover all the tail variations. Amazon reported 250% F1 improvement on long-tail queries.
210+
211+
**Latency.** The cost is real. An exact-match cache lookup is one hash + one table read (~0.1 ms). LSH lookup adds 36 hashes + 36 table reads + a vote tally (~2 ms). That's a 20x increase in cache lookup overhead. The question is whether that 2 ms is acceptable given the p99 savings from serving more cache hits (and skipping 50 ms+ retrieval pipelines on misses). For most search SLOs, it is — but measure it before you commit.
212+
213+
## what can go wrong
214+
215+
**Wrong cache hits.** If LSH assigns a user query to the wrong canonical, they get irrelevant results with no recovery path — the cache says "hit" but the results are wrong. The vote threshold is your main defense. Set it too low and false matches creep in. The offline evaluation step (replaying query logs and comparing returned results against ground-truth retrieval output) is how you find the right threshold empirically before touching production.
216+
217+
**Cluster staleness.** Product catalogs change. A query cluster that was semantically coherent last month may not be today if a brand launches a new category or discontinues a product line. Nightly re-clustering handles the slow drift. You'll want a fast invalidation path — either a manual override or an automated signal from catalog change events — for sudden shifts.
218+
219+
**Cold start for new queries.** Any query that has never appeared in the training window won't be in any cluster. It's a cache miss, same as today. This is fine — it's the same baseline behavior — but it means LSH doesn't help at all for genuinely novel queries. Those are also your most expensive queries (novel phrasing → harder retrieval), but that's a separate problem.
220+
221+
## one breath
222+
223+
The insight is that hash collisions, normally a defect to engineer away, can be made into a feature. MinHash is designed so that the probability of a collision equals the Jaccard similarity between two sets. With enough hash functions and a vote threshold, the resulting match decision is reliable. Similar queries cluster together and share one cache entry; dissimilar queries don't. Cache capacity goes up, tail-query hit rate goes up, retrieval load goes down. You pay ~2 ms extra per lookup and accept that your cache is slightly fuzzy.
224+
225+
The math is worth understanding once. After that, the implementation is a library call and a batch job.
226+
227+
— v

0 commit comments

Comments
 (0)