49 million rows is not big data. It is the awkward middle — too large to scan, too small to justify a distributed search cluster from day one. The right answer is a boring pipeline with sharp edges.
The shape of the problem
Users type messy strings. The corpus is semi-structured. P95 latency budget is 100ms including network. That budget forces every stage to be measured in single-digit milliseconds.
The pipeline
Every query flows through the same seven stages. The magic is that each stage is pure and independently cacheable.
Index layout
- Postgres + trigram GIN for fuzzy prefix.
- Meilisearch shard per vertical for typo tolerance and rank features.
- Materialized
tsvectorcolumn, refreshed by change-data-capture.
Rank shaping
BM25 alone ranks nothing users care about. Blend it with recency, popularity, and a learned per-vertical bias. Keep weights in config, not code, so you can A/B-test rank without a deploy.
score = 0.55 * bm25
+ 0.20 * log1p(popularity)
+ 0.15 * recency_decay(created_at)
+ 0.10 * vertical_bias[v]Serving budget
- Parse + normalize: < 3ms.
- Shard fan-out: < 30ms P95.
- Rank + hydrate: < 40ms P95.
- Serialize + wire: < 10ms.