Search at 100M Requests a Day
Four rewrites of one query on a private-markets data platform, from a database LIKE through a hand-tuned lexical engine to a hybrid retriever fusing BM25 with 768-dimension embeddings.
Overview
The search path on a private-markets data platform got rebuilt four times over about five years. Same feature, same search box, four different implementations underneath. None of the rebuilds happened because anyone wanted to modernise. Each one happened because something specific had broken.
By the end the cluster held 129 indices, 769 million documents and around 5.3 TB, and search was serving over 100 million requests a day.
Rewrite one: get off the database
Search ran against the primary databases at first, MySQL and MongoDB, which is where most
search starts and where it stays until the corpus gets big enough to hurt. The thing a
database will not give you is relevance ranking. A LIKE tells you that a row matched. It
cannot tell you how well it matched, so it cannot tell you what to put first.
So we moved search onto Elasticsearch. The migration itself was ordinary. What mattered was the package we built on top of it, which let a Django or MongoEngine model declare its own index: which fields to ship, how to map them, how to fetch the things the ORM could not reach on its own. Before that, adding search to a feature meant hand-writing Elasticsearch JSON, and teams mostly did not bother.
They bothered afterwards. It grew to 63 index modules and roughly 41,000 lines across the platform, and every one of those indices inherited the analyzer work, the boost tuning and the reindex tooling for free, because all of it lived in one place.
Rewrite two: make one field match five ways
Once search works, people rely on it, and then they start telling you precisely how it is wrong.
Nobody complained about empty result pages. The complaints were near misses. Someone
searching ecommerce missed everything written as e-commerce. Someone searching pharma
missed Pharmaceuticals. Someone searching a company’s full legal name found it ranked
below a competitor whose about-us page happened to mention it more often.
Those are all the same bug. One analyzer decides what counts as a token, and any query that
disagrees with that decision quietly fails. So we stopped choosing. Each searchable field
is indexed four ways at once: verbatim through a keyword tokenizer, normalized with
lowercasing and accent folding, edge-ngrammed from 2 to 15 characters in both directions to
catch prefixes and suffixes, and run through a character filter that strips separators so
e-commerce, e commerce and ecommerce collapse to one token.
A search term fans out across all four of those, in five clause shapes, against every searchable field, each with its own boost. The boosts come from three tiers. A product or revenue-segment field sits near the top at 30, a company’s display name at 27, a free-text description at 1, on the reasoning that a term showing up in a company’s list of disclosed products means something quite different from the same term buried in a paragraph of prose.
Two things from this rewrite I would keep in anything I build again.
The first is that every clause is named. Elasticsearch lets you attach a _name to any
clause and tells you which ones matched, so every generated clause carries one:
display_name.match_phrase_standard_0, hsn_codes.match_partial_0. When someone asks why
a result ranked where it did, you look it up instead of spending an afternoon guessing.
The second is that quotes turn the fuzziness off. A quoted term drops the three loose clause shapes and keeps only the two phrase ones, which gave power users a precision escape hatch without making the default behaviour stingy for everyone else.
Rewrite three: one index, many truths
The third rewrite had nothing to do with relevance. Different client organisations were entitled to see different financial numbers for the same company: the public filing for most, a separately curated set for some. A flat document holds one set of numbers and cannot express that at all.
The fix was to move the financial fields into a nested array where each entry records which organisations are allowed to see it. At query time the user’s organisation becomes a filter, filters on financial fields are rewritten to point inside the array, and the matching entry is flattened back to the top level on the way out, so nothing downstream ever learns the document changed shape.
That is the part of this work I find most interesting, and it needs more room than a section here, so I wrote it up separately as One Index, Many Truths, including the diff harness we used to prove the migration had not quietly changed anyone’s numbers.
Rewrite four: teach it meaning
By this point the lexical engine was about as good as hand-tuning gets. Then I read the search logs properly and found that we had spent years optimising the wrong thing.
The most-searched terms were fintech, pharma, healthcare, gaming, it services,
saas, packaging, family office, education, agri. Of the fifty most frequent
queries, two were the name of a company. The rest were concepts.
No boost table solves that. A company’s filings do not contain the sentence “we are a fintech”. They contain a corporate identification number, a list of disclosed products, an auditor’s report, and a business activity description written to satisfy a regulator. Token matching against that finds every company with the word in its registered name and none of the companies that actually are one.
The workarounds were visible in the logs too. One recurring query was
"manufacturer"||"manufacturing"||"manufacture", which is somebody hand-writing a boolean
OR across three inflections of a single word because the engine would not connect them.
Users compiling their own queries is a fairly direct statement about what the engine cannot
do.
So the fourth rewrite added a second retriever alongside the first rather than replacing it.
What gets embedded is a composed semantic profile for each company, a summary of what the
business actually does, rather than the raw document concatenated, which embeds badly. The
model is BAAI/bge-base-en-v1.5 at 768 dimensions. ELSER would have been the path of least
resistance inside Elastic, but it needs a Platinum licence we did not have, and
semantic_text works against any inference endpoint rather than only Elastic’s own.
bge-base sits near the top of the MTEB retrieval benchmark and runs on CPU, which mattered
more than it sounds, because the whole thing had to be provable on a laptop before anyone
would fund GPU capacity for it.
The two legs are combined with Reciprocal Rank Fusion, and the existing BM25 query carries over unchanged as the lexical leg. RRF works on ranks rather than scores, which is what makes it safe here. BM25 scores and cosine similarities are not on the same scale, and no amount of normalising makes them comparable.
The single most-searched term on the platform, and the one BM25 handles worst. Nothing in a company's filings says “we are a fintech”.
fintech →
split on && / || →
1 term
unquoted → all five clause shapes fire
match_phrase .text_standard match_phrase · raw match .text_joined match .text_standard match .text_partial bool/should | clause | field | boost | _name tag |
|---|---|---|---|
match_phrase | display_name.text_standard | 31 | display_name.match_phrase_standard_0 |
match_phrase | display_name | 31 | display_name.match_phrase_raw_0 |
match | display_name.text_joined | 28 | display_name.match_joined_0 |
match | display_name.text_standard | 28 | display_name.match_standard_0 |
match | display_name.text_partial | 28 | display_name.match_partial_0 |
Every clause carries a _name, so a baffling result can be traced back to the
exact clause that produced it. That one habit paid for itself many times over.
- 1 Fintech Labs Pvt Ltd display_name · phrase · boost 31
- 2 Fintech Advisory Services display_name · phrase · boost 31
- 3 Sunrise Fintech Solutions display_name · partial · boost 28
- 4 Fintech Media House display_name · phrase · boost 31
- 5 Global Fintech Consulting display_name · partial · boost 28
- 6 Razorpay keywords · partial · boost 26
- 1 Razorpay payments infrastructure, merchant lending
- 2 Cred credit-card repayment, member rewards
- 3 Pine Labs point-of-sale terminals, BNPL
- 4 Zerodha retail broking, market infrastructure
- 5 Fintech Labs Pvt Ltd consulting, thin business description
- 6 Groww investment platform, mutual funds
- 1 Fintech Labs Pvt Ltd L1 V5 1/61 + 1/65 = 0.0318
- 2 Razorpay L6 V1 1/66 + 1/61 = 0.0315
- 3 Fintech Advisory Services L2 1/62 = 0.0161
- 4 Cred V2 1/62 = 0.0161
- 5 Sunrise Fintech Solutions L3 1/63 = 0.0159
- 6 Pine Labs V3 1/63 = 0.0159
Every company whose legal name happens to contain the word outranks every company that actually is one. Fusion is more conservative than you might hope. Fintech Labs still holds first place, because appearing on both lists beats appearing high on one. What changes is everything under it: Razorpay climbs from sixth to second, and Cred and Pine Labs enter a ranking they were entirely absent from.
The counter-example. A product name lands in the highest-boosted fields on the index, so the lexical leg is already excellent, and fusion must not break it.
paracetamol →
split on && / || →
1 term
unquoted → all five clause shapes fire
match_phrase .text_standard match_phrase · raw match .text_joined match .text_standard match .text_partial bool/should | clause | field | boost | _name tag |
|---|---|---|---|
match_phrase | disclosed_prod_list.text_standard | 34 | disclosed_prod_list.match_phrase_standard_0 |
match_phrase | disclosed_prod_list | 34 | disclosed_prod_list.match_phrase_raw_0 |
match | disclosed_prod_list.text_joined | 31 | disclosed_prod_list.match_joined_0 |
match | disclosed_prod_list.text_standard | 31 | disclosed_prod_list.match_standard_0 |
match | disclosed_prod_list.text_partial | 31 | disclosed_prod_list.match_partial_0 |
Every clause carries a _name, so a baffling result can be traced back to the
exact clause that produced it. That one habit paid for itself many times over.
- 1 Granules India disclosed_prod_list · phrase · boost 34
- 2 IOL Chemicals & Pharma disclosed_prod_list · phrase · boost 34
- 3 Sri Krishna Pharmaceuticals GST descriptions · phrase · boost 34
- 4 Meghmani Organics revenue_segment · phrase · boost 34
- 5 Amoli Organics disclosed_prod_list · phrase · boost 34
- 6 Seya Industries hsn_codes · phrase · boost 34
- 1 Granules India API and finished-dosage manufacturing
- 2 IOL Chemicals & Pharma bulk drugs, active ingredients
- 3 Cipla formulations, no paracetamol line disclosed
- 4 Sri Krishna Pharmaceuticals API manufacturing
- 5 Mangalam Drugs anti-malarial and analgesic APIs
- 6 Amoli Organics speciality API manufacturing
- 1 Granules India L1 V1 1/61 + 1/61 = 0.0328
- 2 IOL Chemicals & Pharma L2 V2 1/62 + 1/62 = 0.0323
- 3 Sri Krishna Pharmaceuticals L3 V4 1/63 + 1/64 = 0.0315
- 4 Amoli Organics L5 V6 1/65 + 1/66 = 0.0305
- 5 Cipla V3 1/63 = 0.0159
- 6 Meghmani Organics L4 1/64 = 0.0156
Both legs agree, so fusion barely moves anything, which is the point. RRF is rank-based, so a strong lexical result stays strong even when the vector leg drifts toward loosely related manufacturers.
Two ordinary words that together mean something specific. The partial-match leg happily matches either half against thousands of irrelevant companies.
contract manufacturing →
split on && / || →
1 term
unquoted → all five clause shapes fire
match_phrase .text_standard match_phrase · raw match .text_joined match .text_standard match .text_partial bool/should | clause | field | boost | _name tag |
|---|---|---|---|
match_phrase | gst_business_activity_descriptions.text_standard | 34 | gst_business_activity_descriptions.match_phrase_standard_0 |
match_phrase | gst_business_activity_descriptions | 34 | gst_business_activity_descriptions.match_phrase_raw_0 |
match | gst_business_activity_descriptions.text_joined | 31 | gst_business_activity_descriptions.match_joined_0 |
match | gst_business_activity_descriptions.text_standard | 31 | gst_business_activity_descriptions.match_standard_0 |
match | gst_business_activity_descriptions.text_partial | 31 | gst_business_activity_descriptions.match_partial_0 |
Every clause carries a _name, so a baffling result can be traced back to the
exact clause that produced it. That one habit paid for itself many times over.
- 1 Contract Advertising India display_name · partial · “contract”
- 2 Manufacturing Systems Ltd display_name · partial · “manufacturing”
- 3 Hindustan Foods activity desc · phrase · boost 15
- 4 Contract Engineering Services display_name · partial · “contract”
- 5 Nutriwell Manufacturing display_name · partial · “manufacturing”
- 6 Innova Captab activity desc · phrase · boost 15
- 1 Hindustan Foods dedicated FMCG contract manufacturer
- 2 Innova Captab pharma CDMO, third-party formulations
- 3 Syngene International contract research and manufacturing
- 4 Dixon Technologies electronics ODM, never uses the phrase
- 5 Amber Enterprises white-goods OEM for other brands
- 6 Windlas Biotech CDMO, third-party manufacturing
- 1 Hindustan Foods L3 V1 1/63 + 1/61 = 0.0323
- 2 Innova Captab L6 V2 1/66 + 1/62 = 0.0313
- 3 Contract Advertising India L1 1/61 = 0.0164
- 4 Manufacturing Systems Ltd L2 1/62 = 0.0161
- 5 Syngene International V3 1/63 = 0.0159
- 6 Contract Engineering Services L4 1/64 = 0.0156
The vector leg finds Dixon and Amber, which describe themselves as ODM and OEM and never write “contract manufacturing” anywhere. No amount of boost tuning reaches them.
Users learned to add quotes to stop the fuzzy legs firing. Quoting drops three of the five clause shapes. Precision bought by hand, because the engine could not infer it.
"it services" →
split on && / || →
1 term
quoted → the three loose clauses are suppressed
match_phrase .text_standard match_phrase · raw match .text_joined match .text_standard match .text_partial bool/should | clause | field | boost | _name tag |
|---|---|---|---|
match_phrase | display_name.text_standard | 31 | display_name.match_phrase_standard_0 |
match_phrase | display_name | 31 | display_name.match_phrase_raw_0 |
match | display_name.text_joined | 28 | display_name.match_joined_0 |
match | display_name.text_standard | 28 | display_name.match_standard_0 |
match | display_name.text_partial | 28 | display_name.match_partial_0 |
Every clause carries a _name, so a baffling result can be traced back to the
exact clause that produced it. That one habit paid for itself many times over.
- 1 Infosys gics · phrase · boost 29
- 2 Wipro gics · phrase · boost 29
- 3 HCL Technologies sub_sector · phrase · boost 29
- 4 Tech Mahindra gics · phrase · boost 29
- 5 Mphasis classification_keywords · phrase · boost 29
- 6 Zensar Technologies sub_sector · phrase · boost 29
- 1 Infosys IT consulting and outsourcing
- 2 Tata Consultancy Services IT services, classified elsewhere
- 3 Wipro IT consulting, systems integration
- 4 HCL Technologies engineering and IT services
- 5 LTIMindtree digital transformation services
- 6 Persistent Systems software product engineering
- 1 Infosys L1 V1 1/61 + 1/61 = 0.0328
- 2 Wipro L2 V3 1/62 + 1/63 = 0.0320
- 3 HCL Technologies L3 V4 1/63 + 1/64 = 0.0315
- 4 Tata Consultancy Services V2 1/62 = 0.0161
- 5 Tech Mahindra L4 1/64 = 0.0156
- 6 Mphasis L5 1/65 = 0.0154
A well-curated sector field makes the lexical leg strong. Fusion's contribution here is recall: companies sitting under a neighbouring classification code still surface.
The hardest case. No field on the index holds this concept at all. It is a fact about how an entity behaves, not about what it filed.
family office →
split on && / || →
1 term
unquoted → all five clause shapes fire
match_phrase .text_standard match_phrase · raw match .text_joined match .text_standard match .text_partial bool/should | clause | field | boost | _name tag |
|---|---|---|---|
match_phrase | display_name.text_standard | 31 | display_name.match_phrase_standard_0 |
match_phrase | display_name | 31 | display_name.match_phrase_raw_0 |
match | display_name.text_joined | 28 | display_name.match_joined_0 |
match | display_name.text_standard | 28 | display_name.match_standard_0 |
match | display_name.text_partial | 28 | display_name.match_partial_0 |
Every clause carries a _name, so a baffling result can be traced back to the
exact clause that produced it. That one habit paid for itself many times over.
- 1 Office Automation Ltd display_name · partial · “office”
- 2 Family Care Hospitals display_name · partial · “family”
- 3 Office Beacon Services display_name · partial · “office”
- 4 Family Health Plan TPA display_name · partial · “family”
- 5 Modern Office Supplies display_name · partial · “office”
- 6 Family Credit Ltd display_name · partial · “family”
- 1 Premji Invest single-family investment office
- 2 Catamaran Ventures founder's private investment vehicle
- 3 RNT Associates private investment office
- 4 Hemendra Kothari Family Office explicit, and also a lexical hit
- 5 Ratnabali Capital closely-held investment holding
- 6 Kotak Investment Advisors private wealth, adjacent
- 1 Office Automation Ltd L1 1/61 = 0.0164
- 2 Premji Invest V1 1/61 = 0.0164
- 3 Family Care Hospitals L2 1/62 = 0.0161
- 4 Catamaran Ventures V2 1/62 = 0.0161
- 5 Office Beacon Services L3 1/63 = 0.0159
- 6 RNT Associates V3 1/63 = 0.0159
The lexical leg returns nothing a user wanted, just six companies that matched on half a word each. And because the two lists overlap nowhere at all, RRF has nothing to reinforce and simply alternates between them: office-supply company, family office, hospital, family office. That is the honest limit of rank fusion. It lifts a bad lexical leg; it does not overrule one. Fixing this properly means weighting the legs, or accepting that some queries should not run the lexical leg at all.
The four analyzers behind those subfields
| subfield | analyzer | chain | what it catches |
|---|---|---|---|
(no suffix) | raw | keyword tokenizer | the whole field, verbatim: exact identifiers and full names |
.text_standard | normalized | standard → lowercase → asciifolding | ordinary words, case and accents flattened |
.text_partial | partialmatch | standard → lowercase → asciifolding → edge_ngram 2 to 15, front and back | prefixes and suffixes, so 'pharma' finds 'pharmaceuticals' |
.text_joined | joined | strip separators → standard → lowercase → asciifolding → edge_ngram 2 to 15 | 'e-commerce', 'e commerce' and 'ecommerce' as one thing |
Fusion is less dramatic than the marketing around it suggests, and the widget above is built
so you can check that rather than take my word for it. A document appearing on both lists
beats a document ranked first on only one, so the literal name matches do not disappear. On
family office, where the two retrievers agree on nothing, RRF has nothing to reinforce and
simply alternates between them. Rank fusion lifts a weak lexical leg without overruling it,
and weighting the two legs against each other is the next thing I would have tuned.
I built all of it locally, Elasticsearch in Docker on a laptop, embedding the full corpus, so that the cost of being wrong was an evening rather than a procurement cycle.
It reached a validated local index over the whole corpus. The production rollout was still ahead of it, which I would rather state than leave ambiguous.
What I would do differently
Read the query logs in year one rather than year five. Every decision in rewrite two was a reasonable guess about what users wanted, and the answer was sitting in a table the entire time.
Delete failed experiments properly, too. There is still a fuzziness: 2 commented out in
that query builder, from an attempt to fix the near-miss problem with edit distance before
we fixed it with analyzers instead. Backing it out was the right call, since fuzzy matching
across 37 fields is expensive and turns up things nobody meant. But leaving the corpse in
the file taught the next reader nothing that a line in the commit message would not have.