Hybrid search with bge-m3 and Elasticsearch: six lessons
Keyword plus semantic search over a bilingual home library: analysers, RRF scores, cross-lingual models, GPU queues and one 34 MB book
One search for “prometheus” in my library used to return 38 results. After a one-line fix it returned 2,631 results. A search for “kubernetes” went from 0 to 13. Nothing about the documents had changed. The query was simply being analysed differently from the text it was searching.
That bug is a good entry point into what I learned building hybrid search, keyword plus semantic, over a library of tens of thousands of books and documents in English and Romanian, on one machine with one GPU. Most of the lessons are not about embeddings at all. They are about analysers, timeouts, GPU queues and scores that look comparable but are not.
Contents · 11 sections
TL;DR #
- Hybrid search = BM25 keyword search and kNN vector search, merged with reciprocal rank fusion (RRF). Each leg rescues the other’s blind spots.
- Index and query must use the same analyser. Mine did not, and common technical words returned almost nothing.
- RRF scores are ranks, not relevance. 0.0164 means “rank 1” in every call. Never compare them across separate queries.
- An English-only embedding model made cross-lingual search look broken. Switching the semantic leg to the multilingual bge-m3 fixed it.
- On a shared GPU, the slow part of semantic search is waiting to embed the query, not the vector search. Bound that wait so the keyword leg still answers.
- One 34 MB book broke every query that matched it, because of a highlighting limit.
The architecture #
The library lives in Elasticsearch. A crawler indexes each file’s full text into a parent index. Embedders split documents into chunks and store vectors in two chunk indexes:
- an English index with
nomic-embed-textvectors; - a multilingual index with
bge-m3vectors (1,024 dimensions), which places text in different languages in one shared space.
A search service exposes three modes: keyword, semantic and hybrid. Hybrid runs both legs and fuses them:
def rrf(rankings, k=60):
scores = {}
for ranking in rankings: # each ranking: list of doc ids, best first
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
keyword = bm25_search(q, size=50) # parent index, full text
semantic = knn_search(embed(q), size=50) # chunk index, grouped by parent
hits = rrf([keyword, semantic])
RRF does not need the two legs’ scores to be on the same scale, which is exactly why it is popular: BM25 scores and cosine similarities are not comparable. It only uses positions.
Later I added a third leg: study guides, clean per-section summaries of each book, indexed with their own vectors. They retrieve well where PDF text is noisy, and they fuse into the same RRF.
Lesson 1: the analyser mismatch #
The full text was indexed with Elasticsearch’s standard analyser. The query, however, was run through a custom analyser with stemming, stop words and accent folding. The query tokens did not exist in the index. Words like “prometheus” were stemmed into something the index never contained.
The fix was to search with the analyser the field was indexed with:
{
"query": {
"match": {
"content": {
"query": "prometheus",
"analyzer": "standard"
}
}
}
}
“kubernetes” went from 0 to 13 hits and “prometheus” from 38 to 2,631. If you use a custom analyser at query time, it must produce the same tokens as the index analyser, or you are searching for words that are not there. The _analyze API shows both token streams side by side; compare them for a few real queries before you trust the setup.
The same review found two more silent problems: the semantic leg ignored filters (shelf, language), and the chunk indexes had no language field at all, so a language filter on question-answering matched nothing. Filters now go through the parent document.
Lesson 2: RRF scores are not relevance #
With k = 60, rank 1 in any list scores 1/61 = 0.0164. Every query’s best hit gets the same score, whether it is a perfect match or the least bad of nothing.
I learned this when I wrote a tool that ran one hybrid search per shelf and merged the results by score. The merged list was meaningless: each shelf’s best hit tied at 0.0164. RRF scores are only comparable within one fused result list. To compare across calls, rank by something absolute, such as the cosine similarity of the best passage in each document.
Lesson 3: the multilingual model has to be on the path you test #
My early cross-lingual tests were discouraging. Romanian and Russian queries about security topics returned Romanian novels and coursework instead of the English technical documents I expected.
It turned out the tool I tested with was using the English-only model on the English index for its semantic leg. Of course it could not match Romanian queries to English text. Once the semantic leg used bge-m3 on the multilingual index, cross-lingual retrieval worked.
One subtlety remained. For non-English queries, pure semantic often beats hybrid, because the keyword leg pulls in same-language documents that share common words. My guidance is:
- keyword for exact identifiers (a CVE number, a title);
- semantic for concepts and techniques, and for queries in another language than the documents;
- hybrid for named reports and entities.
The two models also need different query formats: nomic-embed-text wants a search_query: prefix, bge-m3 wants none. Get that wrong and quality drops without any error.
Lesson 4: on a shared GPU, embedding the query is the slow part #
Semantic search needs one embedding per query. On a dedicated GPU that takes milliseconds. On my card, shared with study-guide generation and other models, it did not.
I saw three versions of the same failure:
- VRAM starvation. Pipeline services pinned their models with a 24-hour keep-alive. A 4.9 GB generation model sat in VRAM, the embedding model could not load, and a multilingual backfill with 4 workers and no pause flooded the queue. Every semantic search returned 503. The fix: keep-alive 0 for pipelines, 1 worker with a 2-second pause for the backfill.
- Queueing behind generation. With a guide generator serving two parallel requests, the query embedding waited over 44 seconds. The vector search itself took 0.6 seconds. Raising the server’s parallel limit from 2 to 4 brought semantic search to 5.6 to 7.3 seconds under load.
- Timeouts that never let the fallback run. The search service waited up to 90 seconds per embedding, for two legs, while the AI assistant’s client gave up after 60. The keyword fallback never got a chance. Three of four hybrid calls from an AI assistant failed.
The fix for the third was the most general:
SEARCH_EMBED_TIMEOUT = 20 # seconds; well under any client timeout
try:
vec = memo.get(q) or embed(q, timeout=SEARCH_EMBED_TIMEOUT)
memo[q] = vec
semantic = knn_search(vec)
except EmbedTimeout:
semantic = [] # degrade to keyword-only, still answer
hits = rrf([keyword, semantic])
The embedding wait must be shorter than the caller’s timeout, with room to spare, and the query vector is memoised so the hybrid and semantic legs do not embed twice. Replaying the failed calls afterwards: full hybrid results in 2.9 to 6.8 seconds.
Lesson 5: one huge document can break every query that finds it #
The query “keshe plasma” failed with a 503, but only when I asked for 30 or more results. At 25 it worked.
Elasticsearch refuses the whole search with a 400 when a matching document’s field is longer than index.highlight.max_analyzed_offset (10 million characters on my index). A 34 MB book matched that query at around rank 26 to 30. It only entered the result window at size 30.
The fix tells Elasticsearch to highlight only the start of long fields instead of failing:
"highlight": {
"max_analyzed_offset": 1000000,
"fields": { "content": {} }
}
If your index contains whole books, test queries at large result sizes. The failure depends on which document happens to be in the window.
Lesson 6: know what your semantic leg actually sees #
My chunk index keeps the first 100 chunks of each book. Keyword search sees the full text; semantic search sees only the opening. For most queries that is fine. For a question about the last chapter of a long book, only the keyword leg can find it. Knowing that tells you which mode to trust for which question.
Checklist: hybrid search that behaves #
- Compare
_analyzeoutput for index and query on real queries. - Make sure filters apply to the vector leg too.
- Fuse with RRF and never compare its scores across calls.
- Use a multilingual embedding model on the path you actually test, with the right query prefix.
- Bound the query embedding wait below every client’s timeout and fall back to keyword.
- Memoise the query vector per request.
- Do not let batch jobs pin models in a shared GPU.
- Set
highlight.max_analyzed_offsetif any document is large, and test at big result sizes. - Know how much of each document the vector index covers.
Frequently asked questions #
What is hybrid search? #
It runs a keyword search (BM25) and a vector search (kNN over embeddings) for the same query and merges the two ranked lists, usually with reciprocal rank fusion. Keyword search catches exact terms and identifiers; vector search catches meaning and paraphrases.
Why do RRF scores look the same for every query? #
Because RRF scores depend only on rank. With k = 60, rank 1 is always 1/61, about 0.0164. They are useful for ordering one fused list and useless for comparing different queries.
Which embedding model works for searching across languages? #
A multilingual model that maps languages into one space, such as bge-m3. An English-only model cannot match a Romanian query to English text, however good it is in English.
Why is semantic search slow on a home server? #
Usually not because of the vector search, which takes well under a second, but because the query must be embedded on a GPU that other jobs are using. Keep pipelines from pinning models, raise parallel slots, and time out early with a keyword fallback.
The lesson #
Embeddings got the attention, but almost every real problem was plumbing: an analyser mismatch, a timeout in the wrong order, a model nobody unloaded, a highlighting limit. Build the boring checks first, compare token streams, bound every wait, and test at large result sizes, and the clever part will mostly take care of itself.