This is the third installment in a series about building CiteLynq. The first covered data architecture principles at scale. The second covered embedding 60 million documents on rented GPUs for $250. This one is about what happens after the embeddings exist… making search actually work.
Because having 60 million vectors sitting in a database is not the same thing as having search. Not even close.
The two failures that forced hybrid search
Full-text search was our first implementation. PostgreSQL’s tsvector with GIN indexes, ts_rank_cd for scoring, ts_headline for snippets with highlighted terms. At 60 million documents, it works. Fast, reliable, well-understood technology. A query for “CRISPR gene editing” returns exactly what you’d expect.
But try searching for “methods to modify DNA sequences in living organisms” and you get nothing. Zero results. Because none of those words appear in papers that use the term “CRISPR.” Full-text search matches tokens, not meaning. It has no idea that “modify DNA sequences” and “gene editing” are the same concept.
So we added vector search. Embed the query, find the nearest neighbors in embedding space. Now “methods to modify DNA sequences” finds CRISPR papers. The model understands the semantic relationship. Problem solved.
Except it created a new one. Search for “Smith et al. 2019 neural network pruning” and vector search returns a scattered mess of vaguely related machine learning papers. It completely ignores the specific citation reference. It doesn’t understand that the user wants that exact paper. Full-text search would have nailed it instantly.
Each search paradigm has a blind spot that the other one covers perfectly. The obvious answer… use both.
Reciprocal Rank Fusion: the merge that actually works
The naive approach to combining two search results is to normalize their scores and average them. Don’t do this. Full-text ranks and cosine similarities have completely different distributions. A ts_rank_cd of 0.15 is excellent. A cosine similarity of 0.15 is garbage. No linear combination of these numbers produces meaningful results.
What works is Reciprocal Rank Fusion (RRF). Instead of combining scores, you combine rankings. For each document, the hybrid score is:
Where k is a constant (we use 60, which is the industry standard). A document that ranks #1 in both lists gets the maximum score. A document that ranks #1 in one list and doesn’t appear in the other still surfaces, just lower. A document that ranks #50 in both lists beats a document that ranks #1 in one and is absent from the other.
The beauty of RRF is that it doesn’t care about score distributions, magnitude differences, or normalization. It only cares about relative ordering within each list. Two completely different scoring systems become directly comparable.
How we implemented it
The architecture is simpler than it sounds:
- User submits a query
- In parallel: run full-text search via GIN index AND embed the query via the L40S
- Run vector search with the generated embedding
- Merge both result sets using RRF
- Return the fused results with snippets from full-text and similarity from vector
The key detail… we fetch 5x the requested limit from each source. If the user asks for 10 results, we pull 50 candidates from full-text and 50 from vector, merge them, and return the top 10. This gives RRF enough candidates to find documents that rank well in both systems.
The parallel execution means latency is max(full-text, vector), not the sum. Full-text search with GIN is typically under 50ms. Embedding the query takes about 100ms. Vector search depends on the index. Total wall time for a hybrid query: roughly 150-200ms. Acceptable for an interactive search experience.
The CTE trick that saved full-text at scale
One war story worth sharing. At 58 million documents, our full-text search went from 200ms to 111 seconds. Overnight. No code change. Just more data.
The problem: ts_rank_cd was being computed on every matching row. A broad query like “climate change” matches 164,000 documents. PostgreSQL was faithfully ranking all 164,000 before returning the top 10.
The fix: a CTE that pre-filters candidates before ranking.
WITH candidates AS (
SELECT id, title, published_at, source_url, text_search_vector
FROM documents
WHERE text_search_vector @@ plainto_tsquery('english', @q)
LIMIT 200
)
SELECT id, title,
ts_rank_cd(text_search_vector, plainto_tsquery('english', @q)) AS rank,
ts_headline('english', ...) AS snippet
FROM candidates
ORDER BY rank DESC
LIMIT 10
The GIN index scan is fast regardless of how many rows match. We grab the first 200 candidates (which the GIN index returns in some internal order), rank only those 200, and return the top 10. Query time: 339ms. Down from 111 seconds.
Is it theoretically possible that the 201st candidate would have ranked higher? Yes. In practice, the GIN index’s internal ordering is correlated enough with relevance that the top 200 candidates contain all the genuinely relevant documents. We’ve been running this in production for weeks with no quality complaints.
This is the kind of trade-off that becomes mandatory at scale. Perfect ranking of all candidates is a luxury that disappears somewhere between 100,000 and 10 million rows.
The Vector Math Wall
Here is where the story takes a turn. Everything I’ve described so far works. The hybrid search is deployed. The results are better than either search alone. But there’s a problem we haven’t solved yet, and it’s the biggest one.
We dropped the HNSW index during the bulk embedding load because maintaining it on every INSERT was cutting our throughput by 60%. That was the right call. But now those 60 million vectors need an index, and building one is its own special kind of brutal.
The napkin math for HNSW on 60 million 1024-dimensional vectors:
The index build requires holding the graph in memory. The formula is roughly N x D x 4 bytes x 2 (accounting for graph overhead):
60,000,000 x 1,024 x 4 bytes x 2 = ~480 GB of RAM
Just for the build to be fast. If your machine has less RAM than this, the build spills to disk and slows to a crawl.
Build speed on a high-end instance runs about 100-140 vectors per second for 1024 dimensions. At that rate:
60,000,000 / 120 vectors per second = ~140 hours = ~6 days
Six days of non-stop CPU churning for a single index build. On hardware that costs real money per hour. And the resulting index will be somewhere around 120 GB on disk.
This is where the “just use pgvector” advice that works beautifully at 100,000 documents starts to show cracks. The technology works. The economics and physics start to push back.
Matryoshka: the 75% shortcut
There’s an elegant solution that most people haven’t heard of yet: Matryoshka Representation Learning (MRL).
Named after the Russian nesting dolls, Matryoshka models are trained so that the first N dimensions of the embedding carry disproportionately more information than the remaining dimensions. You can truncate a 1024-dimensional vector to 256 dimensions and retain roughly 98% of the retrieval quality.
Think of it like a JPEG quality slider. The first 256 dimensions are the high-frequency information, the broad strokes of what the document is about. Dimensions 257-1024 are the fine detail, the subtle distinctions between closely related documents.
The impact on index infrastructure is dramatic:
A 4x reduction across every dimension that matters: storage, memory, build time, query latency.
The strategy is two-stage retrieval:
Stage 1: Search a 256-dimension HNSW index to find the top 500 candidates. This is fast, the index is small, and the query vector is also truncated to 256 dimensions.
Stage 2: Rerank those 500 candidates using the full 1024-dimension vectors with a brute-force dot product. No index needed for 500 vectors. Just raw math. On an L40S with its 18,176 CUDA cores, reranking 500 vectors against a single query takes microseconds.
The result: sub-second hybrid queries with 4x less infrastructure cost. The 256-dim index handles the “find the neighborhood” problem. The full 1024-dim vectors handle the “which of these neighbors is actually closest” problem. Each does the job it’s suited for.
BGE-M3, the model we’re using, supports this natively. The first 256 dimensions are usable as a standalone embedding. No retraining. No new model. Just vector[:256].
The halfvec shortcut (the easy win)
If Matryoshka feels like too much architecture, pgvector offers a simpler optimization: halfvec. Instead of storing vectors as 32-bit floats, store them as 16-bit half-precision floats.
The impact is straightforward:
- Storage: cuts in half. Our 11 GB HNSW index at 1.25 million rows becomes 5.5 GB. At 60 million rows, ~120 GB becomes ~60 GB.
- Memory: cuts in half. The build that needed 480 GB now needs 240 GB.
- Performance: actually faster on modern hardware. The L40S handles FP16 natively through its Tensor Cores. Distance calculations in 16-bit are faster than 32-bit, not slower.
The accuracy loss is negligible. Embedding values typically range from -1 to 1 with ~3 decimal places of meaningful precision. FP16 handles that with room to spare. You’re not doing scientific simulation. You’re doing approximate nearest neighbor search, which is already approximate by design.
If you’re running pgvector and haven’t switched to halfvec, it’s the single easiest optimization available. No model changes. No architecture changes. Just a column type change and an index rebuild.
The architecture we’re building toward
Putting all of this together, the full retrieval pipeline for 60 million documents looks like:
Total latency: under 200ms. Three different retrieval signals: exact keyword matching, broad semantic similarity, and fine-grained semantic ranking. Fused with a scoring method that doesn’t care about score distributions.
The full-text path catches exact matches and known-item searches. The vector path catches conceptual matches and paraphrased queries. The reranking path ensures the final ordering is as accurate as the model allows. And RRF combines them without any of the normalization headaches that plague weighted-score approaches.
What the numbers actually mean
Here’s how hybrid search changes the result quality in practice:
The hybrid approach doesn’t always produce different results from either individual method. For perfectly specific queries, full-text already wins. For perfectly conceptual queries, vector already wins. The improvement shows up in the messy middle, the ambiguous queries, the ones where the user isn’t sure of the right terminology, the ones that are part citation and part concept. Which is most real-world searches.
The meta-lesson
Three posts into this series, the same pattern keeps emerging…
the interesting problems at scale are never the ones you expect.
- Everyone talks about embedding models. Nobody talks about HNSW build times.
- Everyone talks about vector dimensions. Nobody talks about the CTE that saved full-text search from a 111-second query.
- Everyone talks about which retrieval method is “best.” Nobody talks about the constant k=60 in an RRF formula that makes the whole debate irrelevant.
The models, the embeddings, the vector math: those are solved problems. Someone smarter than you already figured them out. The unsolved problems are always in the integration layer. How do you combine two fundamentally different scoring systems? How do you build an index on 60 million vectors without 480 GB of RAM? How do you keep search under 200ms when one path requires a GPU round-trip?
These aren’t the problems that get conference papers. They’re the problems that determine whether your system actually works.
And they all have the same shape: you can’t solve them with a better model. You solve them with better plumbing.
What I’d do differently
Start with hybrid from day one. We built full-text first, then vector, then hybrid. Each transition required re-architecting the search layer. If I’d started with the RRF merge pattern and empty result sets from each source, adding each search type would have been plug-and-play.
Use Matryoshka dimensions from the start. Store the full 1024-dim vector but build your HNSW index on the first 256 dimensions. The index is 4x smaller, 4x faster to build, and 4x faster to query. Use the full vector only for reranking. The 2% recall loss is invisible in practice.
Use halfvec for everything. There is no practical reason to store embeddings in 32-bit precision for retrieval. FP16 is sufficient. The storage and performance savings compound at every level: disk, memory, index build time, query time.
Benchmark RRF weights early. The standard k=60 works well out of the box, but tuning it for your specific corpus can improve results. A lower k (like 20) weights top results more heavily. A higher k (like 100) flattens the distribution. We haven’t tuned ours yet, and we probably should.
The current state
As I write this, hybrid search is deployed and serving queries. The embedding pipeline is grinding through 59 million remaining documents at 10,000 per minute across three L40S GPUs. The HNSW index will be the next challenge, a multi-day build that we’ll tackle with Matryoshka truncation and halfvec to keep the infrastructure requirements sane.
The search already works better than either method alone. When the remaining 59 million vectors are indexed, it will cover the full corpus. 60 million documents, searchable by keyword and by meaning, in under 200 milliseconds.
That’s the goal. And the path from here to there is, as always, less about the algorithms and more about the plumbing.
Did you like this? Smash the subscribe button. I don’t always go deep into my projects but I always share thoughts from an actual developer doing what people talk about.
A series about building CiteLynq, a citation verification platform.
- In Part 1, we covered the Principles are the foundation that keeps your debt from spiraling.
- In Part 2, we moved from principles to pipes: Bulk Embedding Infrastructure. I’ll show you how to process millions of vectors without the costs compounding faster than your data.
- In Part 3, we’ll tackle the final boss: Hybrid Search Retrieval. Once you have the data and the embeddings, how do you actually get the right answer back out?