Shard Geometry
Managing Search Latency made the product query cheap by baking the business score into a stored field and pushing the price anchor into a rescore window. All of it runs on top of the index’s physical layout, which has been the untouched default since day one.
products_semantic_v1 was created without a shard
count, so OpenSearch filled in a single primary shard. Everything since has
been running on it, embeddings and hybrid retrieval included.
For a while that’s fine. Shard geometry stays invisible while the catalog is small and becomes expensive to change right around the time it starts to matter. It looks like capacity planning. It also, less obviously, changes what scores come back.
One shard by default
A shard is a complete Lucene index with its own segments and caches. When a query arrives, every shard of the index runs it independently and produces its own ranked results, like a small search engine that only knows about its slice of the catalog.
OpenSearch creates indexes with
index.number_of_shards: 1
unless told otherwise. One shard means no fan-out and nothing to merge, which
is genuinely the right call for small indexes. It also means a single Lucene
index holds the whole catalog, so one node does all the search work for that
copy and a recovery has to move everything at once.
Fan-out
With more than one shard, a search becomes a scatter and gather. The
coordinator picks a copy of each shard and sends the query. Every shard runs
it against its own slice and answers with its top size candidates, then the
coordinator merges those lists and fetches the winners.
flowchart TD
Q["<strong>Query</strong><br/><span class='mermaid-detail'>walnut record cabinet</span>"]
C["<strong>Coordinator</strong>"]
S0["<strong>Shard 0</strong><br/><span class='mermaid-detail'>top 10</span>"]
S1["<strong>Shard 1</strong><br/><span class='mermaid-detail'>top 10</span>"]
S2["<strong>Shard 2</strong><br/><span class='mermaid-detail'>top 10</span>"]
M["<strong>Merge</strong><br/><span class='mermaid-detail'>30 candidates, top 10 out</span>"]
Q --> C
C --> S0
C --> S1
C --> S2
S0 --> M
S1 --> M
S2 --> M
Every shard you add buys parallelism and charges overhead. The Elasticsearch sizing guidance puts it bluntly: searching a thousand 50 MB shards is substantially more expensive than searching a single 50 GB shard holding the same data.
Custom _routing can pin queries to one shard when traffic is tenant-shaped.
A shared furniture catalog isn’t, so every search visits every shard, and the
shard count decides how wide that fan is.
Too many shards
Each shard carries fixed costs whether or not it holds much data: heap for segment metadata and a slot in the cluster state. Each query pays per shard too, since a full query execution and a results queue exist for every one of them.
Oversharding gets you the overhead without the parallelism. Ten shards holding 6 GB each answer the same question as three shards holding 20 GB, but the coordinator merges ten lists instead of three, and the cluster babysits ten sets of segments. Small shards also warm slowly, because each one maintains its own caches over a sliver of the data.
Too few shards
In the other direction, a shard is the unit of recovery and rebalancing. When a node dies, its shards move as whole units, and a 60 GB shard takes a long time to copy while the cluster runs with reduced redundancy. Merges get heavier as segments grow, and a single query can’t spread one shard’s work across nodes. The shard is the ceiling on per-query parallelism.
The folk band is 10 to 50 GB per shard. Like most folk wisdom it encodes real trade-offs: big enough that fixed costs amortize, small enough that recovery and merges stay boring.
The sizing math
The catalog is at five million products now. The storage shape, following the embedding storage math:
5,000,000 products
text, metadata, _source ~5 KB each 25 GB
vectors, 1,536 dims, FP32 6.1 KB each 31 GB
HNSW graph, m = 16 128 bytes each 0.6 GB
------
~57 GB
The vectors quietly became the majority of the index. That’s normal for an embedding-backed catalog and worth noticing, because it means shard sizing is now dominated by a field no shopper ever sees.
One shard at 57 GB sits past the band. Ten shards would be 5.7 GB apiece and pay fan-out for nothing. Three shards land around 19 GB each. I aim for the middle of the band rather than the top of it, because catalogs only grow.
PUT /products_semantic_v2
{
"settings": {
"index": {
"number_of_shards": 3,
"number_of_replicas": 1,
"knn": true
}
}
}
The mappings are identical to v1. Only the settings change.
After reindexing, _cat/shards
shows the layout:
GET /_cat/shards/products_semantic?v&h=index,shard,prirep,store,node
index shard prirep store node
products_semantic_v2 0 p 19.2gb search-node-1
products_semantic_v2 0 r 19.2gb search-node-3
products_semantic_v2 1 p 18.7gb search-node-2
products_semantic_v2 1 r 18.7gb search-node-1
products_semantic_v2 2 p 19gb search-node-3
products_semantic_v2 2 r 19gb search-node-2
Three primaries spread across three nodes, each with a replica on a different node. A broad query now runs on all three at once instead of one.
Resizing
Shard count is fixed at index creation. OpenSearch has
_split
and _shrink
to multiply or divide the count, but both require blocking writes on the
index first, which makes them tools for quiet hours rather than live
catalogs.
For a catalog that can’t pause, the path is reindexing with zero downtime: create the new index with the geometry you want, reindex into it, and swap the alias. That discipline of always querying through the alias is exactly what makes a reshard a routine operation instead of a migration project.
Shards change scores
BM25 statistics are computed per shard. The default
query_then_fetch
scores documents with each shard’s local term and document frequencies, so a
term’s IDF on shard 0 isn’t quite the IDF on shard 2. At five million
randomly routed documents the shards are statistically near-identical and
nobody can tell. The place it bites is small or skewed indexes, and the
classic victim is the staging cluster holding two percent of production
data: same code, same query, visibly different order.
dfs_query_then_fetch fixes the statistics by collecting global frequencies
in an extra round trip:
GET /products_semantic/_search?search_type=dfs_query_then_fetch
It costs latency on every query, so treat it as a diagnostic. If result order changes meaningfully between the two search types, your shards are too small or your routing is skewed.
The vector branch has its own version. In the
k-NN query,
k is per shard: each shard searches its own HNSW graph k deep, then sends
its top size to the coordinator, which keeps the best size of the merged
pool. The hybrid query runs with k: 100. On one
shard that’s a single graph searched 100 deep. On three shards it’s three
graphs searched 100 deep each, with the coordinator picking ten from thirty.
More shards examine more candidates in total, which costs more and can lift
recall. Either way the page can change.
Which means a reshard is a ranking change that never touched the query body. The lexical scores shift a little and the vector candidate pool shifts more. RRF fuses whatever those branches now produce.
Replicas
Replicas are the setting people reach for when queries feel slow, and half the time it’s the wrong knob. A query uses one copy of each shard, so adding replicas doesn’t make any single query faster. What replicas buy is concurrency and survival: more copies to spread queries across, and a copy left standing when a node disappears.
Unlike shard count, the replica count is live and adjustable:
PUT /products_semantic/_settings
{
"index": {
"number_of_replicas": 2
}
}
Storage multiplies accordingly: 57 GB of primaries becomes 171 GB on disk with two replicas. Primaries are the parallelism knob and replicas are the throughput knob. They look interchangeable on a whiteboard right up until a node fails or a sale starts.
Evaluation
A resize needs the same two checks as the latency work, and for once the relevance check is the less obvious one.
Measure latency under production-like concurrency, before and after, with attention on the p99. Fan-out moves both ends of the distribution: more shards can cut the p50 on broad queries while the extra merge work piles up at the tail.
Then re-run the judged-query loop. The
previous section is the reason: per-shard BM25 statistics and per-shard k
both moved, so nDCG@10 should land within noise of the old geometry, and a
real gap usually points at the vector branch. Once the alias swaps, watch
the online metrics with the same suspicion
you’d give any ranking deploy.
The single-shard default was a fine place to start and a bad place to stay, and the move between those two states is cheapest while the index is still small. Pick the geometry deliberately and write down why. When the catalog outgrows it, treat the resize as what it actually is: a ranking change that happens to arrive in an ops ticket.