Blog·Learning

Hybrid search explained: BM25, vector search and rank fusion

Hybrid search runs a keyword search and a vector search on the same query and merges the two result lists, usually with reciprocal rank fusion. How each side works, a worked example, runnable code and how it applies to agent memory.

By Shardul Mane·13 min read

A blurred, grainy photo of dark green leaves against a blue sky, with the supermemory logo and the headline When to combine keyword and vector search.

Hybrid search is a retrieval method that runs a keyword search (usually BM25) and a vector search on the same query, then merges the two ranked lists into one, most often with reciprocal rank fusion (RRF). You use it because each side finds what the other misses: keyword search catches exact terms like error codes and product IDs, and vector search catches the same meaning in different words.

Below is how each side works, how RRF merges them with a worked example you can check by hand, code you can run, when to weight one side or add a reranker, which databases do it for you, and how it applies to agent memory.

Hybrid search is retrieval that combines two kinds of matching on every query: lexical matching on the words themselves, and semantic matching on what the words mean. Most systems run both searches at the same time, take the top results from each, and fuse them into one ranked list before anything reaches the model.

The two sides fail in opposite ways, which is why the combination works. Here's how that plays out on common questions:

Question Signal likely to help Failure to test
"What does ERR-482 mean?" Exact keyword matching A similar but different code ranks first
"How do I stop invoices renewing?" Vector search The source says "cancel recurring billing"
"Does the enterprise plan support SSO?" Both A consumer-plan passage uses similar language
"Which service depends on the failed database?" Explicit relationships or a structured lookup A passage mentions both but doesn't say one depends on the other

These are hypotheses to test on your own corpus. A good keyword setup can expand synonyms, and a vector model sometimes gets an exact code right.

The word "hybrid" also gets used for other things. Some products call a search over several content types hybrid. In supermemory's API, for example, searchMode: "hybrid" means returning extracted memories and document chunks together (search docs). This post is about keyword plus vector.

It runs both on the same query and merges the results, so it helps to know how each side scores documents and where each has blind spots.

BM25 scores exact terms

BM25 is the standard keyword ranking function, and the default in Elasticsearch. For each query term a document contains, it adds a weight that grows with how often the term appears in that document and how rare the term is across the collection, then adjusts for document length. In Robertson and Zaragoza's formulation (The Probabilistic Relevance Framework: BM25 and Beyond, 2009), a term's weight is:

weight(term, doc) = idf(term) × tf / (tf + k1 × (1 - b + b × doc_length / avg_doc_length))

Two parameters tune it. k1 controls saturation: a term's contribution levels off as it repeats, so the tenth mention adds much less than the first. b controls length normalization: at 1, term counts are fully normalized by document length, and at 0, length is ignored. The paper reports that values such as 0.5 to 0.8 for b and 1.2 to 2 for k1 work reasonably well in many cases, and Lucene defaults to k1 = 1.2 and b = 0.75 (Lucene BM25Similarity).

BM25 is a sparse method: each document is effectively a vector with one dimension per word in the vocabulary, almost all of them zero. That makes it exact. It matches the exact tokens in a string like "ERR-482" (how a code splits into tokens depends on the analyzer), and it can't see that "automobile" and "car" mean the same thing.

Vector search scores meaning

Vector search turns the query and each chunk into embeddings, dense vectors of a few hundred to a few thousand numbers from an embedding model, and ranks chunks by how close their vectors are, usually by cosine similarity. "How do I stop feeling overwhelmed at work" can find a passage titled "stress management strategies" with no word in common. The vector search guide covers indexes and tradeoffs.

Where each one fails

  • BM25 misses synonyms and paraphrases. "Cancel recurring billing" scores zero for "stop invoices renewing" unless some word overlaps.
  • Vector search blurs identifiers. "PROD-SKU-7842X" and "PROD-SKU-7842Y" look almost the same to an embedding model, so the wrong one can rank first with high similarity.
  • Both can return stale or unauthorized content. Neither knows which version is current or who may see it. That's for filters, covered below.

Users send both kinds of query, often in the same session, so a retrieval layer that only does one will keep failing on the other.

What is reciprocal rank fusion (RRF)?

Reciprocal rank fusion merges ranked lists using only each document's position in each list, not its score. Each document gets 1 / (k + rank) from every list it appears in, and the totals decide the final order:

RRF_score(doc) = sum over each ranked list of 1 / (k + rank of doc in that list)

RRF comes from a short paper by Cormack, Clarke and Büttcher at SIGIR 2009, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. They set k = 60 during a pilot study and didn't change it afterwards. In that pilot, 60 was near-optimal, but the choice wasn't critical. The constant keeps one list's top results from dominating: with k = 60, rank 1 is worth 1/61 and rank 10 is worth 1/70, so a document that shows up in both lists usually beats one that tops only one. Across their pilot and TREC experiments, RRF beat Condorcet fusion, CombMNZ and the best single system by 4% to 5% on average.

Ranks work where scores don't. BM25 scores depend on the query and the collection and have no fixed range, while cosine similarities fall between -1 and 1. Adding the raw numbers lets whichever side produces bigger numbers win by default. RRF avoids normalizing scores at all.

It isn't always the best fusion, though. In An Analysis of Fusion Functions for Hybrid Retrieval (Bruch, Gai and Ingber), a weighted sum of normalized scores (a convex combination) beat RRF both in and out of domain, RRF turned out to be sensitive to its parameters, and the weighted sum needed only a small labeled set to tune. RRF is the safe default when you have no labels. If you have a labeled set, even a small one, test a tuned weighted sum against it.

A worked example

Take five help-center articles and the query "payment failed with ERR-482".

ID Article
kb-12 Error ERR-482: the card issuer declined the payment. Ask the customer to retry or use another card.
kb-07 Why payments fail, and how to retry a declined charge.
kb-31 Error ERR-481: the checkout session expired before payment.
kb-19 How to update billing details and change the card on file.
kb-02 Refunds reach the customer's card in 5 to 10 business days.

BM25 (the code below, with Lucene's default parameters) returns two articles: kb-12, which contains both "err-482" and "payment", and kb-31, which contains only "payment". Their scores are 0.86 and 0.46, numbers that only mean something next to each other.

Now say the vector search returns kb-07, kb-12 and kb-19, in that order: a general guide to failed payments is closest in meaning, followed by the exact error article. With k = 60:

Article BM25 rank Vector rank RRF score
kb-12 1 2 1/61 + 1/62 = 0.032522
kb-07 Not returned 1 1/61 = 0.016393
kb-31 2 Not returned 1/62 = 0.016129
kb-19 Not returned 3 1/63 = 0.015873

kb-12, the article that actually answers the question, comes out on top because both searches found it. Vector search alone would have put a general guide first, and BM25 alone returns only articles that share exact words with the query, including one about a different error code, and misses the general guide entirely.

Run both searches, then fuse the two lists. This version runs as is with Python's standard library. The BM25 class follows the formula above with Lucene's idf, and the vector ranking comes from whatever vector store you use:

import math
import re
from collections import Counter


def tokenize(text):
    # lowercase words; keeps codes like "err-482" as one token
    return re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())


class BM25:
    """BM25 as in Robertson and Zaragoza (2009), with Lucene's idf. k1 and b are Lucene's defaults."""

    def __init__(self, docs, k1=1.2, b=0.75):
        self.k1, self.b = k1, b
        self.tf = {doc_id: Counter(tokenize(text)) for doc_id, text in docs.items()}
        self.length = {doc_id: sum(tf.values()) for doc_id, tf in self.tf.items()}
        self.avg_length = sum(self.length.values()) / len(docs)
        df = Counter(term for tf in self.tf.values() for term in tf)
        n = len(docs)
        self.idf = {term: math.log(1 + (n - d + 0.5) / (d + 0.5)) for term, d in df.items()}

    def rank(self, query, top_k=10):
        scores = {}
        for doc_id, tf in self.tf.items():
            norm = self.k1 * (1 - self.b + self.b * self.length[doc_id] / self.avg_length)
            score = sum(self.idf[t] * tf[t] / (tf[t] + norm) for t in tokenize(query) if t in tf)
            if score > 0:
                scores[doc_id] = score
        return sorted(scores, key=lambda d: -scores[d])[:top_k]


def rrf(rankings, k=60, weights=None):
    """Reciprocal rank fusion (Cormack, Clarke and Buettcher, 2009): sum 1 / (k + rank) over each list."""
    weights = weights or [1.0] * len(rankings)
    scores = {}
    for ranking, weight in zip(rankings, weights):
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + weight / (k + rank)
    return sorted(scores.items(), key=lambda item: -item[1])


docs = {
    "kb-12": "Error ERR-482: the card issuer declined the payment. Ask the customer to retry or use another card.",
    "kb-07": "Why payments fail, and how to retry a declined charge.",
    "kb-31": "Error ERR-481: the checkout session expired before payment.",
    "kb-19": "How to update billing details and change the card on file.",
    "kb-02": "Refunds reach the customer's card in 5 to 10 business days.",
}
query = "payment failed with ERR-482"

keyword = BM25(docs).rank(query)
vector = ["kb-07", "kb-12", "kb-19"]  # from your vector store, for the same query

print("BM25:  ", keyword)
print("Vector:", vector)
for doc_id, score in rrf([keyword, vector]):
    print(f"{doc_id}  {score:.6f}")

It prints:

BM25:   ['kb-12', 'kb-31']
Vector: ['kb-07', 'kb-12', 'kb-19']
kb-12  0.032522
kb-07  0.016393
kb-31  0.016129
kb-19  0.015873

In production, run both searches in parallel and take a few dozen results from each before fusing (Elasticsearch calls this depth rank_window_size), then tune it on your own queries. In LlamaIndex, QueryFusionRetriever with mode="reciprocal_rerank" and num_queries=1 does the same fusion over a vector retriever and a BM25 retriever (at its default of 4, it also has an LLM write extra versions of the query) (LlamaIndex example).

You don't need two systems. All of these run both searches in one database, and all but Postgres have fusion built in (checked against each project's docs on October 16, 2026):

Database How you run it Fusion
Elasticsearch An rrf retriever over a BM25 retriever and a kNN retriever RRF, rank_constant defaults to 60
OpenSearch A hybrid query (added in 2.11) plus a search pipeline Score normalization and combination, or RRF through the score ranker processor
Weaviate A hybrid query with an alpha weight (0.75 by default) Relative score fusion by default since v1.24, or ranked fusion
Qdrant Sparse and dense prefetch queries under one query RRF with k set to 2 by default and configurable since v1.16, or distribution-based score fusion
Milvus hybrid_search over several vector fields RRF ranker with k 60 by default, or a weighted ranker
Postgres with pgvector Postgres full-text search next to a vector query No built-in fusion: write RRF in one SQL query or in your code, or rerank with a cross-encoder

Watch the defaults. A k of 2 in Qdrant weighs top ranks far more heavily than the 60 in Elasticsearch and Milvus, and Weaviate's default fuses normalized scores rather than ranks, so the same two lists can come out in a different order.

The cost is mostly storage and upkeep: you keep an inverted index and a vector index over the same content, and both need updating when a document changes. Batch the embedding work at ingestion rather than per request, and cache query embeddings for repeated queries.

When to weight one side

A 50/50 blend is a starting point. Some query types need one side to lead:

  • Exact lookups (error codes, SKUs, version strings, ticket IDs): lean on keyword matching.
  • Conceptual questions ("why does my auth flow break under load"): lean on vectors.
  • Mixed queries ("OAuth error OA-403 in production"): keep both.

A cheap router can make the call before either search runs. A regular expression catches identifier patterns and raises the keyword list's weight, and everything else gets equal weights. With the rrf function above, that's rrf([keyword, vector], weights=[2, 1]) for an identifier-heavy query. Weaviate's alpha and Qdrant's weighted RRF (from v1.17) do the same inside the database. Tune the weights per query type on held-out queries rather than by feel, and if one class of query is the only one that benefits, consider routing just that class to hybrid.

Add reranking after fusion

Fusion decides which candidates make the shortlist. A reranker then reads the query and each candidate together and re-scores the top of the fused list, which catches relevance that two separate scores miss.

Two kinds are common. A cross-encoder scores each query and document pair in one pass of a model, which is accurate but slower per candidate. ColBERT (Khattab and Zaharia, SIGIR 2020) encodes the query and documents separately and then compares every query token with every document token, a "late interaction" that its authors report runs two orders of magnitude faster than BERT-based rankers, with four orders of magnitude fewer FLOPs per query.

A reranker can't recover a passage that neither search returned, so check candidate recall first. It also adds latency and cost, and if it's a hosted service, it's one more place your candidate text goes. Add it when the right passages show up in the candidates but rank too low, and measure it on the same queries. The contextual reranking guide shows how to test that stage on its own.

Apply permissions to every retrieval path

If the keyword side respects the tenant filter and the vector side doesn't, the fused list can leak data. Apply the same authorized scope to both searches, to the reranker's input, to caches and to any step that expands a chunk into its parent document. Removing a citation afterwards isn't enough: an inaccessible document shouldn't reach the model's prompt at all. The isolation checklist walks through testing the full path.

Version filters matter too. A stale policy that matches the exact words can outrank the current one, so keep old versions only when the question allows history, and label them.

Hybrid search for agent memory

Memory queries have the same mix as document search. People ask about exact things (a ticket number, a person's name, an error code) and about fuzzy things ("what did we decide about pricing?"), so the keyword-plus-vector logic carries over. Memory adds two problems retrieval alone doesn't solve: knowing which fact is current, and keeping each user's memories separate.

In supermemory, one search call covers the content side. searchMode: "hybrid" (the default) returns extracted memories and raw document chunks together, results carry an isLatest flag so an updated fact wins over the old one, and rerank: "order" re-scores the results (search docs):

const results = await supermemory.search("user_123", {
  query: "ERR-482 on my last payment",
  searchMode: "hybrid", // memories and document chunks in one call
  rerank: "order", // re-score for relevance
  limit: 5,
});

The search docs note that reranking adds some latency, and a successful rerank can bill one extra operation. Operation prices are on the pricing page. On the self-hosted server, keyword matching backs up the vectors: its docs note that with an English embedding model on, say, German text, dense recall can fail while hybrid keyword search still finds rare tokens (self-hosting docs). For a chunks-only setup, SuperRAG indexes documents without extracting memories.

How do you know hybrid search is helping?

Label a sample of real queries by type (exact identifiers, paraphrases, mixed, and no-answer cases) with the chunks that answer them. Compare keyword-only, vector-only and fused results before adding a reranker, with filters, corpus version and answer settings held fixed.

Track whether the supporting chunk lands in the top results and how high it ranks, plus duplicate context, unsupported answers and time spent in each stage. Report results by query type, since an average can hide the exact-ID failures your support team hits most. If fusion brings no clear gain on your workload, keep the simpler path.

Common questions

Hybrid search or vector search: which should I use?

If your users ever search for exact strings (codes, names, IDs, version numbers), start with hybrid, since vector search alone tends to blur them. If every query is conversational and your corpus has few identifiers, vector search may be enough. Test both on labeled queries from your own logs.

What k should I use for reciprocal rank fusion?

Start with 60, the value from the original paper and the default in Elasticsearch and Milvus. Lower values give top ranks more weight. If you have labeled queries, tune k, or test a weighted score fusion against it.

Is hybrid search the same as reranking?

No. Hybrid search merges two independent searches to widen the candidate pool. Reranking re-scores a candidate list with a slower, more careful model. Many pipelines use both: fuse first, then rerank the top.

No. Elasticsearch, OpenSearch, Weaviate, Qdrant and Milvus all fuse keyword or sparse results with vector results in one query, and Postgres can do it with full-text search and pgvector.

  1. How AI memory actually works. A beginner's guideHow AI apps remember things between chats, why a bigger context window doesn't solve it, and the parts every memory system shares.
  2. We're open sourcing the company brain. Here's how we designed the multiplayer harnessCompany Brain is now open source. A walkthrough of the multiplayer harness behind its Slack experience, from proactivity and memory boundaries to approvals and recovery.
  3. Jev changes a lot in memory & context engineering. Here's exactly how.We tested Jev across reranking, chunking, observation, and harness decisions. Here is where fast decision models help memory systems, where they cost more, and where they still fall short.
  4. I reverse-engineered Instinct's memory. Here's exactly how it worksInstinct keeps its memory as git-tracked markdown files, found with grep rather than vectors. Here is the whole system as far as black-box probing can reconstruct it, and how to rebuild it on supermemory in about 60 lines.
  5. An update to supermemoryWe've discontinued the supermemory company brain and Nova. Everyone who was charged has been refunded, our MCP and plugins continue to run, and we're going all in on the memory engine.
  6. Scaling Conversations: How Adapta Grew Usage Without Losing ContextAdapta added Supermemory as a persistent memory layer so every conversation keeps its context — letting the team scale usage without losing the thread.
  7. How Chatarmin Ditched RAG and Went Memory-Only with SupermemoryChatarmin replaced a heavy RAG pipeline with Supermemory's memory layer — cutting average AI response time from 40s to 12s and token usage by 40–50%.
  8. SMFS: making agentic retrieval 55% cheaper AND more accurateWe launched SMFS.ai (Supermemory Filesystem) a few weeks ago, with a simple bet: We can redesign the filesystem specifically for agents, with special files, structures, and commands that it can use for it's tasks. Today, SMFS is used by hundreds of companies to power their agents.
  9. Introducing Dynamic Dreaming: supermemory now connects the dots, for you.Dreaming is magical. TLDR: We're launching Dynamic Dreaming in supermemory today, which automatically works if you're using supermemory in any way - API, OpenClaw, Hermes agent, etc.
  10. Dear reader, we just made supermemory insanely cheap... the Context CloudWhen I first started building supermemory, I had one goal: To build the best memory system for AI. I would talk to customers, and find out that memory was not the only thing they needed - They were all setting up 7-8 different vendors at the same time.
  11. Introducing @supermemory/tools v2.0.0Today we're releasing v2.0.0. This release unifies the API across all agents sdk integrations from AI SDK to Mastra, makes conversation identity a first-class concept, and ships with memory saving on by default.
  12. Solving the Precision-Recall Tradeoff: Search Result AggregationWhen you're building memory for AI, search is your foundational layer. The way search generally works is straightforward: the user defines a query, and then sets a limit (top-K) on how many search results they want returned. Usually, this is set to 10 or 20.
  13. OpenClaw Memory Problems: Why It Forgets and How to Fix It (2026)OpenClaw remembers by writing Markdown files in its workspace, so it forgets anything the agent didn't save. Why that breaks down, how the supermemory plugin recalls and saves on every turn instead, and how to install it, free on every plan.
  14. Stateful Coding Agents with Memory: Build Long-Running Agents (2026)We built a plugin for Claude Code and OpenCode that gives your coding agent persistent memory. It remembers your preferences, learns your codebase, and never loses context mid-conversation. The result is an agent you can run for months without starting over.
  15. Clawd / Molt bot's memory SUCKS. We gave it supermemory.I'm the founder of supermemory. Clawd/Molt bot is blowing up right now, with many, many use cases. I set it up, too, and have been using it through telegram. TLDR: just go to https://supermemory.ai/docs/integrations/clawdbot to set up supermemory for your clawd bot.
  16. Catch up with our UNFORGETTABLE Launch WeekOver the last year, one belief has guided almost everything we’ve built at Supermemory AI becomes meaningfully useful only when it remembers. Memory shouldn’t be something developers rebuild from scratch. It shouldn’t be fragile, expensive, or trapped inside a single tool.
  17. Empowering the Next Generation of Founders: Supermemory Startup ProgramIf there’s one thing we’ve learned while building Supermemory, it’s that most startups don’t fail because they didn't build features; they fail when infrastructure slows them down, or they built too slow.
  18. Building code-chunk: AST Aware Code ChunkingAt Supermemory, we're building context engineering infrastructure for AI. A huge part of that is dealing with code: ingesting repos, understanding structure, and making it searchable. The problem is that most code chunking solutions are terrible. We built code-chunk to fix this.
  19. Supermemory raises $3 million with the best memory engine for LLMsToday, I am excited to announce our first funding round to accelerate our mission of building an interoperable, scalable and reliable memory for LLMs and agents. Memory is one of the hardest challenges in AI right now.
  20. Mem0 vs Supermemory: Why Scira SwitchedScira AI moved its production memory layer from Mem0 to Supermemory. This is what failed, what improved, and how the team evaluated the two systems.
  21. Never Record Again: How Montra Uses Supermemory to Rethink Video CreationCampbell Baron, the founder of Montra, has been making videos since he was twelve. By thirteen, he was already doing brand work. Today, he’s betting on a very different future for creators: a world where recording is the exception, and most videos are generated from scratch.
  22. Unified Memory That Works Where You Work: Your Second Brain With SupermemoryHi everyone, I’m Dhravya, the founder of Supermemory. I want to start with a little story behind why this product means so much to me. You can also skip straight to what it is and how it works below.
  23. Supermemory just got faster on PlanetScaleWhat is Supermemory? Supermemory completes the missing part of the LLM puzzle: memory. Just as memory is crucial for human intelligence, it's essential for truly intelligent AI systems.
  24. Faster, smarter, reliable infinite chat: Supermemory IS context engineering.People are obsessed with prompts and prompt engineering. Sure, what you say is important, but what the model knows when you say it is the difference between a stateless text generator and an intelligent AI system. In short, context is the most crucial component.
  25. We solved AI API interoperabilityOne API to rule them all, One spec to find them, One library to bring them all and in the TypeScript, bind them. When we were building the Infinite Chat API, initially, we only supported the OpenAI format. This was fine, until a lot of our customers started asking for more.
  26. The Wow Factor of Memory - How Flow Used Supermemory To Build Smarter, Stickier ProductsOverview: Flow is a note-taking app built around a bold vision: to create a more personal, context-aware writing experience powered by AI. At the heart of this mission is memory.
  27. The UX and technicalities of awesome MCPsLast month, we launched the Supermemory MCP, mostly to test our own infrastructure and get some initial traction. It blew up. To my absolute surprise, the initial launch itself got half a million impressions (!!!). Then, we launched and got #2 on ProductHunt too.
  28. Architecting a memory engine inspired by the human brainLanguage is at the heart of intelligence, but what truly powers meaningful interaction is memory — the ability to accumulate, recall, and contextualize information over time. Large Language Models (LLMs) have mastered language, but memory remains their Achilles’ heel.
YOUR PRIVACY

Change or withdraw any time via Cookie settings in the footer. Read our cookie notice.