Most teams pick a search engine before they decide what good search means for their product. Someone reads a comparison table, someone else has used one before, and the decision is made. A year later the index is a few terabytes and the real questions start. Why is p99 fine but p99.9 terrible? Why does relevance get worse every time we add a field? Why did the cluster fall over during a bulk reindex on a Tuesday afternoon?
I lead search at HighLevel, where the index spans more than a hundred products and the query volume is around half a billion a month. A lot of what I believed when I started turned out to be wrong. This is what I would tell myself at the beginning.
An inverted index is a phone book built backwards
This is worth being precise about, because almost every performance question later is answered by this one structure.
A normal index maps a document to its contents. An inverted index maps the other way. Each term points to the list of documents that contain it.
"payment" → [ 3, 17, 21, 94, 512, ... ]
"gateway" → [ 17, 94, 233, ... ]
"refund" → [ 3, 512, ... ]
Search for payment gateway and the engine loads two of those posting lists and
intersects them. Documents 17 and 94 contain both. The intersection is fast
because the lists are sorted integers, compressed, and walked in lockstep with
skip pointers. The engine is not scanning documents at all. It is merging sorted
arrays.
This is why an inverted index barely cares how many documents you have, and cares a lot about how many documents match. A query for a common term is slower than a query for a rare one. That is the opposite of what most engineers expect, and it explains a whole family of production surprises.
Before any of this can happen, text has to become terms. That is the analyzer, and it runs at both index time and query time.
The most common relevance bug I have seen, in every company I have worked at, is
an analyzer mismatch. The field was indexed with one analyzer and queried with
another. The documents are in there. The query cannot see them. Nothing errors and
nothing shows up in the logs. You find it by running _analyze on both sides and
comparing the token streams. Then you feel silly. Then two years later you find it
again somewhere else.
Relevance is a tuning problem, not a feature
Elasticsearch and OpenSearch both score with BM25 by default. The shape of it matters more than the formula.
A term that appears more often in a document scores higher, but with saturation. The tenth occurrence of "payment" adds far less than the second. This is the main thing BM25 improved over plain TF-IDF, and it is why keyword stuffing does not work on a well configured index.
A rare term is worth more than a common one. In a corpus of payment documentation, "payment" is nearly worthless and "chargeback" is gold.
Shorter fields win ties. Matching "refund" in a title beats matching it in a ten thousand word body, because the term is a bigger fraction of the field.
You will spend real time on that last one. Length normalization is controlled by
the b parameter, and the default of 0.75 is aggressive when your documents vary
a lot in size. If the index mixes very short records with long manuals, short junk
documents will outrank long useful ones and nobody on the team will be able to
explain why.
One more thing I wish I had understood earlier. You cannot tune relevance without judgments. Not opinions, judgments: a list of queries with the results a human says should come back, and a metric like NDCG computed over them. Without that, every relevance change is somebody's personal taste, nothing is falsifiable, and you will happily ship a change that improves your five favorite test queries and quietly ruins ten thousand others. A couple of hundred judged queries is enough to begin. It is boring work and it is the highest leverage thing on the whole project.
Shards are the decision you cannot walk back
An index is split into shards, and each shard is an independent Lucene index. A query fans out to every shard, each one returns its top results, and the coordinating node merges them.
Two things follow directly from that picture.
Your slowest shard is your latency. The coordinator waits for all of them. This is why p99.9 pulls away from p99 so sharply under load. You are not measuring the average shard, you are measuring the worst one, and with enough shards per query you sample the tail on every single request. Adding shards to spread load stops helping at some point and starts hurting.
Scoring is per shard by default. The term frequencies used for IDF are local to
each shard. With evenly distributed data nobody notices. With skew, the same
document scores differently depending on where it landed.
dfs_query_then_fetch fixes it at the cost of an extra round trip. Most people
discover that parameter while debugging something that looks impossible.
Primary shard count is fixed when the index is created. Changing it means a reindex. Pick with a simple rule, shards in the tens of gigabytes rather than hundreds, and then leave it alone.
It is also worth knowing that Elasticsearch is near real time, not real time. A
document becomes searchable after a refresh, which happens every second by
default. When you bulk load with that setting you are constantly creating tiny
segments that then have to be merged, and merges are the expensive background work
that eats your disk throughput. For a large reindex, set refresh_interval to
-1, set number_of_replicas to 0, load the data, then put both back. It is
often five to ten times faster. It is the first thing I check when somebody tells
me indexing is slow.
The three engines
Some history first, because the licensing situation explains most of the confusion around this choice.
Elasticsearch was Apache 2.0 until 2021, when Elastic moved it to SSPL. The move was aimed at AWS, who were offering it as a managed service. AWS forked the last Apache version and shipped OpenSearch. In 2024 Elastic added AGPL back as an option, which surprised a lot of people, but by then OpenSearch had three years of independent development and a foundation behind it. They are not the same software with two names anymore.
Typesense is not part of that story at all. It is a C++ engine under GPL, built on a different assumption: that most teams do not need everything Lucene can do, and are paying for that flexibility in operational complexity.
| Elasticsearch | OpenSearch | Typesense | |
|---|---|---|---|
| Engine | Lucene (Java) | Lucene (Java) | Custom (C++) |
| License | AGPL / SSPL / Elastic | Apache 2.0 | GPL v3 |
| Working set | Disk + page cache | Disk + page cache | RAM |
| Typo tolerance | Configurable, fiddly | Configurable, fiddly | On by default, good |
| Query language | Enormous | Enormous | Small |
| Vector search | Mature | Mature | Good |
| Operational load | High | High | Low |
| Where it hurts | JVM tuning, cost | Drifting from ES | RAM cost, ceiling |
Here is how I would actually choose.
Pick Typesense if your data fits comfortably in memory and your search is a search box. Instant results, typo tolerance, faceting, working this week. It is dramatically less work than the alternatives and the defaults are sensible in a way Elasticsearch's defaults are not. The constraint is real though. Typesense holds the index in RAM, so your cost scales with memory rather than disk, and somewhere north of a hundred gigabytes that stops being comfortable.
Pick OpenSearch if you want the depth of Lucene on an Apache 2.0 license with no ambiguity, or you are already on AWS and want the managed service. Feature parity with Elasticsearch is close but no longer exact, and the gap grows every year, so check the specific features you need instead of assuming.
Pick Elasticsearch if you need the frontier. The newest vector and hybrid search work, ES|QL, the widest ecosystem. It is still the most capable of the three, and you pay for that in operational surface area and in reading the license carefully before you build a product on top of it.
The mistake I see most often is not choosing the wrong engine. It is choosing a very powerful one, using five percent of it, and carrying all of its operational weight anyway. If your search is a box that returns records, start with Typesense and be pleasantly surprised. If search is a product surface with its own roadmap, ranking signals and personalization and hybrid retrieval, take Lucene and commit to it properly.
The part nobody warns you about
Search is the feature where quality is most visible and hardest to measure. Nobody files a bug saying result four should have been result two. They just stop using it. Your dashboards show healthy latency and a slow decline in engagement that you will blame on something else entirely.
So log the queries that return nothing, and read them every week. It is the cheapest signal you will ever get. Every one of those is a person who told you exactly what they wanted, in their own words, and got an empty page. Half of them are vocabulary mismatches you can fix with synonyms in an afternoon.
And write down what good search means for your product before you pick anything. That document is worth more than any comparison table, including this one.