---
title: RRF arms
description: The ranking arms, their weights, and what each one needs before it can fire.
---

## 1. How the arms combine

[`memhtml search`](/reference/commands/search/) runs 4 separate rankings over the corpus, then merges them by reciprocal rank fusion: each arm contributes a score that falls off with the position it gave a memory, the scores are summed, and the sums decide the final order. A memory that two arms both place near the top therefore outranks one that a single arm placed first.

An arm whose precondition is missing drops out before the SQL is assembled, so a search with no query vector comes back ranked by the arms that could still fire. The last three columns are those preconditions.

| Arm | Weight | Needs the query vector | Needs the state plane | Needs query terms |
| --- | --- | --- | --- | --- |
| `fts` | `1.0` | no | no | yes |
| `vector` | `1.0` | yes | no | no |
| `recency` | `0.5` | no | no | no |
| `salience` | `0.4` | no | yes | no |

## 2. Why the arms are data

The four-arm RRF assembler. Arms are data, a registry folded over by `buildRrfSql`, so
adding a fifth arm is a table entry rather than a new query, and dropping one is a filter.

The parameter tuple is fixed at four positions and the SQL uses NUMBERED placeholders, which is
what makes degradation to the lexical floor free. An arm needing the query vector is dropped
before assembly, `?4` then appears nowhere in the statement, and `?1`-`?3` keep their meaning so
the caller binds the same prefix either way. With positional `?` the numbering would shift and
every remaining arm would silently read the wrong parameter.

```
?1 query text   ?2 per-arm candidate limit   ?3 final limit   ?4 query vector (float32 blob)
```

Weights are inlined as numeric literals rather than bound. They come from trusted configuration,
not from a caller, and inlining keeps the tuple stable at four regardless of how many arms fire.

## 3. The arms

One subsection per arm, quoting what the registry says about it.

### 3.1. fts

Weight `1.0`.

Lexical, ranked by `bm25()`, a real term-frequency/inverse-document-frequency score rather than
whatever order the index happens to return rows in.

FTS5 reports bm25 as a NEGATIVE number where more negative is more relevant, so `ORDER BY bm25`
ascending puts the best match first. Getting that sign backwards would invert the whole arm while
still producing a plausible ranked list, which is why the discrimination gate (every probe must
outrank its own wrong-fact twins) is the test that matters here.

The `ORDER BY` sits INSIDE the limited subquery so the LIMIT keeps the most relevant candidates,
and `ROW_NUMBER()` sits outside it so the fused rank numbers the survivors rather than the
pre-limit scan.

`?1` is one of the two MATCH forms `ftsQueryForms` builds (`fts-query.ts`): the query's sanitized
terms joined with `AND`, or joined with `OR`, with a double-quoted span kept as one phrase in
either. The caller binds the all-terms form when `buildFtsProbeSql` finds a file in scope
holding every term, and the any-of form otherwise. A sentence that is not a verbatim quote of stored
text satisfies no all-terms MATCH, so the any-of form is what lets one proper noun in it find its
file (issue #143); the all-terms form is kept where it can answer because RRF's `1/(rank + 60)` is
nearly flat across this arm's 40 candidates, and 40 any-of candidates let recency and salience
outvote a lexical lead that 3 all-terms candidates would have kept (corpus MRR 1.0 against 0.28 on
the gate's fixture). Whichever form is bound, the bm25 order the fold sees is that form's own.

The join is on `rowid`. `files_fts` is external-content over `files`, so it stores no copy of the
row and the rowid is the only handle back to the path.

### 3.2. vector

Weight `1.0`.

Semantic. Exact brute force over the whole `embeddings` table, so 2000 files × 1024 dims, top 40,
measured 27 ms (probed 2026-08-02). An approximate index buys nothing at this scale.

`GROUP BY c.path` with `min(distance)` collapses a file to its single best chunk. Without it a
three-chunk file contributes three ranks, consumes three slots of the arm's candidate budget, and
has three reciprocal-rank contributions summed into its fused score, so being long would
outrank being relevant.

### 3.3. recency

Weight `0.5`.

Recency by EVENT time, falling back to write time. `coalesce(event_at, updated_at)` is what makes
an episodic memory about last month's incident sort by when the incident happened rather than by
when someone got around to writing it down.

### 3.4. salience

Weight `0.4`.

Salience over the durable state plane, read in the same statement as `main.files` through the
ATTACH. Three terms, each unitless and each verified present on this driver:

* `exp(-0.01 * hours_since_access)`: a decaying recency-of-use signal.
* `ln(1 + access_count)`: diminishing returns on raw popularity.
* `max(outcome_score, 0.0)`: the negative-outcome clamp. A memory whose reinforcements were
  negative gets no boost, and takes no penalty either. The retention scorer owns punishment, and
  double-counting it here would let one bad outcome bury a memory that is still the best answer.

**Two exclusions LOCAL to this arm, and the locality is the point.** Salience belongs to ranked
fusion over interchangeable candidates, while a task and a person-reference record are reached by
predicate and by key. The shared `fileFilter` reaches every arm and must NOT carry these. An
excluded row still earns its FTS, vector, and recency ranks, and only its salience contribution
disappears. Written as inline literals rather than bound values, following the
`EXCLUDED_BY_DEFAULT` precedent (`scope.ts:121`). They are this arm's own rule and not caller
input, and binding them would consume placeholder numbers the `?5`-upward scope contract owns.

The mechanism is that the CTE emits no row for an excluded path at all. The decay term reads
`coalesce(a.last_accessed_at, f.updated_at)`, so leaving the row in with a zeroed access count would
still rank it by write time, which is the recency arm's job, counted twice.

## 4. Provenance

A loader generates this page from `packages/index/src/retrieval-sql.ts` while the site builds, so no file in the repository holds it: the arms are a registry the SQL assembler folds over. Change the registry and this page changes with it.