Architecture

When Meaning Is Not Enough - Hybrid Search with BM25 and RRF

July 9, 2026

For a long time, search in Iris meant one thing: meaning-based search. An embedding model turns a piece of text into a vector, a long list of numbers, placed so that text with similar meaning sits close together. You compare two of them by the angle between them (cosine similarity): point the same way and it is a strong match, point apart and they are unrelated. It cares about direction, not length, which is handy when a two-line note and a two-page section can be about the same thing. So the whole move is: embed the query, embed the stored passages, and pull back the ones pointing the same way. It is great, most of the time. Ask "what did I say about my sleep" and it finds the memory about going to bed earlier even though I never used the word "sleep" in it. That is the magic of embeddings, and also the root of the problem.

But embeddings have a blind spot, and it took me a while to admit it.

The blind spot

Meaning-based search smooths everything into a general sense of a topic. That is exactly what you want for fuzzy questions. It is exactly what you do not want for exact ones.

A person's name. An order number. A postcode like SW1A 1AA. A rare acronym. The embedding does not really care about the literal characters, it cares about the gist, so a passage containing the exact ID you asked for might not float to the top, while three vaguely-related passages do. The one thing keyword search has always been good at, matching the actual words, is the one thing embeddings quietly give up.

I noticed it most with documents. I would ask about a specific term I knew was in a file, and get back passages that were "about the same area" but not the line I wanted.

The fix is old and boring: keyword search

The answer is not to replace meaning-based search. It is to run it alongside good old keyword search and combine the two.

Keyword ranking has a well-worn formula called BM25. It rewards your words showing up, but with some sense to it: the tenth time a word appears counts far less than the first, a rare word like a surname counts for more than a common one like "the", and a long passage does not get to win just for having more room to contain your words. Think of it as a much better version of Ctrl-F, one that knows the rare words are the ones that matter.

The nice part is I did not need any new infrastructure for it. Postgres has full-text search built in. I added a generated tsvector column (Postgres boils each passage down to its root words and drops the filler like "the" and "of"), a GIN index so lookups stay fast as the table grows, and ts_rank to do the scoring. No Elasticsearch, no second service to run and keep in sync. It lives right next to the vectors, in the same database, maintained by Postgres itself. Simple until it needs to not be.

Now every search runs twice: once by meaning, once by exact words. Which leaves one problem.

You cannot just add the scores

Meaning-based search gives you a similarity between 0 and 1, where higher is better: 1 means the query and the passage point in the same direction (a strong match) and 0 means they are unrelated. BM25 gives you a score that starts at 0 and climbs with no upper limit, where again higher is better but the numbers live on a completely different scale. Adding them together is nonsense, like adding a temperature in Celsius to a distance in miles. And if you try to normalise them and pick a weight, you end up hand-tuning a number that drifts every time the data changes. I did not want to babysit a magic weight.

Reciprocal Rank Fusion

The trick I landed on is Reciprocal Rank Fusion, or RRF. It throws away the raw scores entirely and uses only the position each result landed in each list. The whole shape looks like this: the question fans out to both searches, each produces its own ranked list, and the two lists get merged back into one.

                     your question
                          │
           ┌──────────────┴──────────────┐
           ▼                             ▼
    vector search                 keyword search
    (by meaning)                  (BM25 / full-text)
           │                             │
           ▼                             ▼
    ranked list A                 ranked list B
           └──────────────┬──────────────┘
                          ▼
          Reciprocal Rank Fusion   1 / (k + rank)
                          │
                          ▼
                 one merged ranking

Every result gets a score of 1 / (k + rank) in each list, summed across the lists it appears in. With the usual k of 60, first place is worth 1/61, second 1/62, third 1/63: a lot at the top, almost nothing down in the tail. That is the point, because the gap between first and second matters far more than the gap between fiftieth and fifty-first. A passage both searches rank near the top collects two big contributions and wins. A passage only one search liked, buried low, barely registers.

The analogy that made it click for me: two scouts search the same library, one looking for the right meaning and one looking for the exact words. You do not care how confident each scout says they are, because they are measuring different things. You care about the books that both of them put near the top of their pile. That is what RRF surfaces.

Why this over the obvious alternative, normalising both scores and blending them with a weight? Because that weight is a trap. You have to tune it, and it keeps drifting: the range of keyword scores shifts as the collection grows, so the number you picked last month is quietly wrong this month. RRF has no weight to get wrong and nothing to re-tune when the content changes. The one thing you give up is the sense of how much better the top result was than the second, only that it was better. For search, that is a trade I will take every time.

Before and after

I do not have a full evaluation harness yet, so I built a small probe to sanity-check the idea. Ten questions with a known right answer, half of them "what did I mean" questions and half "find this exact thing" (a name, an invoice number, a postcode). For each, I checked one simple thing: did the correct passage come back as the top result?

A few of the actual questions, to make it concrete:

  • Meaning: "what helps me rest at night" should find a note that reads "I sleep better when I stop screens an hour before bed", which shares not one word with the question.
  • Meaning: "where did I put money away tax free" should find the line about moving savings into an ISA.
  • Exact: "INV-2291" should find the single passage with that invoice number, and nothing else.
  • Exact: "patel" should find "Dr Patel said my vitamin D was low", not the three other health notes sitting near it.

The meaning questions are where embeddings shine and keyword search flails. The exact ones are the exact reverse. Here is how each approach did:

Query type Vector only Keyword only Hybrid
Meaning (5) 3 0 4
Exact term (5) 1 5 5
Top result correct (of 10) 4 5 9

The same numbers as bars:

top result correct, out of 10

vector only   ████░░░░░░  4
keyword only  █████░░░░░  5
hybrid        █████████░  9

The shape is the whole argument. Vector search is strong on meaning and nearly blind on exact tokens (its one hit there was a lucky tie, not skill). Keyword search is the mirror image, sharp on the exact stuff and weak on meaning. Neither cracks past five on its own. Fused, they cover for each other and land nine. It is a toy set, not a benchmark, and I am not going to pretend otherwise, but it shows the effect cleanly and it matches how it feels to use.

I wired this into a small repeatable command so I can run it again whenever I change something, rather than eyeballing results by hand. It prints exactly this table, can show where the right passage landed for every single question, and can be pointed at a file of my own questions to score those instead. Cheap to run, and it keeps me honest.

Where it runs

This hybrid search now backs both memory retrieval and document search. It is a Postgres feature, so on anything else it falls back cleanly to meaning-based search alone, and it is behind a config switch (on by default) so I can turn it off if I ever need to. Before shipping I even ran the keyword query against a real Postgres to watch it rank a set of passages correctly, because I did not want to trust it on faith.

The honest bit

My skill graph already had a version of this, sort of. It blended a meaning score with a "lexical" score, but the lexical side was hand-rolled term matching I wrote quickly to get something working. It was fine for one user, which is me, but it was not real keyword ranking. Replacing it with actual BM25 is one of those quiet upgrades that does not look like much in a screenshot but makes the thing meaningfully better at finding the right passage.

The lesson, if there is one, is that the two kinds of search are not rivals. Meaning search knows what you meant. Keyword search knows what you said. Most of the time you want both, and the interesting engineering is in how you combine them without inventing a fragile magic number to do it.