Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

How finddax.com turns four spellings of one search into a single cache hit (Node + MongoDB)

If you cache search results, your cache key is probably the search query. That works right up until real people start typing. I'm building Finddax. It's a tool for freelancers and small agencies who sell websites to loc

If you cache search results, your cache key is probably the search query. That works right up until real people start typing.

I'm building Finddax. It's a tool for freelancers and small agencies who sell websites to local businesses. You type a city and a category, say "plumbers in Chicago", and finddax.com returns businesses on Google Maps that have good ratings and reviews but no website. One click then generates a working site for that business, plus a short pitch to send the owner. So you're not cold-calling with "do you want a website?", you're sending "here's your website, take a look."

Every search on Finddax goes through a shared cache. If two users look for "barbershop New York" in the same week, only the first one triggers a Google Maps lookup. The second one is served from MongoDB in milliseconds. That cache is the reason the free tier can exist at all.

The problem is obvious once you think about real input:

  • Junk removal
  • junk removal (trailing space)
  • junk removal (two spaces)
  • junk remooooval (somebody held a key)

Same search, same results. Use the raw string as the cache key and that's four keys and three misses you didn't need.

Here's what I ended up with in the Finddax backend, step by step.

Step 1: the boring normalization

const REPEATED_CHARS = /(.)\1{2,}/gu

function baseNormalize(text) {
  return text
    .toString()
    .trim()
    .toLowerCase()
    .replace(/\s+/g, ' ')
    .replace(REPEATED_CHARS, '$1')
}

The first four steps are obvious. The last one is the interesting one: it collapses three or more identical characters into one.

Why three and not two? Because doubled letters are everywhere in real words: co*ffee, pizza, grass, ball*room. Stretched typos look like remooooval or coffeeee. No real word has the same letter three times in a row. So {2,} after the capture group fixes the stretched typos and never touches a correct spelling.

It's a one-character decision, and it's the line I'd most want a reviewer to look at.

Step 2: Levenshtein, with a threshold that scales

Normalization handles whitespace and stretched letters. It doesn't handle junk remval, which has a letter missing. For that, Finddax compares the input against existing canonical terms by edit distance:

function clusterThreshold(length) {
  return Math.min(3, Math.max(1, Math.floor(length * 0.25)))
}

That's roughly one allowed edit per four characters, clamped to 1–3:

Term length Allowed edits
1–7 1
8–11 2
12+ 3

The low clamp matters: without it, anything shorter than 4 characters would get a threshold of 0 and never cluster. The high clamp stops long queries from swallowing genuinely different searches just because they share most of their letters.

The two steps work together. Take coffeeee:

  1. Normalization collapses eeee into e and gives coffe.
  2. coffe is distance 1 from coffee, and the threshold for 5 characters is 1.
  3. Same cluster, same cache bucket.

And junk remooooval: normalization turns it into junk removal, an exact match, no Levenshtein needed. All four spellings from the list above resolve to the same set of placeIds.

Step 3: don't run Levenshtein against the whole table

The naive version loads every term and runs the distance function against each one, on every search. That's fine with 50 terms and silly with 50,000.

The trick: edit distance is never smaller than the difference in lengths. If the input has 12 characters and the threshold is 3, a 20-character term can't possibly match. So that filter goes into Mongo:

const candidates = await SearchTerm.find({
  type,
  mergedInto: null,
  $expr: {
    $lte: [
      { $abs: { $subtract: [{ $strLenCP: '$canonical' }, normalized.length] } },
      threshold,
    ],
  },
})
  .select('canonical')
  .lean()

Use $strLenCP (code points), not $strLenBytes. Search terms can contain cafΓ©, ZΓΌrich or KrakΓ³w, and the UI also ships in Ukrainian, where every letter is 2 bytes in UTF-8. $strLenBytes would make those terms look longer than they are and quietly drop valid candidates. JavaScript's .length isn't strictly code points either, but for the BMP characters people type into a search box it agrees with $strLenCP.

The result is identical to the naive version. I just load a small slice of the registry.

Step 4: the synonyms Levenshtein can't see

junk removal and trash hauling are the same business. cleaning service and maid service mostly are too. The edit distance between them is basically the whole string, so no string metric will catch that.

So once a week a Finddax job sends the list of canonical terms to an LLM (DeepSeek) and asks which ones mean the same thing. It sends only the term strings: no business data, no user data. The model returns pairs, and the job marks one term in each pair with mergedInto: <other canonical>.

At lookup time the chain is followed to the end:

async function followMergeChain(term) {
  let current = term
  const seen = new Set([current.canonical])
  while (current.mergedInto && !seen.has(current.mergedInto)) {
    seen.add(current.mergedInto)
    const next = await SearchTerm.findOne({ type: current.type, canonical: current.mergedInto })
    if (!next) break
    current = next
  }
  return current
}

The seen set is there because an LLM will eventually return both A→B and B→A in the same week, and I'd rather not find out what an infinite loop in a request handler looks like in production.

The bug that was waiting for exactly this

The first version of the lookup did this:

  1. Find a near match among live terms (mergedInto: null).
  2. If there's none, create() a new term.

Now imagine someone types the exact canonical of a term that was merged last week. Step 1 skips it, because it isn't live. Step 2 tries to create it and hits the unique index on {type, canonical}, because the merged term still exists under that canonical. 500.

The fix was to check for an exact match first, merged or not, with one indexed lookup, before any clustering. Merged terms then resolve through the chain like everything else.

New terms are also created with an upsert ($setOnInsert) instead of create(). Two users searching for a brand-new term in the same second is rare, but "rare" in a request handler means "eventually".

What this can and can't do

A few things I know are wrong, or at least questionable:

  • Short words are risky. At 3 characters the threshold is 1, so spa and spar would land in the same cluster. Short terms probably need a threshold of 0 plus an explicit synonym list.
  • The first typo wins. Whoever searches a term first defines its canonical form. If the very first search is barbeershop (only two e's, so normalization keeps it), that becomes the canonical, and the autocomplete label is the first raw variant. Electing the most frequent variant would fix it. I haven't done that yet.
  • Terms have no language field. Finddax ships in English and Ukrainian, and the term registry is shared. For autocomplete I filter by script: English users see Latin-script terms, Ukrainian users see Cyrillic ones. It works only because the two languages don't share an alphabet, and it would break the day I add Spanish next to English.

The part I didn't expect

The same SearchTerm table that normalizes cache keys became the autocomplete. Every search increments a count atomically ($inc, so concurrent searches don't lose updates), and suggestions are a prefix regex sorted by that count. Start typing in the search box on finddax.com and the suggestions you see are just this table. I never designed a "popular searches" feature. It fell out of keeping cache keys consistent.

The cache window is 7 days now (it started at 1). Businesses on maps change slowly, and a cache miss is the slowest and most expensive request in the app.

The same cache also feeds the public businesses without a website pages on Finddax. Once a month the backend scans a set of cities through the exact same cache-first path users hit, and a static page per city gets rebuilt from the results. Every user search makes those pages a little fresher for free.

Try it

  • The product: finddax.com. Sign in with Google and you get 15 free credits, no card. A search costs 1 credit and a generated website costs 10.
  • City lists: finddax.com/en/businesses-without-website
  • The technique: one service of under 200 lines plus one Mongo $expr. No Elasticsearch, no trigram index, no Redis.

If you sell websites to local businesses, I'd really like to know whether Finddax finds leads you'd actually contact. And if you've run fuzzy cache keys at a larger scale: at what point did you give up on Levenshtein for trigrams or embeddings? Has anyone found a clean way to pick the canonical spelling other than "most frequent wins"?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.