Skip to content

Note

← Back

1 July 2026

Improving hybrid retrieval for Globirie

Two different questions

A news query is rarely one kind of query. “What happened in the Red Sea last week” is asking about a topic. “Reuters on Evergrande” is asking for two exact strings. Those want different machinery, and any single index will do one of them badly.

What Globirie actually does today

The lexical half is not clever. Article search runs ILIKE across title, summary, keywords, extracted entities and country, which is substring matching and nothing more. It is reliable on names and useless on paraphrase.

The dense half is a separate path. Article text becomes 1024-dimension vectors in a semantic_embeddings_v1 table, indexed with pgvector HNSW under cosine, normalised on write so cosine and inner product agree. The assistant’s similar-articles tool orders straight off embedding <=> query_vector and takes the top N.

Calling that hybrid would be generous. They are two doors into the same corpus and the caller picks one. Real fusion, where both candidate pools get merged and scored together, is the part I have not built yet.

The pgvector trap

Worth writing down, because nothing about it fails loudly: pgvector applies your WHERE filter after the HNSW scan, not during it. The index returns its candidates, the filter then cuts them down, and a selective filter can quietly hand back far fewer rows than the LIMIT you asked for. Nothing errors and nothing warns you, and a short list looks exactly like a complete one.

Globirie has one embedding space today, so that filter is a no-op and the problem stays invisible. The moment a second space of the same dimension lands in that table, space-filtered queries need either pgvector 0.8 iterative scans or a partial index per space. I left the note in the migration and did not build for it early, which I still think was right.

Where the budget actually goes

Reranking is the expensive stage and also the one that earns its place. In the retrieval backend’s BEIR evaluation, run through the live HTTP endpoints over 1,271 test queries, listwise reranking the top 50 moved aggregate nDCG@10 by +0.0127. That single number hides a real disagreement between datasets. FiQA gains significantly on nDCG@10, recall and F1. NFCorpus is inconclusive at the 95% level. SciFact actually loses significantly on recall@10 and F1@10, so reranking is not even free in quality terms.

It is certainly not free in time. A top-50 listwise rerank runs 454 ms at p50 on FiQA, 778 ms on SciFact and 892 ms on NFCorpus, and that is with candidate documents already capped at 2,400 characters to fit the input budget.

Which settles the candidate-depth question for me. If the reranker only ever sees 50 documents, widening the ANN pool from 200 to 500 buys nothing, because everything below the cut is decoration. The spends that move the metric are a better reranker or a deeper window, and both come out of the same second.

The uncomfortable part is that +0.0127 aggregate is not much for that price. Worth having, and nowhere near the difference between a good search and a bad one. Recall still decides most of the outcome, and that is the stage I have paid the least attention to.

What is missing

The lexical side should rank instead of match, which in Postgres means tsvector and ts_rank rather than ILIKE. Once it produces scores, the two pools can be fused and ranked together instead of chosen between.

The thing I actually want most is an evaluation set built from Globirie’s own corpus. BEIR tells me about SciFact and FiQA and nothing at all about news, and until I can measure a change on my own data I am tuning by feel.

← all notes