BM25 is a ranking algorithm. FTS5 is a full-text search engine inside SQLite. Does Google use BM25 ?

BM25 and SQLite FTS5 in 2026: Algorithms, Internals, Performance and Hybrid Search

BM25 and SQLite FTS5 in 2026: Deep Technical Guide to Lexical Retrieval, Ranking and Hybrid Search

BM25 is one of information retrieval’s most durable ranking functions. SQLite FTS5 packages closely related lexical ranking, positional inverted indexes, phrase search and extensibility inside a serverless database. This article derives BM25 from its probabilistic foundations, dissects FTS5 down to its postings and segment structures, provides production-style SQL and Python examples, and evaluates where the pair fits alongside dense vectors, learned sparse retrieval and rerankers in 2026.

Technical status reviewed for 10 September 2026. English: en-GB. SQLite’s current release at the time of review is 3.53.4, dated 24 July 2026. [SQLite current release]

Executive summary

BM25 remains a first-class retrieval algorithm in 2026 rather than a historical curiosity. Apache Lucene still exposes BM25 as a core similarity with defaults k1 = 1.2 and b = 0.75, while current Elasticsearch documentation continues to identify BM25 as its default similarity. [Lucene BM25Similarity] [Elasticsearch similarity documentation]

SQLite FTS5 is similarly alive and maintained. It first appeared experimentally in SQLite 3.8.11 on 27 July 2015 and entered the amalgamation with SQLite 3.9.0 on 14 October 2015. Subsequent releases added features such as configurable index detail, trigram indexes, new contentless modes, token-data support and locale-aware tokenizer APIs. [SQLite release history] [Official FTS5 documentation]

Verdict for a local reference library For searching titles, author names, citation strings, identifiers, abstracts and exact technical terminology, SQLite FTS5 with BM25 is an excellent default. It is local, deterministic, inexpensive, explainable and particularly strong where exact lexical evidence matters. It should not, however, be treated as a verifier of bibliographic truth: retrieval should find candidate records, while DOI, ISBN, author, year and title metadata should then be checked explicitly. Dense retrieval is best added when users also need conceptual or paraphrase-based discovery.

The key architectural recommendation is therefore not BM25 versus vectors. For most serious 2026 systems it is exact metadata lookup + BM25 candidate retrieval + optional dense retrieval + optional reranking. This preserves BM25’s excellent identifier and terminology recall while adding semantic matching only where it genuinely contributes.

High-level decision matrix
Requirement FTS5/BM25 fit Why Suggested augmentation
Exact titles, authors and technical terminology Excellent Lexical evidence is precisely what an inverted index preserves. Normalised metadata fields for exact identifiers.
DOI/ISBN/citation checking Excellent candidate retriever Fast local lookup; deterministic matching. Exact SQL indexes and canonical metadata validation.
Conceptual/paraphrase search Moderate BM25 cannot match vocabulary that is absent from a document unless expansion/stemming supplies it. Dense embeddings, learned sparse expansion or synonyms.
Single-machine private library Excellent No search server is required; database and index can remain in one local file. Optional local embedding index.
High-write distributed service Usually not the final architecture SQLite is embedded and serialises database writes; search-server architectures provide different concurrency and distribution characteristics. Lucene-based, distributed or purpose-built retrieval infrastructure.

History and evolution

Who invented BM25?

It is more accurate to describe BM25 as the product of a research lineage than to credit it to one inventor. Its foundations lie in the probabilistic relevance work of Stephen E. Robertson and Karen Spärck Jones. Their 1976 paper, Relevance Weighting of Search Terms, formalised probabilistic term weighting using relevance information. [Robertson & Spärck Jones, 1976]

The weighting function now recognised as BM25 emerged from the Okapi information-retrieval research programme at City University, London. The landmark public description normally cited for BM25 is Okapi at TREC-3 by Stephen E. Robertson, Steve Walker, Susan Jones, Micheline Hancock-Beaulieu and Mike Gatford, presented in the Third Text REtrieval Conference proceedings in 1994. NIST’s TREC-3 proceedings list the paper at page 109, and modern Lucene documentation explicitly traces its BM25 implementation to that work. [NIST TREC-3 proceedings] [Lucene historical citation]

Robertson and Hugo Zaragoza’s 2009 monograph The Probabilistic Relevance Framework: BM25 and Beyond is the definitive retrospective treatment of the model’s theoretical lineage. It makes clear why modern BM25 is best understood as a practical scoring function developed from the probabilistic relevance framework rather than as a single closed-form theorem discovered at one instant. [Robertson & Zaragoza, 2009]

Who invented FTS5?

There is no corresponding research paper naming a single “inventor of FTS5”. FTS5 is an engineering component of the SQLite project. SQLite’s canonical release history records FTS5 at the project level rather than assigning the module to a sole individual. It was introduced as an experimental extension in SQLite 3.8.11 on 27 July 2015, then included in the amalgamation in SQLite 3.9.0 on 14 October 2015. [SQLite release history]

This distinction matters when writing historical material: D. Richard Hipp created SQLite itself, but SQLite’s official FTS5 documentation and release history do not designate a single person as the inventor of FTS5. Likewise, there is no IETF-style “FTS5 RFC”. The authoritative specification is effectively the combination of the FTS5 manual, SQLite release notes and the canonical source tree.

Timeline

Figure: major milestones in the probabilistic/BM25 lineage and FTS5’s engineering evolution. Dates for SQLite features are from the official SQLite release history.

FTS5’s significant evolution after 2015

FTS5 was not frozen after its introduction. SQLite 3.34.0, released 1 December 2020, added trigram indexes, enabling indexed substring-oriented search patterns. SQLite 3.45.0, released 15 January 2024, added the tokendata option. The 21 October 2024 release added fts5_tokenizer_v2, locale-aware tokenisation through locale=1, and additional contentless-table functionality. FTS5 also continued receiving correctness, robustness and diagnostic fixes through the 2025–2026 SQLite release series. [SQLite release history]

BM25 from probabilistic relevance to a practical scoring function

The retrieval problem BM25 tries to solve

Given a query Q and document D, a ranking function needs to estimate which matching documents deserve to appear first. Raw term counts are insufficient: ten occurrences of a term do not normally make a document ten times as useful as one occurrence, ubiquitous words carry less evidence than rare words, and long documents have more opportunities to contain a query term merely by chance.

BM25 addresses these three effects with three interacting pieces: inverse document frequency, saturating within-document term frequency, and document-length normalisation. It remains a bag-of-words lexical model at heart: unless the query system adds phrase/proximity constraints separately, BM25 itself does not understand semantic equivalence or word order.

Probabilistic starting point: relevance odds

In the classical probabilistic relevance framework, documents can be ranked by their odds of relevance. For a term t, let p be the probability that a relevant document contains the term and u the probability that a non-relevant document contains it. The term’s log-odds contribution is:

wt = ln [ pt(1-ut) / (ut(1-pt)) ]

With relevance judgements, define N as the number of documents, R as the number known relevant, n as the number containing term t, and r as the number of relevant documents containing it. The Robertson–Spärck Jones weight with 0.5 corrections is:

RSJ(t) = ln [ (r + 0.5)(N – n – R + r + 0.5) / ((n-r+0.5)(R-r+0.5)) ]

In the common first-pass case where no relevance judgements are available, setting R = r = 0 reduces the collection component to:

IDFRSJ(t) = ln [ (N – nt + 0.5) / (nt + 0.5) ]

This is the classic probabilistic IDF form. It is important to understand that the rest of BM25 is not an inevitable algebraic consequence of this expression. BM25’s term-frequency and length components are practical modelling extensions rooted in the probabilistic/Okapi research programme. [Robertson & Zaragoza]

The modern BM25 equation

A widely used form for query terms q1 … qm is:

score(D,Q) = Σi=1..m IDF(qi) × [ fi(k1+1) / ( fi + k1 (1-b+b|D|/avgdl) ) ]

Here fi is the frequency of query term qi in the document, |D| is document length in tokens and avgdl is the collection’s average document length. k1 controls term-frequency saturation and b controls the strength of document-length normalisation. Lucene’s current API describes these roles exactly and defaults to k1=1.2, b=0.75. [Lucene BM25Similarity]

Term-frequency saturation derived step by step

Define a length-dependent constant:

K(D) = k1 × (1 – b + b|D|/avgdl)

The term-frequency component becomes:

TFsat(f) = f(k1+1)/(f+K)

This is a rectangular-hyperbola-like saturation curve. Its first derivative is:

dTFsat/df = (k1+1)K/(f+K)2

and the second derivative is:

d2TFsat/df2 = -2(k1+1)K/(f+K)3

For positive K, the first derivative is positive and the second is negative: additional occurrences always help, but each additional occurrence helps less than the previous one. As f → ∞:

lim TFsat(f) = k1 + 1

This is BM25’s central defence against keyword stuffing within a document. Repeating a matching word indefinitely cannot make its frequency contribution grow without bound.

At average document length, |D|/avgdl = 1, so K = k1. Consequently, when f = 1:

TFsat(1) = (k1+1)/(1+k1) = 1

With k1=1.2, an average-length document therefore receives a frequency multiplier of exactly 1 for one occurrence, approximately 1.375 for two occurrences, approximately 1.774 for five occurrences, and can never exceed 2.2 regardless of repetition.

BM25 term-frequency saturation Curves showing diminishing returns from repeated term occurrences for documents at half, equal to, and twice average length using k1 1.2 and b 0.75. Term frequency f BM25 TF multiplier 0 1 2 1 6 12 |D| = 0.5 × avgdl |D| = avgdl |D| = 2 × avgdl
Figure: exact mathematical behaviour of BM25’s TF component for k1=1.2 and b=0.75. This is a formula-derived chart, not an empirical benchmark.

What k1 actually does

k1 sets the shape of the saturation curve. Lower values make BM25 approach its ceiling faster. At the limiting case k1 = 0, every positive term frequency receives essentially the same within-document factor: occurrence becomes almost binary presence. Higher k1 allows repeated occurrences to continue affecting the score for longer.

There is therefore no universal “optimal” k1. Short metadata fields often need little reward for repetition, whereas longer natural-language passages can sometimes benefit from slower saturation. Defaults around 1.2 are strong starting points, not laws of nature.

What b actually does

The b parameter scales length normalisation:

normaliser(D) = 1 – b + b|D|/avgdl

At b=0, document length disappears from the denominator. At b=1, length is normalised fully in proportion to |D|/avgdl. For a document longer than average, increasing b increases the denominator and suppresses its term-frequency contribution. For a shorter-than-average document, the reverse occurs.

Length normalisation is particularly consequential when an index mixes radically different kinds of content. A 12-token title and a 5,000-token abstract/article body should usually not be treated as one undifferentiated field if field-specific ranking behaviour is important.

IDF variants: they are not interchangeable

Common BM25 IDF variants
Implementation/form IDF Important behaviour
Classical RSJ ln((N-n+0.5)/(n+0.5)) Becomes negative when a term occurs in more than roughly half of documents.
Lucene BM25Similarity ln(1 + (N-n+0.5)/(n+0.5)) Adding 1 inside the logarithm keeps IDF positive.
SQLite FTS5 implementation max(1e-6, ln((N-n+0.5)/(n+0.5))) FTS5 computes classic RSJ IDF, then floors non-positive values to 1e-6.

That final detail is subtle because SQLite’s prose documentation presents the classical expression, while the current source code explicitly guards against negative IDF. In ext/fts5/fts5_aux.c, SQLite calculates log((nRow-nHit+0.5)/(nHit+0.5)) and replaces any result less than or equal to zero with 1e-6. [SQLite FTS5 source]

Lucene takes a different route: log(1 + (docCount-docFreq+0.5)/(docFreq+0.5)). Consequently, “BM25 score” is not a portable absolute quantity. Two engines can both correctly describe themselves as BM25 implementations while producing different numeric scores and, occasionally, different orderings. [Lucene IDF implementation]

A complete numerical example

Suppose:

N = 10,000, n = 100, f = 3, |D| = 800, avgdl = 1,000, k1 = 1.2, b = 0.75

The classical RSJ IDF is:

IDF = ln((10,000 – 100 + 0.5)/(100 + 0.5)) ≈ 4.59018

The length-adjusted denominator constant is:

K = 1.2 × (1 – 0.75 + 0.75 × 800/1000) = 1.02

The TF factor is:

TF = 3 × (1.2 + 1)/(3 + 1.02) = 6.6/4.02 ≈ 1.64179

So this term contributes approximately:

4.59018 × 1.64179 ≈ 7.53612

A multi-term query simply adds corresponding contributions, subject to the engine’s exact IDF convention, query parsing, boosting and any additional ranking stages.

Query term frequency and the k3 factor

Historical BM25 formulations sometimes include an additional query-side saturation factor:

((k3+1)qtf)/(k3+qtf)

Modern search queries are often short enough that explicit query-term frequency is not useful, and major implementations commonly omit this factor. Neither Lucene’s standard BM25Similarity API nor SQLite FTS5’s built-in bm25() exposes a k3 parameter.

BM25 scoring flow

BM25 scoring flow Query terms are tokenised; collection statistics produce IDF; document statistics produce frequency and length normalisation; contributions are summed. Query Q tokenise / parse Collection statistics N, document frequency → IDF Document statistics f, |D|, avgdl → TF saturation Per-term contribution IDF × saturated TF Σ contributions final document score
Figure: BM25 separates corpus-wide evidence from within-document evidence, then sums contributions over query terms or, in FTS5’s implementation, query phrases.

SQLite FTS5 internals: tokenisation, inverted indexes, phrases and ranking

FTS5 is an inverted-index virtual table

FTS5 is a SQLite virtual-table module for full-text search. At the conceptual level it converts text into tokens and builds a mapping from each token to the places where that token occurs. Instead of scanning every document for every query, FTS5 looks up the token’s postings and works from the much smaller candidate set. [SQLite FTS5 documentation]

Inverted index structure Three example documents are tokenised into dictionary terms whose postings contain document row identifiers, column identifiers and token positions. rowid 17 title: “probabilistic retrieval model” rowid 29 title: “BM25 retrieval in SQLite” rowid 51 title: “dense retrieval model” Term dictionary retrieval model bm25 sqlite Postings (17,title,pos=1) (29,title,pos=1) (51,title,pos=1) (17,title,pos=2) (51,title,pos=2) (29,title,pos=0) (29,title,pos=3) FTS5 uses compact binary encodings and delta/varint-oriented structures rather than these literal tuples.
Figure: conceptual inverted index. With full detail, FTS5 can retain enough positional information to evaluate phrases and proximity expressions. Official data-structure documentation.

The shadow-table architecture

An FTS5 virtual table is backed by SQLite “shadow tables”. Depending on its configuration, these include structures conventionally named %_data, %_idx, %_config, %_docsize and %_content. The index itself is stored primarily as compact binary records, not as a normal one-row-per-posting SQL table. [FTS5 data structures]

Key FTS5 shadow structures
Structure Purpose When it matters
%_data Stores the principal binary index/segment records. Core postings and segment storage.
%_idx Provides index/navigation information into segment data. Locating index pages efficiently.
%_config Stores FTS5 configuration values. Persistent options such as ranking configuration.
%_docsize Stores per-row token counts when columnsize=1. Efficient document-length access for BM25 and extension APIs.
%_content Stores a copy of indexed text in ordinary FTS5 tables. Can be omitted using external-content/contentless designs.

Segment-based index maintenance

Internally, FTS5 maintains its searchable index as a series of segment B-tree-like structures. Writes can introduce new segments rather than rewriting one enormous monolithic postings tree immediately. Segments are subsequently merged according to FTS5’s merge policies. This is usefully thought of as LSM-like behaviour, although FTS5’s format and merge machinery are its own. [FTS5 index format]

This design makes index updates practical, but it introduces the familiar trade-off between write cost and read amplification: many small outstanding segments can make searches do more merging work. FTS5 therefore exposes maintenance controls such as automerge, merge and optimize. [FTS5 special INSERT commands]

Tokenisers

The tokenizer determines what the search engine considers a token. Tokenisation is not a cosmetic preprocessing choice: it changes document frequency, positions, phrase semantics, index size and therefore ranking.

Built-in FTS5 tokenisers
Tokenizer Behaviour Good use cases Cautions
unicode61 Default Unicode-oriented word tokenisation; case folding and configurable diacritic handling. Titles, abstracts, prose, multilingual Latin-script libraries. Identifier punctuation such as DOI separators is tokenised, so exact identifiers deserve separate normalised columns.
ascii Simpler ASCII-oriented tokenisation. Strictly ASCII corpora or specialised pipelines. Not a general solution for multilingual metadata.
porter Wraps another tokenizer and applies Porter stemming. English recall where morphological variants matter. Stemming can reduce precision for names, identifiers and specialised vocabulary.
trigram Indexes overlapping three-character sequences. Substring matching, names, fragments and LIKE/GLOB acceleration under documented conditions. Generally larger index and different semantics from word retrieval.

FTS5 also exposes APIs for registering custom tokenizers. Recent SQLite versions extended these APIs with locale-aware tokenizer support. [FTS5 tokenizer documentation] [SQLite release history]

Prefix indexes

A normal FTS5 term lookup can go directly to a complete token. A query such as robert*, however, potentially represents a range of dictionary terms. FTS5 can therefore create separate prefix indexes of configured lengths. For example:

CREATE VIRTUAL TABLE papers_fts USING fts5(
    title,
    authors,
    abstract,
    prefix='2 3 4'
);

This is a classic space-versus-speed trade-off: prefix indexes consume more index space and add work during ingestion, but can avoid dictionary range scans for common prefix lengths. [FTS5 prefix indexes]

Phrase and proximity queries

With the default detail=full, postings contain positional information. A phrase such as "probabilistic relevance" can therefore be evaluated by finding rows containing both tokens and confirming that their token positions are adjacent in the required order.

FTS5 additionally supports prefix tokens, column filters, Boolean combinations and NEAR(...) expressions. [FTS5 query syntax]

-- Exact phrase
SELECT rowid, title
FROM papers_fts
WHERE papers_fts MATCH '"probabilistic relevance"';

-- Prefix
SELECT rowid, title
FROM papers_fts
WHERE papers_fts MATCH 'robert*';

-- Column-specific phrase
SELECT rowid, title
FROM papers_fts
WHERE papers_fts MATCH 'title : "information retrieval"';

-- Boolean
SELECT rowid, title
FROM papers_fts
WHERE papers_fts MATCH 'bm25 AND sqlite NOT postgres';

-- Proximity
SELECT rowid, title
FROM papers_fts
WHERE papers_fts MATCH 'NEAR("dense retrieval" benchmark, 8)';

The detail option and positional information

FTS5 detail levels
Setting Stored detail Benefit Cost/restriction
detail=full Row, column and token-position information. Full phrase/proximity functionality. Largest positional index.
detail=column Retains column information but omits token offsets. Smaller index. Some phrase/proximity functionality is restricted.
detail=none Omits column and positional detail. Smallest of these index forms. Significant restrictions for phrase/column-dependent operations.

For a bibliographic library where quoted titles and names matter, detail=full is normally worth keeping unless storage pressure is severe. [FTS5 detail option]

How FTS5 implements BM25

FTS5’s built-in bm25() is not just “roughly BM25”. Its source contains a recognisable implementation of the familiar formula, with hard-coded:

k1 = 1.2     b = 0.75

However, there are several SQLite-specific details that matter in production.

First, FTS5 decomposes the query into phrases. Its implementation computes document frequency and phrase frequency at the phrase level. A quoted multi-token phrase is therefore not necessarily scored as though each constituent token were an independent query term. [FTS5 bm25()] [FTS5 bm25 source]

Second, FTS5 floors non-positive IDF to 1e-6. This keeps very common phrases from contributing a negative weight. [Source implementation]

Third, FTS5 negates the conventional score. In most BM25 descriptions, larger positive values mean better matches. FTS5 multiplies the result by -1 so that the best result is numerically smallest, which makes natural ascending SQL ordering convenient:

SELECT rowid, title, bm25(papers_fts) AS score
FROM papers_fts
WHERE papers_fts MATCH :query
ORDER BY score
LIMIT 20;

A highly relevant FTS5 match may therefore have a score such as -12.7, while a weaker match might have -3.1. Do not mistakenly use DESC. [FTS5 scoring order]

Column weighting

Additional arguments to bm25() are column weights, not alternate values of k1 and b. For a four-column table:

SELECT
    rowid,
    title,
    bm25(papers_fts, 8.0, 5.0, 1.0, 10.0) AS score
FROM papers_fts
WHERE papers_fts MATCH :query
ORDER BY score
LIMIT 20;

This might mean title weight 8, author weight 5, abstract weight 1 and identifier-text weight 10. Internally, FTS5 forms the phrase frequency as a weighted sum of occurrences in columns before applying its BM25 saturation. The document-length component remains based on the total row token count. [FTS5 column weights]

FTS5 column weighting is not full BM25F. BM25F performs more elaborate field-aware normalisation. FTS5’s built-in function instead weights per-column phrase occurrences and feeds them into one BM25 calculation with a row-level length. The distinction matters when fields differ dramatically in length.

The hidden rank column

Every FTS5 table has a hidden rank column. During a full-text query its default value corresponds to bm25(table). SQLite’s documentation specifically notes that sorting by rank may be faster than explicitly sorting by bm25(), particularly when result consumption is abandoned early or a LIMIT is present. [FTS5 rank column]

SELECT rowid, title, rank
FROM papers_fts
WHERE papers_fts MATCH :query
ORDER BY rank
LIMIT 20;

A weighted configuration can be attached to the rank expression:

SELECT rowid, title, rank
FROM papers_fts
WHERE papers_fts MATCH :query
  AND rank MATCH 'bm25(8.0, 5.0, 1.0, 10.0)'
ORDER BY rank
LIMIT 20;

Custom ranking functions

FTS5 exposes a public extension API that can register custom auxiliary functions through fts5_api.xCreateFunction(). Such functions receive an Fts5ExtensionApi context that can inspect row counts, token counts, query phrases, positions and matching instances. [Custom FTS5 functions]

This is the route to a genuinely custom BM25 variant — for example, alternative k1/b, recency priors, per-field length models or an entirely different lexical score. The built-in bm25() does not let SQL callers override its hard-coded 1.2/0.75.

Python’s ordinary sqlite3.Connection.create_function() can register standard SQL scalar functions, but it does not by itself provide the FTS5 extension context required to inspect postings and phrase instances. For a true FTS5 auxiliary ranker, use a compiled SQLite extension or perform a second-stage rerank in Python.

FTS5 architecture from ingestion to ranking

SQLite FTS5 architecture Documents and queries pass through the tokenizer. Documents populate segment-based inverted indexes while queries retrieve postings, evaluate Boolean and phrase constraints, then BM25 ranks matching rows. Documents INSERT / UPDATE MATCH query phrases / Boolean Tokenizer unicode61 / porter / ascii / trigram / custom Segment index %_data + %_idx terms → compressed doclists row / column / positions Query evaluator posting intersections/unions phrase + NEAR constraints candidate rows Ranking BM25 / rank / custom auxiliary ORDER BY rank
Figure: simplified FTS5 architecture. Index maintenance also includes segment merging, content/document-size storage and configuration shadow tables.

Practical implementation for a local bibliographic library

Use exact metadata and full-text retrieval together

A reference library should not place DOI, ISBN and other canonical identifiers exclusively inside the full-text index. Store their normalised forms in ordinary indexed SQLite columns and use FTS5 for the parts that genuinely require retrieval ranking: titles, authors, abstracts, keywords and citation text.

A robust base schema is:

PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;

CREATE TABLE works (
    id          INTEGER PRIMARY KEY,
    title       TEXT NOT NULL,
    authors     TEXT NOT NULL DEFAULT '',
    abstract    TEXT NOT NULL DEFAULT '',
    identifiers TEXT NOT NULL DEFAULT '',
    year        INTEGER,
    doi_norm    TEXT,
    isbn13      TEXT,
    source      TEXT,
    UNIQUE (doi_norm)
);

CREATE INDEX works_isbn13_idx ON works(isbn13);
CREATE INDEX works_year_idx   ON works(year);

CREATE VIRTUAL TABLE works_fts USING fts5(
    title,
    authors,
    abstract,
    identifiers,
    content='works',
    content_rowid='id',
    tokenize='unicode61 remove_diacritics 2',
    prefix='2 3 4',
    detail=full,
    columnsize=1
);

This uses an external-content FTS5 table. The authoritative text lives once in works; FTS5 stores its index rather than maintaining a second content copy. [External-content tables]

Keep the external-content index synchronised

SQLite explicitly warns that applications are responsible for keeping an external-content FTS index consistent with its content table. Triggers are a conventional solution:

CREATE TRIGGER works_ai AFTER INSERT ON works BEGIN
  INSERT INTO works_fts(
      rowid, title, authors, abstract, identifiers
  )
  VALUES (
      new.id, new.title, new.authors, new.abstract, new.identifiers
  );
END;

CREATE TRIGGER works_ad AFTER DELETE ON works BEGIN
  INSERT INTO works_fts(
      works_fts, rowid, title, authors, abstract, identifiers
  )
  VALUES (
      'delete', old.id, old.title, old.authors, old.abstract, old.identifiers
  );
END;

CREATE TRIGGER works_au AFTER UPDATE ON works BEGIN
  INSERT INTO works_fts(
      works_fts, rowid, title, authors, abstract, identifiers
  )
  VALUES (
      'delete', old.id, old.title, old.authors, old.abstract, old.identifiers
  );

  INSERT INTO works_fts(
      rowid, title, authors, abstract, identifiers
  )
  VALUES (
      new.id, new.title, new.authors, new.abstract, new.identifiers
  );
END;

For an existing populated table, build or repair the external index with:

INSERT INTO works_fts(works_fts) VALUES('rebuild');

Weighted bibliographic search

Bibliographic relevance usually deserves stronger weights for title, authors and identifiers than for an abstract. A starting configuration might be:

SELECT
    w.id,
    w.title,
    w.authors,
    w.year,
    w.doi_norm,
    works_fts.rank AS fts_score
FROM works_fts
JOIN works AS w ON w.id = works_fts.rowid
WHERE works_fts MATCH :query
  AND works_fts.rank MATCH 'bm25(8.0, 5.0, 1.0, 10.0)'
ORDER BY works_fts.rank
LIMIT 25;

Those weights are a starting hypothesis, not universal optimum values. Tune them against a judgement set drawn from real reference-search queries.

Exact identifier lookup before ranked retrieval

-- DOI: normalise before insertion and query.
SELECT id, title, authors, year, doi_norm
FROM works
WHERE doi_norm = :doi
LIMIT 1;

-- ISBN-13:
SELECT id, title, authors, year, isbn13
FROM works
WHERE isbn13 = :isbn13
LIMIT 1;

This is both faster and semantically stronger than asking BM25 whether one identifier is “more relevant” than another. An identifier is normally an equality predicate, not a fuzzy relevance signal.

Snippets and highlighting

SELECT
    w.id,
    w.title,
    snippet(
      works_fts,
      2,              -- abstract column
      '<mark>',
      '</mark>',
      ' … ',
      24
    ) AS context,
    works_fts.rank
FROM works_fts
JOIN works AS w ON w.id = works_fts.rowid
WHERE works_fts MATCH :query
ORDER BY works_fts.rank
LIMIT 10;

FTS5’s snippet() and highlight() functions use match information to produce result context. [FTS5 snippet()]

Inspecting the vocabulary with fts5vocab

fts5vocab is invaluable for diagnosing tokenisation, document-frequency surprises and search behaviour: [fts5vocab documentation]

CREATE VIRTUAL TABLE works_vocab
USING fts5vocab(works_fts, 'row');

SELECT term, doc, cnt
FROM works_vocab
WHERE term IN ('bm25', 'sqlite', 'retrieval')
ORDER BY term;

Here doc tells you how many rows contain a token, which is precisely the type of collection statistic that governs IDF.

Python integration

from __future__ import annotations

import re
import sqlite3
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class SearchHit:
    work_id: int
    title: str
    authors: str
    year: int | None
    doi: str | None
    score: float


def normalise_doi(value: str) -> str:
    value = value.strip().lower()
    value = re.sub(r"^https?://(?:dx\.)?doi\.org/", "", value)
    value = re.sub(r"^doi:\s*", "", value)
    return value.strip()


class LibrarySearch:
    def __init__(self, path: str | Path) -> None:
        self.conn = sqlite3.connect(path)
        self.conn.row_factory = sqlite3.Row

        # Useful for read-heavy local applications.
        self.conn.execute("PRAGMA foreign_keys = ON")
        self.conn.execute("PRAGMA journal_mode = WAL")

        try:
            self.conn.execute(
                "CREATE VIRTUAL TABLE IF NOT EXISTS __fts5_test "
                "USING fts5(value)"
            )
            self.conn.execute("DROP TABLE __fts5_test")
        except sqlite3.OperationalError as exc:
            raise RuntimeError(
                "This SQLite build does not provide FTS5"
            ) from exc

    def by_doi(self, doi: str) -> SearchHit | None:
        row = self.conn.execute(
            """
            SELECT id, title, authors, year, doi_norm
            FROM works
            WHERE doi_norm = ?
            LIMIT 1
            """,
            (normalise_doi(doi),),
        ).fetchone()

        if row is None:
            return None

        return SearchHit(
            work_id=row["id"],
            title=row["title"],
            authors=row["authors"],
            year=row["year"],
            doi=row["doi_norm"],
            score=0.0,
        )

    def search(self, query: str, limit: int = 20) -> list[SearchHit]:
        if not query.strip():
            return []

        rows = self.conn.execute(
            """
            SELECT
                w.id,
                w.title,
                w.authors,
                w.year,
                w.doi_norm,
                works_fts.rank AS score
            FROM works_fts
            JOIN works AS w
              ON w.id = works_fts.rowid
            WHERE works_fts MATCH ?
              AND works_fts.rank MATCH
                  'bm25(8.0, 5.0, 1.0, 10.0)'
            ORDER BY works_fts.rank
            LIMIT ?
            """,
            (query, limit),
        ).fetchall()

        return [
            SearchHit(
                work_id=row["id"],
                title=row["title"],
                authors=row["authors"],
                year=row["year"],
                doi=row["doi_norm"],
                score=row["score"],
            )
            for row in rows
        ]

    def optimise(self) -> None:
        self.conn.execute(
            "INSERT INTO works_fts(works_fts) VALUES('optimize')"
        )
        self.conn.commit()

The SQL value is parameterised, which protects the SQL statement itself. Remember, however, that a parameter supplied to MATCH is still parsed as an FTS5 query language. If your interface promises literal text rather than Boolean/phrase syntax, implement an explicit query-escaping policy rather than silently exposing operators.

Bulk loading

Bulk ingestion should normally happen inside an explicit transaction rather than one commit per document:

with conn:
    conn.executemany(
        """
        INSERT INTO works(
            title, authors, abstract, identifiers,
            year, doi_norm, isbn13, source
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """,
        records,
    )

# After a large one-off load:
conn.execute(
    "INSERT INTO works_fts(works_fts) VALUES('optimize')"
)
conn.commit()

Whether optimize is worth running after every batch depends on update frequency and latency requirements. It is better viewed as controlled index maintenance than as a compulsory per-write operation.

Contentless indexes

Where every indexed string can be regenerated or is stored elsewhere, contentless modes can reduce duplication. They come with behavioural restrictions, so they should be selected intentionally rather than merely because “smaller is better”. [Contentless FTS5 tables]

Changing the default rank mapping

FTS5 permits persistent rank configuration. For example:

INSERT INTO works_fts(works_fts, rank)
VALUES('rank', 'bm25(8.0, 5.0, 1.0, 10.0)');

After this, queries using the hidden rank column can use the configured weighting without repeating the expression each time. [FTS5 rank configuration]

Performance, accuracy and scalability

Why inverted indexes are fast

A naive full-text query examines every token of every document. An inverted index changes the shape of the problem: it first locates the postings lists for query terms, then works primarily with documents that contain those terms.

For a selective term, lookup cost is dominated by locating its dictionary entry plus traversing its postings. A conjunctive query can intersect postings lists, usually beginning with selective evidence. Phrase search requires additional positional checks after candidate rows and shared term occurrences are identified.

Exact asymptotic notation alone is not a useful latency forecast because compression, cache locality, page size, storage medium, term distribution, phrase frequency, segment count and result ranking all matter. A five-million document index containing short titles behaves very differently from five million books containing complete OCR text.

Do not publish invented “FTS5 takes X ms at Y million rows” charts. Latency is hardware- and corpus-dependent. Benchmark the real corpus with warm and cold caches, representative query distributions and the intended concurrency. Formula-derived plots such as the BM25 TF chart above are reproducible; generic latency curves without a specified machine and dataset are not.

What determines index size?

Major FTS5 storage and performance levers
Lever Index-size effect Query effect Recommendation
detail=full More positional data Enables full phrase/proximity support Keep for scholarly/reference libraries unless space pressure is severe.
Prefix indexes Increase size Accelerate matching configured prefix lengths Create only lengths your UI actually uses.
columnsize=1 Adds document-size data Efficient BM25 length retrieval Usually keep enabled for ranked retrieval.
External content Avoids duplicated original text in the FTS table Requires content lookup via original table Strong default when authoritative metadata already has a normal table.
Trigram index Can be substantially larger Supports efficient substring-oriented search Use selectively rather than as a default replacement for word indexing.
Porter stemming Vocabulary characteristics change Can increase English morphological recall Avoid on exact identifier/name fields unless deliberately desired.
Outstanding segments Temporary fragmentation/overhead Can increase query work Monitor and use FTS5 merge/optimisation controls appropriately.

SQLite concurrency is a separate scaling dimension

FTS5’s search algorithm may scale comfortably for a read-heavy local library long before SQLite’s application-level concurrency model becomes relevant. But the two concerns should not be confused. SQLite supports many concurrent readers, while database writes are serialised. WAL mode improves reader/writer coexistence but does not transform SQLite into a distributed multi-writer search cluster. [SQLite WAL documentation] [When to use SQLite]

For a desktop reference manager, local knowledge base or personal research library this is frequently an advantage: there is no service to operate. For a web-scale ingestion system with many simultaneous writers, replication, sharding and high availability requirements, the operational problem is different even if BM25 remains an appropriate ranking function.

Ranking cost and candidate count

A term that occurs in 30 documents is inexpensive to score compared with one that occurs in three million. Stop-word-like query phrases also have tiny IDF and poor selectivity. Good query design therefore affects both ranking effectiveness and work performed.

The hidden rank column is worth preferring when ordering FTS5 results because SQLite documents an execution advantage over a direct ORDER BY bm25(table) in cases where not all candidate rows need to be consumed. [FTS5 rank sorting]

Measuring retrieval quality

Performance in milliseconds is only half the evaluation. A search engine that returns an irrelevant answer instantly is still poor. Build a judgement set with real queries and record which library records are relevant.

Useful information-retrieval metrics
Metric Definition Why it matters for a reference library
Precision@k Relevant documents among the first k results divided by k. Measures result-list cleanliness.
Recall@k Relevant documents found in the first k divided by all judged relevant documents. Critical when missing a citation is more costly than inspecting an extra candidate.
MRR Mean reciprocal rank of the first relevant result. Good for known-item/reference lookup, where one correct record should be near rank 1.
nDCG@k Position-discounted graded relevance normalised against an ideal ordering. Useful when matches have degrees of relevance rather than binary labels.
p50/p95/p99 latency Latency distribution percentiles. Shows tail behaviour that an average conceals.

Recommended experimental procedure

For reference checking, divide test queries into at least three behavioural groups: known-item queries containing title/author fragments; identifier queries containing DOI/ISBN-style strings; and semantic topic queries. Tune lexical column weights on one subset and report final metrics on a held-out subset. This prevents a deceptively good overall score caused by overfitting the exact queries used to choose weights.

Library-size guidance

The following size boundaries are engineering starting points, not SQLite hard limits or benchmark results. Actual feasibility depends far more on bytes per record, text length, update rate, SSD performance, concurrency and query distribution.

Practical starting configurations by library scale
Library class Indicative record count Recommended lexical configuration Semantic layer Operational recommendation
Small Up to roughly 100,000 records FTS5, unicode61, detail=full, columnsize=1, title/author weighting Optional Single SQLite file is usually the simplest architecture worth testing first.
Medium Roughly 100,000–2 million records External-content FTS5, selective prefixes, disciplined bulk loading and maintenance Useful if conceptual search is important Benchmark on target SSD/RAM; keep exact identifiers in B-tree indexes.
Large local Roughly 2–10+ million records FTS5 can remain viable for read-heavy workloads, but do not assume it without measurement Hybrid index may dominate storage Benchmark against Lucene/Tantivy/search-server alternatives before committing.
Distributed/service-scale Driven more by concurrency/availability than count BM25 remains useful, but SQLite may cease to be the appropriate serving architecture Often integrated Consider replicated/distributed search infrastructure.

BM25 and FTS5 in the 2026 retrieval landscape

BM25 has not been displaced by neural retrieval

Neural retrieval changed the state of the art, but it did not make lexical retrieval obsolete. The BEIR benchmark evaluated lexical, sparse neural, dense, late-interaction and reranking architectures across heterogeneous retrieval datasets and described BM25 as a robust baseline. Its authors found that reranking and late-interaction approaches achieved the strongest average zero-shot effectiveness among the evaluated systems, but at higher computational cost. [BEIR, Thakur et al.]

The continuing industrial role is also observable directly in current search software: Elasticsearch still documents BM25 as its default similarity, and Lucene continues to expose a first-class BM25Similarity implementation. [Elasticsearch] [Lucene]

Dense retrieval

Dense retrieval embeds queries and documents into continuous vector spaces, then searches for nearby representations. Dense Passage Retrieval (DPR) was an influential demonstration: on the open-domain question-answering datasets used in that paper, its learned dual encoder improved top-20 passage retrieval accuracy by 9–19 percentage points over the strong Lucene-BM25 baseline used by the authors. [Dense Passage Retrieval]

That result should not be universalised into “dense always beats BM25”. Dense models depend on training distribution, embedding model quality, chunking and similarity-index settings. They can excel when a query and relevant document use different vocabulary; they can also struggle with exact identifiers, rare names, numerical strings and newly coined terms that lexical search preserves naturally.

Learned sparse retrieval

Systems such as SPLADE retain sparse representations compatible with inverted-index ideas while using neural models to learn term importance and expansion. SPLADE v2 reported substantial effectiveness improvements, including more than a 9% nDCG@10 gain on TREC DL 2019 over its compared predecessor configuration, while retaining desirable sparse-retrieval properties. [SPLADE v2]

Learned sparse retrieval is conceptually attractive for applications that want lexical interpretability and inverted-index execution while mitigating vocabulary mismatch. The trade-off is a model-dependent indexing pipeline and potentially expanded postings.

Late interaction

ColBERT-style retrieval keeps token-level vector representations instead of collapsing an entire passage into one vector. ColBERTv2 combines this late-interaction architecture with residual compression and reported a 6–10× reduction in its late-interaction space footprint compared with the prior approach while maintaining strong retrieval quality across evaluated benchmarks. [ColBERTv2]

The result is a useful middle point between one-vector-per-document dense search and a fully expensive cross-encoder: richer interactions are available during retrieval, but the index and serving path are substantially more complex than FTS5.

Rerankers

A reranker changes the economics of neural relevance modelling. Instead of scoring every document with an expensive model, BM25 or a dense retriever selects perhaps 50–500 candidates. A cross-encoder or sequence-to-sequence ranker then applies richer query-document reasoning only to those candidates.

This multi-stage pattern is especially compelling for a local research library: lexical candidate generation can remain instantaneous and deterministic, while expensive semantic reasoning is bounded by a small result set.

Hybrid retrieval

Hybrid systems run lexical and semantic retrieval in parallel and fuse their rankings. Reciprocal Rank Fusion (RRF) is popular because it combines ranks without requiring raw BM25 scores and cosine similarities to share a numeric scale.

RRF(d) = Σr ∈ rankers 1 / (k + rankr(d))

Raw-score addition is usually a poor default because BM25 scores and vector similarities have unrelated distributions. Rank fusion side-steps that scale mismatch.

from collections import defaultdict


def reciprocal_rank_fusion(
    rankings: list[list[int]],
    k: int = 60,
) -> list[tuple[int, float]]:
    """
    rankings: one ordered document-id list per retriever.
    Returns documents ordered by fused RRF score.
    """
    scores: dict[int, float] = defaultdict(float)

    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] += 1.0 / (k + rank)

    return sorted(
        scores.items(),
        key=lambda item: item[1],
        reverse=True,
    )

SQLite-local hybrid retrieval is an active 2026 direction

Two very recent 2026 preprints are notable specifically because they combine SQLite FTS5 with vector retrieval rather than replacing it. The scrydb paper, posted in August 2026, describes lexical, semantic and hybrid retrieval in SQLite using FTS5 together with sqlite-vec. vstash, posted earlier in 2026, likewise studies a local-first FTS5/vector/RRF architecture. [scrydb preprint, 2026] [vstash preprint, 2026]

These are recent preprints rather than grounds for claiming a settled production standard. Their relevance here is architectural: current research continues to explore FTS5 plus semantic retrieval, which is evidence against the simplistic notion that lexical retrieval has become obsolete.

Retrieval approaches compared in 2026
Approach Exact terminology Semantic paraphrase Index/runtime complexity Explainability Local-library fit
SQLite FTS5 + BM25 Excellent Limited without expansion Very low High Excellent baseline
Lucene/Elasticsearch BM25 Excellent Limited without expansion Moderate to high operationally High Excellent when serving/concurrency needs justify it
Dense dual encoder Variable Excellent potential Embedding model + vector index Lower Strong augmentation for conceptual discovery
SPLADE / learned sparse Strong Strong through learned expansion Model-dependent indexing Moderate/high Useful when quality justifies model complexity
ColBERT-style late interaction Strong Strong Higher storage/serving complexity Moderate Advanced high-quality search
Cross-encoder reranking Strong over candidate set Very strong potential High per-pair compute Variable Best as a second stage, not corpus-wide exhaustive search
Hybrid BM25 + dense + reranker Excellent Excellent potential Highest complexity Mixed Best no-budget-constraint quality architecture when justified

Pros and cons of BM25/FTS5 in 2026

Practical strengths and limitations
Strength Limitation
No model inference is required at indexing or query time. Vocabulary mismatch: semantically equivalent wording may not share tokens.
Excellent behaviour for rare words, names, identifiers and technical vocabulary. Bag-of-words scoring alone has no deep semantic understanding.
Deterministic and comparatively easy to inspect. Built-in FTS5 k1/b are fixed.
FTS5 combines search and relational data in one embedded database. SQLite is not a distributed search service.
Phrase, Boolean, prefix, proximity and snippet functionality is built in. Tokenizer choices may require care for multilingual data and identifiers.
Low operational burden and strong privacy for local data. Very large/high-concurrency systems may justify specialised serving infrastructure.

Recommended architectures, migration strategies and final assessment

Best design for checking references in a local library

For the specific problem of checking references against a local library, the strongest architecture is layered. Retrieval and verification should be separate operations.

Recommended retrieval order First attempt exact DOI/ISBN or other canonical identifier lookup. If that fails, use FTS5 BM25 over heavily weighted title and author fields plus a lower-weight abstract. Then apply structured year/author/title consistency checks. Add dense retrieval only for cases where citation text is incomplete, paraphrased or conceptually described rather than lexically recognisable.

This approach exploits what each component is best at. A DOI equality match is stronger evidence than any relevance score. A title-and-author BM25 match is highly discriminative for damaged or incomplete citations. Dense retrieval is valuable when a user remembers what a paper was about but not what it was called.

A staged migration path

Migration and augmentation roadmap
Stage Architecture Trigger for moving further
Lexical foundation Normal SQLite metadata + FTS5 + BM25 Start here for almost every local reference library.
Quality tuning Field weights, tokenizer tuning, prefixes, phrase handling, judgement set When poor ranking can be fixed without adding models.
Semantic sidecar Embeddings + local vector index alongside FTS5 When relevant results frequently use different vocabulary.
Hybrid fusion BM25 top-k + dense top-k + RRF When lexical and dense systems retrieve complementary candidates.
Neural reranking Cross-encoder/LLM-style relevance model over fused top 20–100 When first-stage recall is good but top-result ordering remains weak.
Serving migration Dedicated lexical/vector/search infrastructure When concurrency, availability, replication or corpus scale—not ranking quality alone—outgrow the embedded design.

Hybrid candidate generation example

def hybrid_search(query: str, limit: int = 20):
    # First-stage lexical retrieval.
    lexical = fts_search(query, limit=100)

    # Independent semantic retrieval.
    query_vector = embed(query)
    semantic = vector_search(query_vector, limit=100)

    lexical_ids = [hit.id for hit in lexical]
    semantic_ids = [hit.id for hit in semantic]

    fused = reciprocal_rank_fusion(
        [lexical_ids, semantic_ids],
        k=60,
    )

    candidate_ids = [doc_id for doc_id, _ in fused[:50]]

    # Optional expensive second stage.
    reranked = cross_encoder_rerank(
        query=query,
        document_ids=candidate_ids,
    )

    return reranked[:limit]

The key engineering property is that each stage has a bounded job: FTS5 and the vector index maximise recall cheaply; fusion protects candidates that only one retriever sees; the expensive reranker examines only a small set.

Configuration by size and budget

“No stated budget constraint” does not mean “use the most complex system”. Complexity itself has a cost. The no-constraint recommendations below assume retrieval quality justifies the additional engineering and compute.

Recommended configurations for local libraries
Scale Low/minimal budget Moderate budget No budget constraint
Small: ≤ ~100k records SQLite + exact metadata indexes + FTS5 BM25. unicode61, detail=full, title/author boosts. Same lexical core plus local embeddings for semantic fallback. FTS5 + dense retrieval + RRF; rerank only if measured quality gains justify it. A search server is usually unnecessary.
Medium: ~100k–2m External-content FTS5, selective prefixes, WAL, batch writes, index maintenance and careful benchmarking. FTS5 + ANN/vector sidecar + RRF; local embedding model where privacy matters. Hybrid retrieval plus cross-encoder reranking; benchmark both SQLite-local and dedicated-search implementations.
Large local: ~2m–10m+ Benchmark FTS5 aggressively on SSD before adding infrastructure; reduce unnecessary index detail/prefixes only after measuring quality. Dedicated lexical engine may become attractive for operational reasons; semantic index on the same machine or service. Lucene/Elasticsearch/OpenSearch/Vespa-class lexical serving plus ANN retrieval and neural reranking where quality requirements warrant it.
High concurrency / distributed SQLite may be the wrong serving layer even when corpus size is modest. Replicated/dedicated search service with BM25. Distributed hybrid retrieval, monitoring, reranking and online evaluation.

Recommended FTS5 defaults for reference data

Starting settings for bibliographic search
Setting Starting choice Reason
Tokenizer unicode61 remove_diacritics 2 Strong general-purpose Unicode-oriented starting point.
Title weight ~8 Title terms are highly discriminative for known-item lookup.
Author weight ~5 Strong bibliographic evidence without overwhelming title evidence.
Abstract weight ~1 Useful recall source but much longer and noisier.
Identifier text weight High, e.g. ~10 Identifier fragments can be highly distinctive; still keep exact normalised columns separately.
detail full Preserves phrase/proximity behaviour useful for titles.
columnsize 1 Supports efficient length statistics for BM25.
Prefix indexes 2 3 4 only if UI uses them Avoid paying storage/write cost for unused prefix patterns.
Journal mode WAL for suitable local applications Improves read/write coexistence; benchmark the actual workload.

The numeric field weights in this table are intentionally presented as starting values, not empirical universal optima. Tune them using held-out relevance judgements from the target library.

When not to add vectors

Dense embeddings are not automatically an upgrade. Do not add them merely because they are modern. If nearly all user tasks are DOI lookup, title matching, author matching and precise technical terminology, vectors may add storage, model lifecycle management, approximate-nearest-neighbour tuning and harder-to-debug ranking without materially improving retrieval.

Before augmentation, inspect FTS5 failures. Many apparent “semantic search” problems are actually tokenisation, metadata, query parsing, spelling or weighting problems. Fixing the lexical layer is cheaper and improves the hybrid system too.

When vectors are justified

Add semantic retrieval when relevance judgements show genuine vocabulary mismatch. Typical examples are natural-language questions against terse paper titles, conceptual searches such as “papers about making retrieval robust to paraphrases”, or searching across translations and terminology shifts. A dense model can retrieve candidates whose words do not overlap the query at all — exactly the case BM25 cannot solve by itself.

When reranking is justified

Add a reranker when candidate recall is already high but ordering is the problem. A reranker cannot recover a document that neither BM25 nor the dense retriever supplied. This is why candidate-set recall should be measured before investing in a more expensive second stage.

Final assessment

BM25 remains one of the best baseline ranking functions available in 2026. Its longevity is not accidental. It encodes three durable pieces of retrieval evidence — rarity, within-document frequency with diminishing returns, and length normalisation — using a tiny amount of computation and corpus statistics. Its continuing presence as the default similarity in Elasticsearch and as a core Lucene class demonstrates that modern search infrastructure still treats BM25 as a production algorithm, not merely an academic baseline. [Elasticsearch] [Lucene]

SQLite FTS5 remains an unusually capable implementation choice when search belongs inside a local application. It supplies an inverted index, positional phrase/proximity matching, Unicode-oriented tokenisation, prefix and trigram options, snippets, built-in BM25 ranking, configurable column weights, vocabulary introspection and a C extension API without requiring a separate search daemon. [SQLite FTS5]

Its built-in ranking does have constraints: k1=1.2 and b=0.75 are fixed, IDF behaviour differs from Lucene, column weighting is not the same as BM25F, and an embedded SQLite database is not a substitute for distributed search infrastructure when the application actually needs that operational model.

For checking references in a local library, those constraints are rarely a reason to reject it. Exact identifiers should be normal SQL fields; FTS5 should search titles, authors, abstracts and citation strings; phrase support should remain enabled; and relevance should be evaluated with known-item and semantic query sets. Only after measuring lexical misses should semantic retrieval be added.

Bottom line For a small-to-medium local reference library, begin with SQLite metadata indexes + FTS5 + BM25. For the highest retrieval quality with no strict budget constraint, preserve that lexical layer and augment it with dense retrieval + reciprocal-rank fusion + optional neural reranking. BM25 should usually become one component of a modern hybrid system, not something that must be migrated away from.

Primary and authoritative sources

Robertson, S. E. & Spärck Jones, K. (1976). Relevance Weighting of Search Terms. Journal of the American Society for Information Science. DOI.

Robertson, S. E.; Walker, S.; Jones, S.; Hancock-Beaulieu, M. M.; Gatford, M. (1994). Okapi at TREC-3. NIST Third Text REtrieval Conference. NIST proceedings.

Robertson, S. & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval. DOI.

SQLite Project. SQLite FTS5 Extension. Canonical specification covering syntax, tokenizers, BM25, ranking, extension APIs and on-disk structures. Official documentation.

SQLite Project. SQLite Release History. Primary chronology for FTS5’s 2015 introduction and later features. Official release history.

SQLite Project. fts5_aux.c. Source implementation of FTS5 BM25 including the IDF floor, hard-coded k1/b and score negation. SQLite source mirror.

Apache Lucene. BM25Similarity, Lucene 10.3.1. Current implementation documentation, parameter semantics and Lucene’s positive IDF variant. Lucene API.

Elastic. Similarity settings. Current documentation identifying BM25 as Elasticsearch’s default similarity and documenting k1/b. Elastic documentation.

Thakur, N. et al. (2021). BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. arXiv:2104.08663.

Karpukhin, V. et al. (2020). Dense Passage Retrieval for Open-Domain Question Answering. arXiv:2004.04906.

Formal, T. et al. (2021). SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval. arXiv:2109.10086.

Santhanam, K. et al. (2021/2022). ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction. arXiv:2112.01488.

Breuer, T. (2026). SQLite is Enough. Lexical, Semantic, and Hybrid Search with scrydb. Recent preprint; cited as emerging 2026 work rather than an established standard. arXiv:2608.24060.

Steffens, J. (2026). vstash: Local-First Hybrid Retrieval with Adaptive Fusion for LLM Agents. Recent preprint examining FTS5/vector/RRF retrieval. arXiv:2604.15484.

Research note: recent 2026 preprints are clearly labelled as such and should not be treated as equivalent in evidential maturity to SQLite’s canonical documentation or the foundational peer-reviewed information-retrieval literature. Generic latency claims have deliberately been avoided because corpus composition, hardware, cache state, index configuration and workload can change FTS5 performance by orders of magnitude; benchmark the target library rather than extrapolating from unrelated systems.

Leave a reply

Your email address will not be published. Required fields are marked *