Files

3.9 KiB

rrf

Several ranked lists into one, without the database.

Part of catena.

What it does

Reciprocal rank fusion. Reads hit records from any number of retrieval channels and writes one ranked list, best first.

When you would use it

  • Hybrid search, without adopting anything. Vector search and full-text search disagree about everything except what matters, and their scores are not comparable — a cosine distance and a text rank are different quantities on different scales. Fusion sidesteps that by throwing the scores away and keeping only the ranks: a result both channels put near the top beats one that either channel loves alone.
  • Making a metadata match count for more. When somebody searches for a book by its title, the book rarely repeats its own title in its body, so the channel that matched the title is the one telling the truth. That is what the weights are for.
  • Merging results across corpora, or across shards, or across any two systems that both return ranked lists.

This is the one tool here worth calling in process rather than as a subprocess: by the time a hit reaches it the data is already yours, so the isolation a separate process buys is not worth a spawn on an interactive search path.

Install

cargo install --git https://git.pumpin.online/funkpower/catena rrf

Example

Three channels, overlapping:

$ cat vector.ndjson fts.ndjson meta.ndjson | rrf
{"v":1,"id":"e901","channel":"meta","rank":1,"score":0.040719196192490745,"payload":{"title":"The Human Condition"}}
{"v":1,"id":"c81a","channel":"vec","rank":2,"score":0.032266458495966696,"payload":{}}
{"v":1,"id":"b455","channel":"fts","rank":3,"score":0.032266458495966696,"payload":{}}
{"v":1,"id":"7f20","channel":"vec","rank":4,"score":0.016129032258064516,"payload":{}}

e901 was third in full-text search and first in metadata, and the metadata weight carries it to the top. c81a and b455 tie, and the tie is broken by which was seen first, so the same input always gives the same output.

The top ten:

cat *.ndjson | rrf --limit 10

Trusting full-text matching more than usual:

cat *.ndjson | rrf --weight fts=2 --weight meta=1

Options

Option Default Effect
-n, --limit <COUNT> all How many results to keep
--rrf-k <K> 60 The rank constant
--weight <CHANNEL=WEIGHT> vec=1 fts=1 meta=1.5 What a channel's rank is worth. Repeatable
--man Write this page as a manual page, in roff

The rank constant flattens the difference between rank one and rank two. Sixty is what the method's own paper uses and what every other implementation uses, which makes fused scores comparable across systems — worth more than tuning it.

Exit codes

Code Meaning
0 Done
64 The command line was wrong
65 A hit could not be read
74 The input could not be read

Input and output

One hit per line, in any order and interleaved if you like — each carries the rank its own channel gave it, so the stream does not have to be grouped.

The record that comes back for an id is the one that ranked it best, carrying its payload, with rank and score replaced by its place and score in the fused list. That makes channel on the output informative: it names which channel liked this result most.

The arithmetic

Each hit contributes weight / (k + rank) to its id's score. Nothing else is used — not the channel's own score, which is exactly what is not comparable.

As a library

let fused = rrf::fuse(&hits, &rrf::Weights::default());

See also

  • emb — producing the vectors one of the channels searches.
  • The records — the shape this reads and writes.