Search that understands momo, mo:mo and momos.
Adding typo-tolerant search with Meilisearch beside the existing SQL search, synced by a small Go job, without letting it take anything away.
People in Birtamod and Damak type food names the way they say them. Biryani is also briyani, biriyani and bryani. Chowmein is chaumin. Paneer is panir. Sekuwa is sekwa. Momo is momo, momos and mo:mo. The old search was SQL with LIKE patterns and a hand-tuned score, and for many of those spellings it found nothing.
I didn't want to replace it. The SQL search knew things a search engine wouldn't: which stores are open, zone and module rules, and a score that had been tuned over time. So I added Meilisearch beside it, as a second opinion that can only add results.
A sidecar, not a replacement
Meilisearch runs in a small container next to the app, with a dish index and a store index per environment. The Laravel app asks it for matching ids with a tight timeout and caches the answer briefly. If it is slow or down, the call returns an empty list and the SQL search runs exactly as before.
The ids come back into the existing query as an extra OR condition. Results are ordered by:
- rows Meilisearch confirmed, first
- the existing SQL score
- Meilisearch's own order, as a tie-break
SELECT i.id, i.name
FROM items i
WHERE (i.name LIKE :pattern OR i.id IN (:meili_ids))
-- plus the existing zone, module and open-store conditions
ORDER BY (i.id IN (:meili_ids)) DESC,
search_score DESC,
FIELD(i.id, :meili_ids);SELECT i.id, i.name
FROM items i
WHERE (i.name LIKE :pattern OR i.id IN (:meili_ids))
-- plus the existing zone, module and open-store conditions
ORDER BY (i.id IN (:meili_ids)) DESC,
search_score DESC,
FIELD(i.id, :meili_ids);FIELD() is a cheap way to keep the engine's ranking without moving the whole sort out of SQL. It returns the position of the id in the list, so it orders the confirmed rows the way Meilisearch ranked them.
Store search has a second path. A search for "biryani" should also find restaurants that serve biryani when the word isn't in their name, so the store search also includes stores whose dishes matched.
Keeping the index in step
A small Go command does the syncing. A timer runs it every minute or so as a one-shot job, using a read-only database user. Each run:
- reads every approved dish and store with the fields search needs: name, translated names, store name, category and parent category, tags, module, zone, status and the veg flag
- hashes each document and compares the hash with the one from the last run, kept in a small state file
- sends only the changed documents, in batches of a thousand
- deletes ids that disappeared
- pushes the index settings and synonyms again, so the settings in code are always the settings in the engine
for _, d := range docs {
raw, _ := json.Marshal(d)
sum := sha1.Sum(raw)
h := hex.EncodeToString(sum[:])
id := fmt.Sprint(d["id"])
cur[id] = h
if old[id] != h {
changed = append(changed, d)
}
}
for id := range old {
if _, ok := cur[id]; !ok {
gone = append(gone, id)
}
}for _, d := range docs {
raw, _ := json.Marshal(d)
sum := sha1.Sum(raw)
h := hex.EncodeToString(sum[:])
id := fmt.Sprint(d["id"])
cur[id] = h
if old[id] != h {
changed = append(changed, d)
}
}
for id := range old {
if _, ok := cur[id]; !ok {
gone = append(gone, id)
}
}The state file is written under a temporary name and then renamed, so a crash halfway through a write can't leave a broken file behind.
A diff against the database every minute has a useful property: it doesn't care who changed a dish. Menus are edited from the admin panel, the restaurant app, the menu importer and the newer Go console, and none of them had to learn about search. A one minute delay on a menu edit is fine.
Tuning what counts as a match
Synonyms come from two places. A built-in list in the sync command covers the common food spellings: biryani, momo, chowmein, chicken, paneer, sekuwa, thukpa, laphing, sel roti and a few more. A table in the database lets the team add their own from the console, either both ways or one way only.
Typo tolerance is on, with one typo allowed from four characters and two typos from eight. Short words have to be spelled right.
The setting that mattered most was the ranking score threshold. Without one, a search for biryani returned every dish from a restaurant with "Biryani" in its name, plus the fried rice that happened to sit in a biryani category, and "panir" pulled in pills. Anything below a score of 0.75 is now treated as noise and dropped before the ids reach SQL.
After the change, spellings that used to return nothing returned full pages of the right dishes and stores, and exact queries returned the same totals as before. That second check was the one I cared about most. The new path was allowed to add results, never to quietly remove old ones.
One operational note. Test searches are logged like real ones, and the logs feed the trending searches on the home screen. After testing in production, I delete my own rows, otherwise the app would tell customers that "panir" is trending.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.