Skip to content

Blog

Build local hybrid search with SQLite FTS5, EmbeddingGemma 2 and Python

By Saif Qureshi33 min read
  • Embeddings
  • Semantic search
  • Local AI
  • RAG

This guide builds hybrid search in Python on your own files. SQLite FTS5 handles keywords, EmbeddingGemma 2 vectors handle meaning, weighted reciprocal rank fusion merges the two lists, a gated reranker re-reads near-ties, and MRR and hit@k tell you whether any of it helped. It follows Finer, my local search engine (source linked under Sources), cut down to a few hundred lines.

I do not repeat the reasoning here. For why each choice was made, with the measurements, read EmbeddingGemma 2 local search on a Mac. The snippets that do not load a model run against an in-memory SQLite database with NumPy installed, and the numbers quoted below are what two of them print. Blocks marked "Adapt to your runtime" download and load a model, so that part is yours to adapt.

Key takeaways

  • Merge ranked lists with weighted reciprocal rank fusion, k = 60. It needs no score normalisation, because only ranks count, so bm25 values and cosines combine directly.
  • Use EmbeddingGemma 2's task prefixes exactly: "task: search result | query: " for queries and "title: {title} | text: {content}" for chunks. Run the model in bfloat16 or float32, never float16.
  • A 1-bit index with a shortlist 16 times larger than k and an exact re-score is a few lines of NumPy. Measure its top-10 overlap with an exact scan on your own vectors before you trust it.
  • Run a reranker only when the top two scores are close, and fuse by rank, not by its probability.
  • Write about 30 questions with expected files, score with MRR and hit@k, and accept a tuning change only when it passes a guardrail.

What you will build

Nine steps, in the order Finer does them. Each snippet adds to one file, hybrid.py, and reuses the names and imports of the steps before it. You need Python 3.10 or newer, NumPy and an SQLite with FTS5. The embedding model and the reranker download from Hugging Face, and the official embedding weights are about 1.5 GB.

Left out on purpose: OCR, audio and video, the English to German bridge, favourites and query parsing. The pillar post covers them.

  • Extract text and split it into chunks.
  • Put the chunks in an FTS5 table and rank them with BM25.
  • Embed the chunks with EmbeddingGemma 2 and its task prefixes.
  • Store the vectors in SQLite.
  • Build a 1-bit index and re-score its shortlist exactly.
  • Merge the lists with weighted reciprocal rank fusion.
  • Re-rank only the near-ties.
  • Evaluate with a small gold set, MRR and hit@k.
  • Optionally, tune weights behind a guardrail.

Set up SQLite FTS5 and fix "no such module: fts5"

FTS5 ships with most SQLite builds. If CREATE VIRTUAL TABLE fails with no such module: fts5, your Python links an SQLite compiled without it, so use a Python build whose SQLite includes FTS5. The schema in step 2 uses contentless_delete, which needs SQLite 3.43. On an older build, delete the line that holds content='' and contentless_delete=1 from that schema, and the table keeps its own copy of the text. Check your build:

Step 0. Check SQLite and FTS5.
python
import sqlite3

print("SQLite", sqlite3.sqlite_version)            # contentless_delete needs 3.43 or newer
con = sqlite3.connect(":memory:")
con.execute("CREATE VIRTUAL TABLE t USING fts5(x)")   # raises 'no such module: fts5' if missing
print("FTS5 is available")

Extract and chunk your files

Extraction depends on your files. Plain text and Markdown need nothing. For PDFs, Word files and e-mail, use an extractor you trust; the pillar names some options for Linux and Windows, and those are suggestions; Finer uses none of them. The unit you search is a chunk.

Finer chunks by characters: about 1,100 per chunk, texts up to 1,600 characters kept whole, 150 characters of overlap, and structure first where it exists (headings, pages, slides, sheets). Counting characters needs no tokenizer. This is a simplified version.

Step 1. A character chunker with overlap. Runs as written.
python
import re


def chunk_text(text: str, target: int = 1100, whole: int = 1600, overlap: int = 150) -> list[str]:
    """Pack sentences into chunks of about target characters, with a short overlap."""
    text = text.strip()
    if len(text) <= whole:                      # short texts stay in one piece
        return [text] if text else []
    pieces = []
    for block in re.split(r"\n\s*\n", text):    # blank lines first, then sentences
        for sent in re.findall(r"[^.!?:;\n]+[.!?:;\n]*", block):
            sent = sent.strip()
            while len(sent) > target:           # hard-cut very long sentences
                pieces.append(sent[:target])
                sent = sent[target - overlap:]
            if sent:
                pieces.append(sent)
    chunks, cur = [], ""
    for p in pieces:
        if cur and len(cur) + len(p) + 1 > target:
            chunks.append(cur)
            cur = cur[-overlap:] + " " + p      # carry a tail into the next chunk
        else:
            cur = f"{cur} {p}".strip()
    if cur:
        chunks.append(cur)
    return chunks

Create the FTS5 index and rank with BM25

One FTS5 table with three columns: name, heading and text. The name column holds the path words, so a file can be found by its name. Rows are contentless: the text lives in an ordinary items table and FTS5 keeps only the index. The unicode61 tokenizer with remove_diacritics 2 makes "Müller" match "Muller", and prefix='3' adds a prefix index.

The bm25() function returns smaller numbers for better matches, because FTS5 multiplies the score by minus one, so the query sorts ascending. Its arguments are one weight per column: a hit in the name counts four times a hit in the body, a hit in a heading twice. The FTS5 documentation fixes k1 at 1.2 and b at 0.75. The query builder ORs the words, drops a few stop-words and turns words of four letters or more into prefix terms. The stop-word set in the snippet is a stub; use real lists for your languages.

Step 2. Schema, indexing and keyword search. Runs as written.
python
import re
import sqlite3

SCHEMA = """
CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY, path TEXT UNIQUE);
CREATE TABLE IF NOT EXISTS items (
    id INTEGER PRIMARY KEY, file_id INTEGER, heading TEXT, text TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
    name, heading, text,
    content='', contentless_delete=1,           -- needs SQLite 3.43 or newer
    tokenize='unicode61 remove_diacritics 2', prefix='3');
"""

STOP = {"the", "and", "for", "with", "der", "die", "das", "und", "mit", "von", "ein"}


def open_db(path: str = ":memory:") -> sqlite3.Connection:
    con = sqlite3.connect(path)
    con.executescript(SCHEMA)
    return con


def path_words(path: str) -> str:
    """'2024/Mietvertrag_final.pdf' -> '2024 Mietvertrag final pdf'"""
    words = re.sub(r"[_\-./]+", " ", path)
    return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", words).strip()


def add_file(con, path: str, chunks: list[tuple[str, str]]) -> list[int]:
    """chunks = [(heading, text), ...]. Returns the new item ids."""
    file_id = con.execute("INSERT INTO files(path) VALUES (?)", (path,)).lastrowid
    ids = []
    for heading, text in chunks:
        item_id = con.execute(
            "INSERT INTO items(file_id, heading, text) VALUES (?,?,?)",
            (file_id, heading, text)).lastrowid
        con.execute("INSERT INTO fts(rowid, name, heading, text) VALUES (?,?,?,?)",
                    (item_id, path_words(path), heading, text))
        ids.append(item_id)
    return ids


def fts_query(q: str) -> str:
    """OR of the query's words; words of 4+ letters also match as prefixes."""
    terms = [t for t in re.findall(r"\w+", q.lower()) if t not in STOP]
    terms = list(dict.fromkeys(terms))
    return " OR ".join(f'"{t}"*' if len(t) >= 4 and not t.isdigit() else f'"{t}"'
                       for t in terms)


def keyword_search(con, q: str, limit: int = 60) -> list[int]:
    match = fts_query(q)
    if not match:
        return []
    rows = con.execute(                          # bm25 is negative: smaller is better
        "SELECT rowid FROM fts WHERE fts MATCH ? ORDER BY bm25(fts, 4.0, 2.0, 1.0) LIMIT ?",
        (match, limit))
    return [r[0] for r in rows]

Embed with EmbeddingGemma 2 and the right task prefixes

EmbeddingGemma 2 expects short prefixes, and the model card says text inputs should carry them. Queries get "task: search result | query: " in front. Documents get "title: {title} | text: {content}", with "title: none" when there is no title. Images, video and audio take no prefix. Leaving the prefix out still works, but may lower quality. In sentence-transformers, prompt_name="SearchQuery" applies the query prefix. The Document prompt applies "title: none", so titled documents must be formatted by hand, as the next block does.

Two more rules from the card. Run inference in bfloat16 or float32, never float16: the activations exceed float16's range, and you get NaN or silently worse vectors. And keep vectors unit length, because the search code below assumes it.

Loading depends on your platform. The first block uses sentence-transformers and loads only the text encoder through config_kwargs, the setting the model card lists for text-only loading. The second is the Apple silicon route, adapted from the mlx-community model card. Both expose the same embed(texts) function, and both expect the prefix to be on the text already. Embed chunks in length-sorted batches so padding stays small. Finer uses batches of 32, or 16 when the longest prompt in the batch reaches 600 characters.

Step 3a. Adapt to your runtime. sentence-transformers, adapted from the model card. Not run.
python
# Adapt to your runtime. pip install -U sentence-transformers transformers
import numpy as np
import torch
from sentence_transformers import SentenceTransformer

# bfloat16 where the hardware supports it, float32 everywhere else. Never float16.
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float32

model = SentenceTransformer(
    "google/embeddinggemma-2",
    model_kwargs={"torch_dtype": dtype},
    config_kwargs={"vision_config": None, "audio_config": None},   # text only
)


def embed(texts: list[str]) -> np.ndarray:
    """texts already carry their prefix: QUERY + question, or doc_prompt(title, chunk)."""
    vecs = model.encode(texts, batch_size=16, normalize_embeddings=True)
    return np.asarray(vecs, dtype=np.float32)        # (n, 768), unit length
Step 3b. Adapt to your runtime. Apple silicon, adapted from the mlx-community card. Not run.
python
# Adapt to your runtime. Apple Silicon, from the mlx-community model card:
#   pip install "git+https://github.com/Blaizzy/mlx-vlm.git@3d87e88402f307efbf68e568971aa887ee7d9ed0"
#   pip install -U "mlx>=0.32.3" "transformers>=5.18.0"
import mlx.core as mx
import numpy as np
from mlx_vlm.embedding_loader import load_embedding_model
from mlx_vlm.utils import get_model_path
from transformers import AutoTokenizer

path = get_model_path("mlx-community/embeddinggemma-2-8bit")
model = load_embedding_model(path)
tokenizer = AutoTokenizer.from_pretrained(path)


def embed(texts: list[str]) -> np.ndarray:
    enc = tokenizer(texts, padding=True, truncation=True, max_length=2048, return_tensors="np")
    out = model(**{k: mx.array(v) for k, v in enc.items()}).text_embeds
    return np.array(out.astype(mx.float32))          # (n, 768), unit length

Store vectors in SQLite, with or without sqlite-vec

SQLite has no vector type of its own. The simplest store is a BLOB per chunk. Finer keeps two: the exact vector as float16, 1,536 bytes, and a copy that keeps only the sign of each dimension, packed into 96 bytes. Float16 storage is fine even though the model must not run in float16. The cast happens after inference, on unit-length vectors.

A cache table keyed by a hash of the prompt means identical prompts, like copies of a file, reuse their vector instead of calling the model. Finer's README says this takes re-embedding an unchanged folder from 320 seconds to 6, and no script in the repo reproduces that.

Step 4. Prompt format, vector tables and an embedding cache. Runs as written; pass your own embed function.
python
import hashlib

import numpy as np

QUERY = "task: search result | query: "


def doc_prompt(title: str, chunk: str) -> str:
    title = title.replace("|", " ")[:200] or "none"
    return f"title: {title} | text: {chunk}"


VECTOR_SCHEMA = """
CREATE TABLE IF NOT EXISTS vectors (item_id INTEGER PRIMARY KEY, v BLOB);  -- float16, 1536 bytes
CREATE TABLE IF NOT EXISTS vbits   (item_id INTEGER PRIMARY KEY, b BLOB);  -- sign bits, 96 bytes
CREATE TABLE IF NOT EXISTS vcache  (key BLOB PRIMARY KEY, v BLOB);         -- prompt hash -> vector
"""


def embed_cached(con, prompts: list[str], embed) -> np.ndarray:
    """Embed only prompts never seen before. Identical prompts (copies of a file) cost nothing."""
    keys = [hashlib.blake2b(p.encode(), digest_size=16).digest() for p in prompts]
    got = {}
    for k in set(keys):
        row = con.execute("SELECT v FROM vcache WHERE key=?", (k,)).fetchone()
        if row:
            got[k] = np.frombuffer(row[0], np.float16).astype(np.float32)
    todo = {k: p for k, p in zip(keys, prompts) if k not in got}
    if todo:
        vecs = embed(list(todo.values()))
        for k, v in zip(todo, vecs):
            v16 = (v / np.linalg.norm(v)).astype(np.float16)
            con.execute("INSERT OR REPLACE INTO vcache VALUES (?,?)", (k, v16.tobytes()))
            got[k] = v16.astype(np.float32)
    return np.stack([got[k] for k in keys])


def store_vector(con, item_id: int, v: np.ndarray) -> None:
    v = v / np.linalg.norm(v)
    con.execute("INSERT OR REPLACE INTO vectors VALUES (?,?)", (item_id, v.astype(np.float16).tobytes()))
    con.execute("INSERT OR REPLACE INTO vbits VALUES (?,?)", (item_id, np.packbits(v > 0).tobytes()))

Add a 1-bit index and re-score exactly

Scanning every float vector costs more with each chunk you add. The shortcut: compare the sign bits with Hamming distance (XOR, then count the ones), take a shortlist of k × 16 chunks, and re-score only those with exact cosine. The exact re-score restores the order; the 16× shortlist makes sure the true neighbours are in it.

Finer's README claims a 99% identical top-10 against exact search at about 10× the speed. No script in the repo produces that figure, and the README names no corpus. That is why the code below includes overlap_at, which measures what share of the exact top-k the shortcut finds, on your vectors. The check after it builds 30,000 synthetic clustered vectors and prints the overlap at 1×, 4× and 16× oversampling: 0.4, 0.81 and 1.0. On 30,000 purely random vectors, which have no structure to find, 16× gives 0.42. The check can take a minute or so, and the values move with the seed. Synthetic data says nothing about EmbeddingGemma 2, so run overlap_at on your own vectors.

Step 5. Hamming shortlist, exact re-score and an overlap function. Runs as written.
python
import numpy as np

POPCOUNT = np.array([bin(i).count("1") for i in range(256)], dtype=np.uint8)


def load_bits(con):
    """Keep this in memory and reload it when the table changes."""
    rows = con.execute("SELECT item_id, b FROM vbits ORDER BY item_id").fetchall()
    ids = np.array([r[0] for r in rows], dtype=np.int64)
    bits = np.frombuffer(b"".join(r[1] for r in rows), np.uint8).reshape(len(rows), -1)
    return ids, bits


def rescore(con, ids, qv: np.ndarray, k: int):
    """Exact cosine on the float16 vectors. ids=None scans every vector."""
    if ids is None:
        rows = con.execute("SELECT item_id, v FROM vectors").fetchall()
    else:
        marks = ",".join("?" * len(ids))
        rows = con.execute(f"SELECT item_id, v FROM vectors WHERE item_id IN ({marks})",
                           [int(i) for i in ids]).fetchall()
    M = np.stack([np.frombuffer(v, np.float16).astype(np.float32) for _, v in rows])
    sims = (M @ qv) / np.linalg.norm(M, axis=1)
    return [(rows[i][0], float(sims[i])) for i in np.argsort(-sims)[:k]]


def vector_search(con, index, qv: np.ndarray, k: int = 60, oversample: int = 16):
    """1-bit Hamming shortlist of k * oversample items, then exact re-scoring."""
    ids, bits = index
    if len(ids) == 0:
        return []
    dist = POPCOUNT[bits ^ np.packbits(qv > 0)].sum(axis=1, dtype=np.int32)
    n = min(len(ids), k * oversample)
    cand = ids[np.argpartition(dist, n - 1)[:n]] if n < len(ids) else ids
    return rescore(con, cand, qv, k)


def overlap_at(con, index, queries, k: int = 10, oversample: int = 16) -> float:
    """Share of the exact top-k that the 1-bit path also returns. Run it on your own vectors."""
    hits = 0
    for qv in queries:
        exact = {i for i, _ in rescore(con, None, qv, k)}
        fast = {i for i, _ in vector_search(con, index, qv, k, oversample)}
        hits += len(exact & fast)
    return hits / (k * len(queries))
Step 5, check. The overlap on synthetic vectors, with the output it prints as comments.
python
# Step 5, check. Needs VECTOR_SCHEMA and store_vector (step 4), load_bits and overlap_at (step 5).
import sqlite3

import numpy as np


def unit(a: np.ndarray) -> np.ndarray:
    return a / np.linalg.norm(a, axis=1, keepdims=True)


def overlap_demo(X: np.ndarray, Q: np.ndarray, oversamples=(1, 4, 16)) -> dict:
    con = sqlite3.connect(":memory:")
    con.executescript(VECTOR_SCHEMA)
    for i, v in enumerate(X, start=1):
        store_vector(con, i, v)
    index = load_bits(con)
    return {o: round(overlap_at(con, index, list(Q), 10, o), 2) for o in oversamples}


rng = np.random.default_rng(7)
n, clusters, dims = 30_000, 300, 768
centers = rng.standard_normal((clusters, dims)).astype(np.float32)
X = unit(centers[rng.integers(0, clusters, n)] + 0.9 * rng.standard_normal((n, dims)).astype(np.float32))
Q = unit(X[rng.integers(0, n, 60)] + 1.5 * rng.standard_normal((60, dims)).astype(np.float32) / np.sqrt(dims))
print("clustered:", overlap_demo(X, Q))

R = unit(rng.standard_normal((n, dims)).astype(np.float32))
QR = unit(R[rng.integers(0, n, 40)] + 0.7 * rng.standard_normal((40, dims)).astype(np.float32) / np.sqrt(dims))
print("random:   ", overlap_demo(R, QR, (1, 16)))

# Prints, with seed 7 and NumPy 2.5.3 (the values move with the seed):
# clustered: {1: 0.4, 4: 0.81, 16: 1.0}
# random:    {1: 0.17, 16: 0.42}

Let sqlite-vec do the shortlist

The NumPy version keeps all the bits in memory: 96 bytes per chunk, about 29 MB for 300,000 chunks. If you prefer SQL, sqlite-vec adds vec0 tables with a bit[768] column (its binary quantization guide shows one) and a nearest-neighbour query. It needs pip install sqlite-vec and a Python that can load extensions. Compare its shortlist with the NumPy path on your own vectors before you rely on it.

Step 5, variant. The shortlist through sqlite-vec. Needs the sqlite-vec package.
python
# Optional: let sqlite-vec do the 1-bit shortlist. pip install sqlite-vec
# Needs a Python whose sqlite3 module can load extensions.
import sqlite_vec


def use_sqlite_vec(con) -> None:
    con.enable_load_extension(True)
    sqlite_vec.load(con)
    con.enable_load_extension(False)
    con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS vbin USING vec0(embedding bit[768])")


def store_bits(con, item_id: int, v: np.ndarray) -> None:
    con.execute("INSERT INTO vbin(rowid, embedding) VALUES (?, vec_bit(?))",
                (item_id, np.packbits(v > 0).tobytes()))


def vector_search_vec(con, qv: np.ndarray, k: int = 60, oversample: int = 16):
    cand = [r[0] for r in con.execute(
        "SELECT rowid FROM vbin WHERE embedding MATCH vec_bit(?) AND k = ?",
        (np.packbits(qv > 0).tobytes(), k * oversample))]
    return rescore(con, cand, qv, k) if cand else []

Merge the lists with weighted reciprocal rank fusion in Python

Keyword scores and cosines do not share a scale, so fuse ranks. Reciprocal rank fusion, from Cormack, Clarke and Büttcher (SIGIR 2009), gives a document 1 / (k + rank) from every list that contains it and adds the contributions, with k = 60. The paper calls 60 near-optimal and the choice not critical. The weighted version multiplies each list's contribution by a weight.

Start with equal weights. Finer's tuned weights fit one small set, so they are not defaults to copy. A hand check: an item at rank 1 in a list of weight 1.0 and rank 2 in a list of weight 0.5 scores 1/61 + 0.5/62 = 0.02446. The pillar post has Finer's own numbers for keyword alone, meaning alone and hybrid on 28 questions, all in-sample.

Chunks roll up to files as best + 0.3 × second + 0.1 × third, so a file with several good chunks gets a lift over a file with one lucky chunk. The search function then ties the pieces together. To compare keyword-only with hybrid, pass weights with a zero for the list you want to switch off.

Step 6. Weighted RRF and the roll-up to files. Runs as written.
python
def rrf(ranked: dict[str, list[int]], weights: dict[str, float], k: int = 60) -> dict[int, float]:
    """Weighted reciprocal rank fusion. ranked maps a list name to item ids, best first."""
    score: dict[int, float] = {}
    for name, ids in ranked.items():
        w = weights.get(name, 1.0)
        for rank, item in enumerate(ids, start=1):
            score[item] = score.get(item, 0.0) + w / (k + rank)
    return score


WEIGHTS = {"keyword": 1.0, "meaning": 1.0}   # start equal, tune on your own questions later


def by_file(scores: dict[int, float], item_file: dict[int, int]):
    """Roll chunk scores up to files: best + 0.3 * second + 0.1 * third."""
    per_file: dict[int, list[tuple[float, int]]] = {}
    for item, s in scores.items():
        per_file.setdefault(item_file[item], []).append((s, item))
    out = []
    for file_id, hits in per_file.items():
        hits.sort(reverse=True)
        total = sum(w * s for w, (s, _) in zip((1.0, 0.3, 0.1), hits))
        out.append((file_id, total, hits[0][1]))      # file, score, best chunk
    return sorted(out, key=lambda t: -t[1])
Step 6, continued. The hybrid search function. Runs as written; pass your own embed function.
python
def search(con, index, query: str, embed, limit: int = 10, weights=WEIGHTS) -> list[dict]:
    qv = embed([QUERY + query])[0]
    qv = qv / np.linalg.norm(qv)
    ranked = {
        "keyword": keyword_search(con, query),
        "meaning": [i for i, _ in vector_search(con, index, qv)],
    }
    scores = rrf(ranked, weights)
    if not scores:
        return []
    marks = ",".join("?" * len(scores))
    item_file = dict(con.execute(f"SELECT id, file_id FROM items WHERE id IN ({marks})", list(scores)))
    paths = dict(con.execute("SELECT id, path FROM files"))
    results = []
    for file_id, score, best in by_file(scores, item_file)[:limit]:
        text = con.execute("SELECT text FROM items WHERE id=?", (best,)).fetchone()[0]
        results.append({"path": paths[file_id], "score": score, "text": text})
    return results

Re-rank only the near-ties

A reranker reads the query and one document together. That is usually more accurate than comparing two vectors, and slower. Qwen3-Reranker-0.6B answers yes or no and exposes the yes and no logits as a score. Running it on every query spends time on queries that were never in doubt, so Finer runs it only when the runner-up scores at least 0.8 of the leader.

Fuse by rank, not by the reranker's probability. Finer's first attempt blended the raw probability into the score and scored 0.88 MRR against 0.90 without a reranker on its 28 questions, because the probabilities saturate (a code comment gives the reason); the pillar has the figures. The current rule gives a document at retrieval rank i and reranker rank j the score 0.75 / (10 + i) + 1 / (10 + j), over the top 10 only, each cut to about 128 tokens.

The model block adapts the card's sentence-transformers CrossEncoder example, with a custom instruction through prompts. Scores are raw logit differences, and only their order is used. Cache scores by query and document text, so a repeated query or a page change does not re-score. Finer keeps 2,048 entries in memory.

Step 7. The near-tie gate and rank fusion. Runs as written; pass your own score function.
python
def gated_rerank(query, results, score_fn, top=10, gate=0.8, k=10, w_retrieval=0.75):
    """Re-rank only near-ties. score_fn(query, texts) returns one number per text."""
    n = min(top, len(results))
    if n < 2 or results[1]["score"] < gate * results[0]["score"]:
        return results                           # retrieval is decisive: skip the model
    rr = score_fn(query, [f'{r["path"]}\n{r["text"]}' for r in results[:n]])
    rr_rank = {i: r for r, i in enumerate(sorted(range(n), key=lambda i: -rr[i]))}
    fused = {i: w_retrieval / (k + i) + 1.0 / (k + rr_rank[i]) for i in range(n)}
    order = sorted(range(n), key=lambda i: -fused[i])
    return [results[i] for i in order] + results[n:]
Step 7, continued. Adapt to your runtime. The reranker model, adapted from its card. Not run.
python
# Adapt to your runtime. pip install -U sentence-transformers
from sentence_transformers import CrossEncoder

reranker = CrossEncoder(
    "Qwen/Qwen3-Reranker-0.6B",
    prompts={"files": "Given a search query over a person's documents, "
                      "judge whether the document is what they are looking for"},
    default_prompt_name="files",
)


def score_fn(query: str, texts: list[str]) -> list[float]:
    # Raw yes/no logit differences. Only their order is used.
    # Cutting by characters is a rough stand-in for cutting at about 128 tokens.
    return reranker.predict([(query, t[:600]) for t in texts]).tolist()

Evaluate with MRR and hit@k on your own questions

Before tuning anything, write about 30 real questions from your own files, each with one or more path fragments that count as a right answer. Use questions you asked, not ones you already know how to answer.

hit@k is the share of questions whose right file appears in the top k. MRR, the mean reciprocal rank, averages 1 / rank of the first right result and counts 0 when there is none. Ranks of 1, 2 and 4 plus one miss give (1 + 0.5 + 0.25 + 0) / 4 = 0.4375, and report([1, 2, 4, None]) returns that value as mrr.

Score keyword-only, meaning-only and hybrid on the same set. With 30 questions, one question is 3.3 points of hit@k, so small differences are noise. If you tune on the questions you report, the result is in-sample, which is why Finer's own table is. Hold some questions back.

Step 8. Gold set, ranks, hit@k and MRR. Runs as written.
python
GOLD = [   # your own questions; "expect" holds path fragments that count as a right answer
    {"q": "tomato soup recipe", "expect": ["recipes"]},
    {"q": "flight booking Lisbon", "expect": ["lisbon", "trip"]},
]


def first_hit(results: list[dict], expect: list[str]):
    for rank, r in enumerate(results, start=1):
        if any(frag.lower() in r["path"].lower() for frag in expect):
            return rank
    return None                                  # not in the results at all


def evaluate(search_fn, gold, depth: int = 10) -> list:
    """One rank (or None) per question. search_fn(question) -> results, best first."""
    return [first_hit(search_fn(g["q"])[:depth], g["expect"]) for g in gold]


def report(ranks: list) -> dict:
    n = len(ranks)

    def hit(k):
        return sum(1 for r in ranks if r is not None and r <= k) / n

    return {"hit@1": hit(1), "hit@3": hit(3), "hit@10": hit(10),
            "mrr": sum(1 / r for r in ranks if r) / n}

Tune a few weights behind a guardrail

Tuning on your own gold set invites overfitting. Finer changes a setting only if weighted MRR rises by at least 0.01, no gold set loses more than 0.01 MRR, and no set loses the rank-1 hit of any question. It logs every run as one JSON line, and the log is what makes a rollback possible. This is a simplified version of that rule.

run_eval(weights) returns the ranks per gold set, for example {"hand": ranks}. Add a favourites set or generated known-item questions the same way; Finer scores known items at half weight. The search is a small grid, one weight at a time, keeping the best accepted change. A change that passes is evidence on your gold set, not on your future queries.

Step 9, optional. Guardrail, one-weight search and rollback. Runs as written; pass your own run_eval function.
python
import json
import time


def accept(before: dict, after: dict, weights=None, min_gain=0.01, max_loss=0.01):
    """before/after: gold-set name -> ranks from evaluate(). Returns (ok, reason)."""
    weights = weights or {name: 1.0 for name in before}
    gain = sum(weights[n] * (report(after[n])["mrr"] - report(before[n])["mrr"])
               for n in before) / sum(weights.values())
    for n in before:
        loss = report(before[n])["mrr"] - report(after[n])["mrr"]
        if loss > max_loss:
            return False, f"{n} lost {loss:.3f} MRR"
        if any(b == 1 and a != 1 for b, a in zip(before[n], after[n])):
            return False, f"{n} lost a rank-1 hit"
    return gain >= min_gain, f"weighted MRR {gain:+.3f}"


def tune(run_eval, current: dict, step=0.2, log="quality.jsonl") -> dict:
    """run_eval(weights) -> {set name: ranks}. Try one weight at a time, keep the best accepted change."""
    before, best, best_gain, best_why = run_eval(current), None, 0.0, ""
    for name in current:
        for delta in (-step, step):
            cand = {**current, name: round(max(0.05, current[name] + delta), 2)}
            after = run_eval(cand)
            ok, why = accept(before, after)
            gain = sum(report(after[n])["mrr"] - report(before[n])["mrr"] for n in before) / len(before)
            if ok and gain > best_gain:
                best, best_gain, best_why = cand, gain, why
    entry = {"when": time.strftime("%Y-%m-%d %H:%M:%S"), "previous": current,
             "action": "applied" if best else "kept", "candidate": best, "reason": best_why or "no change passed"}
    with open(log, "a") as f:
        f.write(json.dumps(entry) + "\n")
    return best or current


def rollback(log="quality.jsonl"):
    """Weights from before the most recent applied change, or None."""
    entries = [json.loads(line) for line in open(log)]
    applied = [e for e in entries if e["action"] == "applied"]
    return applied[-1]["previous"] if applied else None

German text in FTS5: umlauts, ß and compounds

The snippet below runs the step 2 schema (unicode61 with remove_diacritics 2) against four small German documents and prints what each query finds. On SQLite 3.53.4 the output is the comment block at its end. "Müller" and "Muller" find both spellings of the name. "Mueller" finds nothing, because there is no transliteration from ue to ü. FTS5 does not fold "ß", so "Straße" finds only the document that spells it "Straße" and "Strasse" only the other one.

Finer has a bug here: its query code folds ß to ss before the query reaches FTS5, but the index keeps ß, so by that code a query for "Straße" behaves like the "Strasse" line above and misses documents that spell it with ß. The fts_query function in step 2 folds nothing, because FTS5 tokenizes query terms with the table's own tokenizer. If you want ss and ß to match, add both spellings to the query yourself.

FTS5's built-in tokenizers do not stem German or split compounds. In the output above, the prefix term "miet"* reaches "Mietvertrag" but not "Untermietvertrag", and "Vertrag" finds neither. Compounds that end in your word need another route.

Step 2, check. German spellings against the step 2 schema, with the output it prints as comments.
python
# Step 2, check. German spellings against the step 2 schema. Needs open_db, add_file and keyword_search.
con = open_db()
add_file(con, "notes/a.txt", [("", "Herr Müller wohnt in der Straße")])
add_file(con, "notes/b.txt", [("", "Herr Muller wohnt in der Strasse")])
add_file(con, "notes/c.txt", [("", "Der Mietvertrag läuft")])
add_file(con, "notes/d.txt", [("", "Der Untermietvertrag läuft")])


def paths_for(query: str) -> list[str]:
    ids = keyword_search(con, query)
    if not ids:
        return []
    marks = ",".join("?" * len(ids))
    rows = con.execute(
        f"SELECT DISTINCT f.path FROM items i JOIN files f ON f.id = i.file_id "
        f"WHERE i.id IN ({marks}) ORDER BY f.path", ids)
    return [r[0] for r in rows]


for q in ["Müller", "Muller", "Mueller", "Straße", "Strasse", "miet", "Vertrag"]:
    print(f"{q:8} -> {paths_for(q)}")

# Prints, on SQLite 3.53.4:
# Müller   -> ['notes/a.txt', 'notes/b.txt']
# Muller   -> ['notes/a.txt', 'notes/b.txt']
# Mueller  -> []
# Straße   -> ['notes/a.txt']
# Strasse  -> ['notes/b.txt']
# miet     -> ['notes/c.txt']
# Vertrag  -> []

Frequently asked questions

What is reciprocal rank fusion (RRF)?

A way to merge several ranked lists. Each list gives a document 1 / (k + rank), with k = 60 in the original paper, and the contributions add up. It ignores raw scores, so lists on different scales, like bm25 and cosine similarity, combine without normalisation.

Can you provide an example of hybrid search?

A chunk that is 1st in the keyword list and 3rd in the vector list scores 1/61 + 1/63 = 0.0323 with equal weights. A chunk that is 1st in only one list scores 1/61 = 0.0164. So a chunk found by both signals beats a chunk found by one, which is the point of the fusion.

What is MRR in RAG, and how is it calculated?

Mean reciprocal rank scores a retriever on a question set. For each question take 1 / the rank of the first right result, or 0 if it is missing, then average. Ranks 1, 2 and a miss give (1 + 0.5 + 0) / 3 = 0.5. It rewards putting the right document first; read it next to hit@k.

How to make a local RAG?

This guide is the retrieval half. For the answer half, take the best passage from each top result, number them, ask a model to answer only from them and cite [n], and strip citations that point nowhere. Finer's optional Ask feature does this. With Ask on it uses a cloud model by default, so it is local only if you keep the answer model local too.

Does SQLite support vector search?

Not natively. You can store vectors as BLOBs and scan them with NumPy, as above, or load the sqlite-vec extension, which adds a vec0 table and a nearest-neighbour query. Finer uses its bit[768] column. FTS5, in contrast, is built into most SQLite builds.

How to implement and evaluate a reranker in RAG?

Score the top 10 results with a cross-encoder, fuse its order with the retrieval order by rank, and run it only when the top two scores are close. To evaluate it, run your gold set with the reranker off and on, compare MRR and hit@1, and look at which questions moved. On Finer's 28 questions it moved two hit@1 questions, on a set the settings were tuned on.

Why does SQLite say no such module: fts5?

Your Python's SQLite was built without FTS5. Run the check in step 0, then switch to a Python build whose SQLite includes it.

Sources

  1. SQLite FTS5 documentation sqlite.org
  2. EmbeddingGemma 2 model card (Hugging Face) huggingface.co
  3. mlx-community 8-bit conversion of EmbeddingGemma 2 huggingface.co
  4. Qwen3-Reranker-0.6B model card huggingface.co
  5. Cormack, Clarke, Büttcher: Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009) research.google
  6. sqlite-vec: a vector search SQLite extension github.com
  7. sqlite-vec binary quantization guide alexgarcia.xyz
  8. Finer source code github.com

Talk to me

Building an AI product and need someone senior to own the technical side? Book a free 30-minute call.

Have something to build?

Tell me what you're building — start with a free call

Send a message

Founder, SolutionPlus · AI Product Engineer

SQ
Saif Qureshi
  • Berlin, Germany · Production AI agents and systems for companies and enterprises
  • Outcomes-focused delivery: measurable impact, not demos.

Contact

Available for new projects
© 2026 Made withby Saif Qureshi
ImpressumReact · TypeScript · Tailwind CSS