Most search advice assumes your users type like you do. Mine don't. They are small retailers ordering stock on a phone, often with a patchy connection, and they type the way they speak — Hindi words in Latin script, English product names, Devanagari when the keyboard is already in that mode, and numbers scattered through the query meaning completely different things.

A representative query: kids shoes 7 number 400gm 500 rs tak. A human reads that instantly — children's shoes, size 7, 400 gram pack, under ₹500. A keyword search engine reads four numeric tokens and has no idea that only one of them is a price.

Why keyword search alone fails here

The first instinct is to fix this with tokenisation and synonyms. It doesn't hold up. Code-mixed language has no stable spelling — the same product gets typed a dozen ways, and a synonym table becomes a permanent maintenance tax that is always slightly out of date.

More fundamentally, the problem isn't vocabulary, it's intent. The engine needs to know that 500 rs tak is a filter on price while 400gm is part of what the user is looking for. That's a parsing problem, not a matching problem, and no amount of analyser configuration solves it.

Why "just ask the LLM" also fails

The obvious next move is to hand the query to a language model and ask for a filter. This works beautifully in a demo and badly in production, for a reason that took me a while to name:

A model asked to produce a filter will always produce a filter. Ask it for structured output and it will invent structure, because returning nothing looks like failure. In search, a hallucinated filter is worse than no filter — it silently removes correct results, and the user sees an empty shelf with no explanation.

I watched this happen with exactly the query above. The model decided 7 was a price and filtered the catalogue down to nothing. The search didn't error. It just returned zero rows, confidently.

Treat the model as a compiler

The reframing that fixed it: the model is not answering a question, it is compiling one language into another. Natural language in, a constrained filter expression out. Once you think of it as a compiler, the normal rules apply — you need a grammar, you need to define what is not valid, and you need the output to be verifiable before you execute it.

Concretely, that meant four rules.

1. A number is not a price unless something says it is

Default deny. A bare number is part of the search text, never a filter. It is only promoted to a price when an explicit cue sits next to it — a currency marker, a word like rate or daam, or a comparative like tak, under, se kam, ke upar.

Just as important is the inverse list: tokens that disqualify a number. Units of weight and volume, the words for size and number, quantity qualifiers. In kids shoes 7 number 400gm 500 rs tak, the 7 is killed by number, the 400 is killed by gm, and the 500 survives because rs and tak both vote for it.

This single rule removed the overwhelming majority of bad filters. It is also the rule I would write first if I started again.

2. Resolve ranges to numbers, never to expressions

Users say approximate things — around 1000, 1000 ki range. The tempting output is something like price >= 1000*0.9. Don't. Emitting arithmetic means your filter language now has an evaluator, and the model will eventually emit arithmetic you didn't anticipate.

The rule is that the model must do the arithmetic and emit literal bounds: 900 and 1100, never 1000*0.9. The output stays a flat, checkable expression, and you can validate it with a regex before it ever reaches the search engine.

3. Price lives in more than one column

A real catalogue has several prices per item — a base price, a tax-inclusive price, an MRP. Users don't know which one they mean, and they're right not to care. So a price filter expands to a disjunction across every price-like field rather than guessing at one. Slightly more permissive, dramatically fewer empty results.

4. Translate first, then parse

Devanagari input gets transliterated into the same Latin-script form the catalogue is indexed in before any matching happens — चना दाल becomes chana dal. Normalising scripts up front means one index and one matching path rather than a parallel pipeline per script.

Show the user what you understood

The highest-value feature turned out to be the smallest. Alongside the filter, the parser emits a short human-readable echo of what it extracted — in the same casual register the user typed in. "kids shoes 500 rupaye tak".

This matters for two reasons. When the parse is right, it builds trust: the system visibly understood. When it's wrong, the user can see why they got strange results instead of silently concluding the app is broken. It's also the single best debugging tool I have — a wrong echo tells you immediately whether the fault is in parsing or in retrieval.

The echo has strict rules of its own: mention the product and the price, never internal field names, never JSON, never filters the user didn't ask for. It is a summary for a person, not a debug dump.

Attributes you didn't know existed

Price is the easy filter because every catalogue has one. The harder problem is product attributes — colour, size, pack, flavour, material — which vary per category and change as the catalogue grows. Hardcoding them means a code change every time merchandising adds a dimension.

So instead of a fixed schema, the pipeline discovers the filterable attributes at query time from the result set itself, works out what each one represents from the metadata already attached to the products, and builds the parsing prompt dynamically from what it found. Add a new attribute to the catalogue and search can filter on it without anyone deploying anything.

When the filter is too good

A precise parser creates a new failure mode: a query that parses perfectly into four filters, all of which are individually reasonable, which together match zero products. Technically correct, commercially useless.

Two mechanisms handle this.

Progressive relaxation. Apply all the filters. If nothing comes back, drop the last one and retry, and keep going until something matches. Filters are ordered by confidence, so the first thing dropped is the thing the parser was least sure about. The response records how far it had to relax, which is useful both for the UI and for working out which attributes are routinely over-matched.

Strict and union together. Rather than choosing between "match everything" and "match anything", run both. Products matching every filter are ranked highest; products matching some are kept below them. The two sets merge into one result list with a confidence score attached, so items satisfying both strategies rank above items found by only one. The shelf is never empty, and the best matches are still on top.

What I'd do differently

Write the disqualifier list before the parser. I built cue detection first and spent far longer than I should have debugging confident, wrong filters. The list of things that mean "this number is not money" is the real core of the feature.

Validate the output shape, not just the content. Checking the filter expression against a strict grammar before execution catches a whole class of problems at the cheapest possible point. Anything unparseable gets discarded and the search degrades to plain text matching — which is a perfectly good outcome.

Log every parse with its input. The failures are not random. They cluster around specific phrasings, and you only find the clusters by looking at real queries. Most of the cue list came from reading production logs, not from thinking hard at a desk.

The general lesson, if there is one: when you put a language model in front of a database, the interesting engineering isn't the prompt. It's everything you build to constrain what the prompt is allowed to produce, and what happens when it produces it anyway.