Search in your own documents
Find the right page in an internal corpus — a handbook, a knowledge base, documentation — from what a reader types into a search box.
No AI needed
FreeInstantYour data never leaves
The verdict
N0 wins because the index is already there: the FTS5 table updates in the same transaction as the document, is backed up with it, and adds no service to operate. More to the point, it has the property a search box needs and N2 gives up: when it has nothing, it says so. N1 costs an extra index to rebuild outside the database, and does not return FTS5’s scores: it is a BM25 whose matching rule, idf and weights are lines you own — reach for it the day you have to change the tokenizer or the matching rule, not to get a better default.
The database’s own full-text index, ranked by BM25
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Search your own documents with the full-text index of the database you
already run.
Rung N0. SQLite ships FTS5: a virtual table holding an inverted index, and a
bm25() ranking function. Postgres has tsvector and ts_rank, MySQL has
FULLTEXT ... IN NATURAL LANGUAGE MODE. Whatever is under your application
already has an index and a ranking.
Two things are worth knowing before you use it.
First, bm25() returns a negative number, the best match being the most
negative. Negate it, as below, and a bigger score means a better match again.
Second, MATCH takes a query language, not a string. A user typing a double
quote, AND or NEAR must never be handed to it raw: quoting each token turns
the query back into plain words, and turns a syntax error into a search.
That quoting is also what this file gives up: a quoted token is matched whole.
For a search box that answers as the reader types, FTS5 has prefix tokens — a
`*` placed after a quoted string, as in `"cong"*` — and this snippet does not
use them, because a prefix on every term widens every search, not only the one
being typed.
"""
import sqlite3
import unicodedata
# A title match counts for more than a body match. bm25() takes one weight per
# column, in the order the columns were declared.
COLUMN_WEIGHTS = (0.0, 10.0, 1.0) # doc_id, title, body
CREATE = (
"CREATE VIRTUAL TABLE documents USING fts5("
"doc_id UNINDEXED, title, body, tokenize='unicode61 remove_diacritics 2')"
)
def tokenise(text: str) -> list[str]:
"""Lower case, strip accents from Latin letters, keep letters and digits.
The folding of the tokenizer declared above, so what we look up is spelled
the way the index stored it: accents come off Latin letters only, and a
ligature such as "fi" or a full-width letter is kept as it is.
"""
kept: list[str] = []
for char in unicodedata.normalize("NFD", text.lower()):
if unicodedata.combining(char) and kept and "LATIN" in unicodedata.name(kept[-1], ""):
continue
kept.append(char)
letters = unicodedata.normalize("NFC", "".join(kept))
return "".join(c if c.isalnum() else " " for c in letters).split()
def query_terms(query: str) -> list[str]:
"""The tokens a query is searched with."""
terms = tokenise(query)
# A one-letter token is what an elision ("l'accord") or a possessive
# ("manager's") leaves behind. The implicit AND would require it, and empty
# the results: it is dropped, unless the query holds nothing else.
return [term for term in terms if len(term) > 1] or terms
def build_index(documents: list[dict]) -> sqlite3.Connection:
"""Documents are dicts with keys id, title and body.
In memory here so the snippet runs alone. In production the FTS5 table is
the documents table itself, or an external-content table kept in step by
triggers: either way it changes in the transaction that changes the document.
"""
connection = sqlite3.connect(":memory:")
connection.execute(CREATE)
connection.executemany(
"INSERT INTO documents (doc_id, title, body) VALUES (?, ?, ?)",
[(d["id"], d["title"], d["body"]) for d in documents],
)
return connection
def search(connection: sqlite3.Connection, query: str, limit: int = 5) -> list[dict]:
"""Return the best matches, best first, as dicts with keys id and score."""
if limit < 0:
# SQLite reads LIMIT -1 as "no limit": refuse it rather than return everything.
raise ValueError("limit must be zero or more")
terms = query_terms(query)
if not terms:
# An empty MATCH is a syntax error, not an empty result set.
return []
# Quoted terms, separated by a space: FTS5 requires all of them to appear.
match = " ".join(f'"{term}"' for term in terms)
rows = connection.execute(
"SELECT doc_id, -bm25(documents, ?, ?, ?) AS score FROM documents "
"WHERE documents MATCH ? ORDER BY score DESC, doc_id LIMIT ?",
(*COLUMN_WEIGHTS, match, limit),
).fetchall()
return [{"id": doc_id, "score": round(score, 4)} for doc_id, score in rows]JavaScript
/**
* Search your own documents with the full-text index of the database you
* already run.
*
* Rung N0. The Python version of this snippet is four SQL statements against
* SQLite's FTS5: a virtual table holding an inverted index, and a bm25()
* ranking function. Postgres has tsvector and ts_rank, MySQL has FULLTEXT ...
* IN NATURAL LANGUAGE MODE. Whatever is under your application already has an
* index and a ranking: on this rung you write SQL, not an algorithm.
*
* Node 22 ships `node:sqlite` (behind --experimental-sqlite before 22.13.0).
* The SQLite it bundles has no FTS5 module before Node 22.16.0, and has one
* from 22.16.0 on: there, the SQL of the Python version runs as it is and
* returns the same scores as this file. This file writes out what the FTS5
* table does all the same: the same tokenizer (lower case, accents off Latin
* letters), the same implicit AND between terms, the same BM25 with the same
* constants and column weights. That is a choice, not a necessity: it imports
* nothing, so it runs wherever JavaScript runs, a browser included, and every
* step of the ranking can be read. Read it as the documentation of the SQL. On
* a server with Node 22.16.0 or later, deploy the SQL, not this file.
*
* One thing the SQL has and this does not: prefix tokens. For a search box that
* answers as the reader types, FTS5 matches a prefix when a `*` follows a
* quoted string, as in `"cong"*`. Neither version uses them, because a prefix
* on every term widens every search, not only the one being typed.
*/
// SQLite's fts5 defaults. Its bm25() is negated so that ORDER BY works
// ascending; the scores below are the plain ones, bigger being better.
const K1 = 1.2;
const B = 0.75;
// A title match counts for more than a body match.
const COLUMN_WEIGHTS = { title: 10, body: 1 };
/**
* Lower case, strip accents from Latin letters, keep letters and digits. A
* ligature such as "fi" or a full-width letter is kept as it is, as FTS5 does.
*/
export function tokenise(text) {
return String(text).toLowerCase().normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, (_, letter) => letter)
.normalize('NFC')
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
}
/** The tokens a query is searched with. */
export function queryTerms(query) {
const terms = tokenise(query);
// A one-letter token is what an elision ("l'accord") or a possessive
// ("manager's") leaves behind. The implicit AND would require it, and empty
// the results: it is dropped, unless the query holds nothing else.
const words = terms.filter((term) => [...term].length > 1); // code points, as in Python
return words.length > 0 ? words : terms;
}
/** Documents are objects with keys id, title and body. */
export function buildIndex(documents) {
const documentFrequency = new Map();
const rows = documents.map((document) => {
const counts = new Map(); // term -> frequency, weighted by column
let length = 0;
for (const [field, weight] of Object.entries(COLUMN_WEIGHTS)) {
for (const term of tokenise(document[field] ?? '')) {
counts.set(term, (counts.get(term) ?? 0) + weight);
length += 1;
}
}
for (const term of counts.keys()) {
documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
}
return { id: document.id, counts, length };
});
const total = rows.reduce((sum, row) => sum + row.length, 0);
return { rows, documentFrequency, averageLength: rows.length ? total / rows.length : 0 };
}
/** Return the best matches, best first, as objects with keys id and score. */
export function search(index, query, limit = 5) {
// A negative slice would silently drop the last results: refuse it.
if (limit < 0) throw new RangeError('limit must be zero or more');
const terms = queryTerms(query);
if (terms.length === 0) return [];
const results = [];
for (const row of index.rows) {
// Implicit AND: a document missing one term of the query is not a result.
if (!terms.every((term) => row.counts.has(term))) continue;
let score = 0;
for (const term of terms) {
const hits = index.documentFrequency.get(term);
// A term carried by more than half the documents separates nothing.
// fts5 floors its weight rather than letting it go negative.
const idf = Math.max(Math.log((index.rows.length - hits + 0.5) / (hits + 0.5)), 1e-6);
const frequency = row.counts.get(term);
const norm = 1 - B + (B * row.length) / index.averageLength;
score += (idf * frequency * (K1 + 1)) / (frequency + K1 * norm);
}
results.push({ id: row.id, score });
}
results.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1));
return results.slice(0, limit).map(({ id, score }) => ({ id, score: Math.round(score * 1e4) / 1e4 }));
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
Six pages indexed. The score rises when the words searched for are rare in the handbook; a word found in half the pages or more adds nothing to it, and a word in a title counts ten times a word in the body.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
What it returns
| Handbook page | BM25 score |
|---|---|
| Expense claims | 5.14 |
One page out of 6 carries every word searched for.
Here it breaksSee the level above
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the index is a table in the database you already run, and follows its backups and its deletions
- Does not stand in for applying each document’s access rights to the results: the snippet searches the whole corpus and knows of none
Breaking point
Search by meaning: the reader asks for the thing, the document names it otherwise, and the index has nothing to match. In the test, “combien de vacances puis-je poser” returns nothing although the page is titled “Congés payés”, and the implicit AND of FTS5 deepens the silence — “congés responsable” returns nothing either, because no single page carries both words. That AND is a choice, not a fate: the usual practice is to try every term first, then fall back on any of them when the first query comes back empty.
When to move up a level
Your search logs show queries returning nothing at all for documents you know exist: your readers do not call them what they are called.
The four options
- RecommendedN0The database’s own full-text index, ranked by BM25Free · Instant
- N1Inverted index and BM25 written by handnégligeable · Instant
- N2Self-hosted encoder, fused with the full-text rankingInexpensive · Fast
- N3Retrieval-augmented generation, with cited answersélevé · About a second
The other options
N1Inverted index and BM25 written by handnégligeable · Instant
Inverted index and BM25 written by hand
Lightweight classic modelN1négligeableInstant
Python
"""
Search your own documents with an inverted index and a BM25 you wrote.
Rung N1. Not because the index of N0 is bad — N0 is still the recommendation —
but because the code below is a BM25 you can read, and reading it once tells
you why a document ranked where it did. It does not reproduce FTS5 to the
decimal: the matching rule, the idf, the length and the title weight all
differ, and each is a line you can change.
Three decisions are yours here, and they were the engine's before.
The tokenizer: what counts as a word, which accents are folded, which terms are
dropped. The matching rule: this one keeps any document carrying at least one
term, where FTS5 demands all of them. And the ranking: k1 saturates repetition,
b corrects for document length, and the field weights say how much a title is
worth. Change one and the order changes; that is the point of owning it.
"""
from __future__ import annotations
import math
import unicodedata
from collections import Counter
FIELD_WEIGHTS = {"title": 3.0, "body": 1.0}
def tokenise(text: str) -> list[str]:
"""Lower case, strip accents, keep letters and digits."""
decomposed = unicodedata.normalize("NFKD", text.lower())
letters = "".join(c for c in decomposed if not unicodedata.combining(c))
return "".join(c if c.isalnum() else " " for c in letters).split()
def build_index(documents: list[dict], weights: dict | None = None) -> dict:
"""Build the postings: for each term, the documents carrying it."""
weights = weights or FIELD_WEIGHTS
postings: dict[str, dict[str, float]] = {}
lengths: dict[str, float] = {}
for document in documents:
counts: Counter[str] = Counter()
for field, weight in weights.items():
for term in tokenise(document.get(field) or ""): # a NULL column is empty
counts[term] += weight
for term, frequency in counts.items():
postings.setdefault(term, {})[document["id"]] = frequency
lengths[document["id"]] = sum(counts.values())
average = sum(lengths.values()) / len(lengths) if lengths else 0.0
return {"postings": postings, "lengths": lengths, "average_length": average}
def search(index: dict, query: str, limit: int = 5, k1: float = 1.2, b: float = 0.75) -> list[dict]:
"""Return the best matches, best first, each with the score it was given.
`terms` says what each query word contributed. A ranking nobody can explain
is a ranking nobody can fix.
"""
if limit < 0:
raise ValueError("limit must be zero or more")
total = len(index["lengths"])
scores: dict[str, float] = {}
contributions: dict[str, dict[str, float]] = {}
# dict.fromkeys keeps the order and drops repeats: a word typed twice is
# not twice as important.
for term in dict.fromkeys(tokenise(query)):
postings = index["postings"].get(term)
if not postings:
continue
# The rarer the term, the more a match on it means.
idf = math.log(1 + (total - len(postings) + 0.5) / (len(postings) + 0.5))
for doc_id, frequency in postings.items():
norm = 1 - b + b * index["lengths"][doc_id] / index["average_length"]
share = idf * frequency * (k1 + 1) / (frequency + k1 * norm)
scores[doc_id] = scores.get(doc_id, 0.0) + share
contributions.setdefault(doc_id, {})[term] = round(share, 4)
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
return [
{"id": doc_id, "score": round(score, 4), "terms": contributions[doc_id]}
for doc_id, score in ranked[:limit]
]JavaScript
/**
* Search your own documents with an inverted index and a BM25 you wrote.
*
* Rung N1. Not because the index of N0 is bad — N0 is still the
* recommendation — but because the code below is a BM25 you can read, and
* reading it once tells you why a document ranked where it did. It does not
* reproduce FTS5 to the decimal: the matching rule, the idf, the length and
* the title weight all differ, and each is a line you can change.
*
* Three decisions are yours here, and they were the engine's before.
*
* The tokenizer: what counts as a word, which accents are folded, which terms
* are dropped. The matching rule: this one keeps any document carrying at
* least one term, where FTS5 demands all of them. And the ranking: k1
* saturates repetition, b corrects for document length, and the field weights
* say how much a title is worth. Change one and the order changes; that is the
* point of owning it.
*/
export const FIELD_WEIGHTS = { title: 3, body: 1 };
/** Lower case, strip accents, keep letters and digits. */
export function tokenise(text) {
return String(text).toLowerCase().normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
}
/** Build the postings: for each term, the documents carrying it. */
export function buildIndex(documents, weights = FIELD_WEIGHTS) {
const postings = new Map(); // term -> Map(document id -> weighted frequency)
const lengths = new Map();
for (const document of documents) {
const counts = new Map();
for (const [field, weight] of Object.entries(weights)) {
for (const term of tokenise(document[field] ?? '')) {
counts.set(term, (counts.get(term) ?? 0) + weight);
}
}
for (const [term, frequency] of counts) {
if (!postings.has(term)) postings.set(term, new Map());
postings.get(term).set(document.id, frequency);
}
lengths.set(document.id, [...counts.values()].reduce((sum, v) => sum + v, 0));
}
const total = [...lengths.values()].reduce((sum, v) => sum + v, 0);
return { postings, lengths, averageLength: lengths.size ? total / lengths.size : 0 };
}
/**
* Return the best matches, best first, each with the score it was given.
*
* `terms` says what each query word contributed. A ranking nobody can explain
* is a ranking nobody can fix.
*/
export function search(index, query, { limit = 5, k1 = 1.2, b = 0.75 } = {}) {
// A negative slice would silently drop the last results: refuse it.
if (limit < 0) throw new RangeError('limit must be zero or more');
const scores = new Map();
const contributions = new Map();
// A Set keeps the order and drops repeats: a word typed twice is not twice
// as important.
for (const term of new Set(tokenise(query))) {
const postings = index.postings.get(term);
if (!postings) continue;
// The rarer the term, the more a match on it means.
const idf = Math.log(1 + (index.lengths.size - postings.size + 0.5) / (postings.size + 0.5));
for (const [id, frequency] of postings) {
const norm = 1 - b + (b * index.lengths.get(id)) / index.averageLength;
const share = (idf * frequency * (k1 + 1)) / (frequency + k1 * norm);
scores.set(id, (scores.get(id) ?? 0) + share);
if (!contributions.has(id)) contributions.set(id, {});
contributions.get(id)[term] = Math.round(share * 1e4) / 1e4;
}
}
return [...scores.entries()]
.sort((a, b2) => b2[1] - a[1] || (a[0] < b2[0] ? -1 : 1))
.slice(0, limit)
.map(([id, score]) => ({ id, score: Math.round(score * 1e4) / 1e4, terms: contributions.get(id) }));
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Low footprint
What this adds to your regulatory scope
- No specific scope added: the corpus never leaves your infrastructure
- The index lives outside the database and no longer follows its deletions: a removed document stays in it until the next rebuild
Breaking point
Writing the index yourself does not close the gap N0 showed; it hands it to you. “vacances” still finds nothing, and the singular “congé” finds nothing either where the handbook writes the plural — no more than at N0 — because nothing in this code knows French morphology. Stemming, elision, synonyms, stop words — each becomes a rule you write, test and maintain, for the language of every document you hold.
When to move up a level
You find yourself writing stemming rules and synonym lists, language by language, to catch queries that come back empty — and your database does not hand them to you: with PostgreSQL, `to_tsvector('french', …)` stems and drops stop words on its own, where FTS5 only offers a `porter` tokenizer its documentation reserves for English.
N2Self-hosted encoder, fused with the full-text rankingInexpensive · Fast
Self-hosted encoder, fused with the full-text ranking
Small self-hosted specialised modelN2InexpensiveFast
Python
"""
Add a vector leg to the full-text search you already have.
Rung N2. N0 and N1 match words. A reader who asks for "vacances" where the
handbook says "congés payés" gets nothing, and that gap is what this rung
exists to close: a self-hosted sentence encoder, built for semantic search,
maps each text to a dense vector, and texts are compared on those vectors
rather than on the words they share.
It closes it in addition, not instead. Keyword search finds what shares the
words and stays silent otherwise; vector search ranks every document, always.
Fusing the two rankings keeps the first keyword result ahead of any page the
keywords did not find, and borrows the reach of the vectors; further down the
list, each leg weighs as much as the other.
What the rung really costs is not the arithmetic below. It is the deployment:
weights to download and load, a process to keep warm, and document vectors to
recompute when a document changes — next to a full-text index the database
already maintains for free.
"""
from __future__ import annotations
import functools
import math
import weakref
# A multilingual model: its card lists French among its languages, and the
# handbook in the tests is French.
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
# Per encoder, the unit vector of each document text seen in the last search:
# a document is encoded again only once its text has changed.
_document_vectors: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
class EncodingFailed(RuntimeError):
"""The encoder could not be run, or returned something unusable."""
@functools.cache
def default_encoder(): # pragma: no cover - loads the weights
"""Loaded once per process, then kept warm."""
from sentence_transformers import SentenceTransformer
return SentenceTransformer(MODEL_NAME)
def unit(vector) -> list[float]:
"""Normalise once, so that a cosine is a dot product afterwards."""
values = [float(v) for v in vector]
norm = sum(v * v for v in values) ** 0.5
return [v / norm for v in values] if norm else values
def vector_ranking(query: str, documents: list[dict], encoder) -> list[str]:
"""Rank every document by cosine similarity to the query."""
texts = [f"{d['title']} {d['body']}" for d in documents]
known = _document_vectors.get(encoder, {})
missing = [text for text in dict.fromkeys(texts) if text not in known]
try:
# One batched call: the query travels with the documents not yet encoded.
vectors = [unit(v) for v in encoder.encode(missing + [query])]
except Exception as error: # noqa: BLE001 - a model failure is not the caller's fault
raise EncodingFailed(str(error)) from error
if len(vectors) != len(missing) + 1:
raise EncodingFailed(f"{len(vectors)} vectors returned for {len(missing) + 1} texts")
*fresh, query_vector = vectors
known = {**known, **dict(zip(missing, fresh))}
kept = {text: known[text] for text in texts}
size = len(query_vector)
if not all(len(v) == size and all(map(math.isfinite, v)) for v in [*kept.values(), query_vector]):
raise EncodingFailed("vectors of different sizes, or values that are not finite numbers")
_document_vectors[encoder] = kept
similarities = [
(document["id"], sum(x * y for x, y in zip(kept[text], query_vector)))
for document, text in zip(documents, texts)
]
# Ties are broken on the identifier, so two runs give the same order.
similarities.sort(key=lambda pair: (-pair[1], pair[0]))
return [doc_id for doc_id, _ in similarities]
def hybrid_search(query, documents, keyword_ids, encoder=None, *, limit=5, k=60) -> list[dict]:
"""Fuse the ranking your full-text search returned with a vector ranking.
`keyword_ids` is what N0 already gave you, best first.
"""
if not documents:
return []
if encoder is None:
encoder = default_encoder()
# Reciprocal rank fusion: each list votes with 1/(k + rank). Nothing has to
# be rescaled, because a BM25 score and a cosine are never comparable, and
# k says how much being second is worth compared with being first.
scores: dict[str, float] = {}
for ranking in (list(keyword_ids), vector_ranking(query, documents, encoder)):
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
return [{"id": doc_id, "score": round(score, 6)} for doc_id, score in ranked[:limit]]JavaScript
/**
* Add a vector leg to the full-text search you already have.
*
* Rung N2. N0 and N1 match words. A reader who asks for "vacances" where the
* handbook says "congés payés" gets nothing, and that gap is what this rung
* exists to close: a self-hosted sentence encoder, built for semantic search,
* maps each text to a dense vector, and texts are compared on those vectors
* rather than on the words they share.
*
* It closes it in addition, not instead. Keyword search finds what shares the
* words and stays silent otherwise; vector search ranks every document,
* always. Fusing the two rankings keeps the first keyword result ahead of any
* page the keywords did not find, and borrows the reach of the vectors;
* further down the list, each leg weighs as much as the other.
*
* What the rung really costs is not the arithmetic below. It is the
* deployment: weights to download and load, a process to keep warm, and
* document vectors to recompute when a document changes — next to a full-text
* index the database already maintains for free.
*/
// A multilingual model: its card lists French among its languages, and the
// handbook in the tests is French.
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
// Per encoder, the unit vector of each document text seen in the last search:
// a document is encoded again only once its text has changed.
const documentVectors = new WeakMap();
export class EncodingFailed extends Error {}
let defaultEncoder;
/** Loaded once per process, then kept warm. */
function loadDefaultEncoder() {
defaultEncoder ??= (async () => {
const { pipeline } = await import('@huggingface/transformers');
const extract = await pipeline('feature-extraction', MODEL_NAME);
return { encode: async (batch) => (await extract(batch, { pooling: 'mean' })).tolist() };
})().catch((error) => {
defaultEncoder = undefined; // a failed load is tried again on the next search
throw error;
});
return defaultEncoder;
}
/** Normalise once, so that a cosine is a dot product afterwards. */
export function unit(vector) {
const values = Array.from(vector, Number);
const norm = Math.hypot(...values);
return norm ? values.map((value) => value / norm) : values;
}
/** Rank every document by cosine similarity to the query. */
export async function vectorRanking(query, documents, encoder) {
const texts = documents.map((document) => `${document.title} ${document.body}`);
const known = documentVectors.get(encoder) ?? new Map();
const missing = [...new Set(texts)].filter((text) => !known.has(text));
let vectors;
try {
// One batched call: the query travels with the documents not yet encoded.
vectors = (await encoder.encode([...missing, query])).map(unit);
} catch (error) {
throw new EncodingFailed(String(error));
}
if (vectors.length !== missing.length + 1) {
throw new EncodingFailed(`${vectors.length} vectors returned for ${missing.length + 1} texts`);
}
const queryVector = vectors.pop();
// A copy, not the served map: a vector that turns out to be unusable must not
// stay behind in the cache, where it would make this page unfindable, and
// every later search that touches it fail, until the process restarts.
// Python writes into a new dictionary for the same reason.
const fresh = new Map(known);
missing.forEach((text, i) => fresh.set(text, vectors[i]));
const kept = new Map(texts.map((text) => [text, fresh.get(text)]));
const usable = (v) => v.length === queryVector.length && v.every(Number.isFinite);
if (![...kept.values(), queryVector].every(usable)) {
throw new EncodingFailed('vectors of different sizes, or values that are not finite numbers');
}
documentVectors.set(encoder, kept);
const similarities = documents.map((document, i) => [
document.id,
kept.get(texts[i]).reduce((sum, value, d) => sum + value * queryVector[d], 0),
]);
// Ties are broken on the identifier, so two runs give the same order.
similarities.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
return similarities.map(([id]) => id);
}
/**
* Fuse the ranking your full-text search returned with a vector ranking.
*
* @param {string} query
* @param {object[]} documents
* @param {string[]} keywordIds what N0 already gave you, best first
* @param {object} options
* @param {{encode: Function}} [options.encoder] injected so this can be tested
* without downloading a model; defaults to a real local encoder
*/
export async function hybridSearch(query, documents, keywordIds, { encoder, limit = 5, k = 60 } = {}) {
if (documents.length === 0) return [];
encoder ??= await loadDefaultEncoder();
// Reciprocal rank fusion: each list votes with 1/(k + rank). Nothing has to
// be rescaled, because a BM25 score and a cosine are never comparable, and k
// says how much being second is worth compared with being first.
const scores = new Map();
const rankings = [[...keywordIds], await vectorRanking(query, documents, encoder)];
for (const ranking of rankings) {
ranking.forEach((id, index) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + index + 1));
});
}
return [...scores.entries()]
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.slice(0, limit)
.map(([id, score]) => ({ id, score: Math.round(score * 1e6) / 1e6 }));
}What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- High footprint
What this adds to your regulatory scope
- Processing on your own infrastructure: the corpus goes to no third party
- The model weights come from a third party, with the licence and provenance that implies checking
- The vectors are derived from the documents and carry their sensitivity: they fall in the same scope as the corpus
Breaking point
Cosine similarity is defined for every pair of texts, so the vector leg always has an answer and “no match” stops existing — in the test the handbook says nothing about the colour of the office walls, and the fusion still puts a page first. And the fused score cannot carry a floor: the top page scores the same for this question as for one the handbook does answer, because a floor would have to sit on the cosine, which the snippet does not return.
When to move up a level
Your readers want a sentence for an answer, not a list of documents to open.
N3Retrieval-augmented generation, with cited answersélevé · About a second
Retrieval-augmented generation, with cited answers
General-purpose LLM APIN3élevéAbout a second
Python
"""
Answer a question from your own documents: retrieval-augmented generation.
Rung N3. The model only knows of your handbook what the prompt carries. So
retrieval comes first — N0, N1 or N2 finds the passages — and the model writes
the sentence from them.
Everything below is plumbing, and the plumbing is where the bugs are: how many
passages to send and how long each may be, what the model is allowed to say
when they do not answer, how to decode a reply that is only probably JSON, and
what to do when it cites a passage nobody sent.
That last check earns its lines, and it is worth knowing exactly what it buys:
it catches an invented source. It cannot catch an invented sentence hung on a
real one: it compares identifiers, and never reads the sentence.
"""
from __future__ import annotations
import json
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
PROMPT = (
"Answer the question using only the passages below.\n"
"If they do not contain the answer, answer exactly: {no_answer}\n"
'Answer with JSON only: {{"answer": "...", "sources": ["id", ...]}},\n'
"where every id is one of the passage ids you were given.\n\n"
"Passages:\n{passages}\n\n"
"Question: {question}"
)
MAX_PASSAGES = 4
MAX_CHARACTERS = 1500 # per passage
MAX_QUESTION = 1000 # characters
NO_ANSWER = "je ne sais pas"
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
class AnswerUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
class AnswerNotGrounded(Exception):
"""The answer cites a passage that was never sent, or cites nothing."""
def answer(question: str, passages: list[dict], client=None, *, attempts: int = 2,
max_passages: int = MAX_PASSAGES) -> dict:
"""`passages` are the retrieved dicts with keys id and text, best first."""
# The provider bills every character of the prompt: refuse before any call.
if len(question) > MAX_QUESTION:
raise ValueError(f"question longer than {MAX_QUESTION} characters")
kept = passages[:max_passages]
if not kept or not question.strip():
# Nothing retrieved, or nothing asked. There is nothing to answer from,
# and no reason to pay for a call that can only invent.
return {"answer": NO_ANSWER, "sources": []}
client = client or ProviderClient()
block = "\n\n".join(f"[{p['id']}] {p['text'][:MAX_CHARACTERS]}" for p in kept)
reply = _ask(client, PROMPT.format(no_answer=NO_ANSWER, passages=block, question=question),
attempts)
text = reply["answer"].strip()
sent = {str(p["id"]): p["id"] for p in kept} # ids may be integers: compare as text
unknown = [source for source in reply["sources"] if str(source) not in sent]
if unknown:
raise AnswerNotGrounded(f"the model cited {unknown}, which it was never sent")
sources = [sent[str(source)] for source in reply["sources"]]
if not text:
raise AnswerUnavailable("the model answered without an answer")
if text.lower().rstrip(" .!") == NO_ANSWER: # "Je ne sais pas." is the same answer
return {"answer": NO_ANSWER, "sources": sources}
if not sources:
raise AnswerNotGrounded("an answer that cites nothing cannot be checked")
return {"answer": text, "sources": sources}
def _ask(client, prompt: str, attempts: int) -> dict:
last_error: Exception | None = None
for _ in range(attempts):
try:
# Temperature zero narrows the sampling. It does not make the call
# deterministic: the provider documents chat completions as
# non-deterministic by default, and the entry says so.
parsed = _decode(client.complete(prompt=prompt, temperature=0))
if isinstance(parsed, dict) and isinstance(parsed.get("answer"), str) \
and isinstance(parsed.get("sources"), list) \
and all(isinstance(s, (str, int)) and not isinstance(s, bool) for s in parsed["sources"]):
return parsed
last_error = ValueError("the model answered something that is not the object asked for")
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise AnswerUnavailable(str(last_error))
def _decode(reply) -> object:
"""
JSON, or JSON wrapped whole in one code fence. No text at all (a refusal)
is unusable, and so is anything else around the object: prose before or
after it, a second block, or a fence opened and never closed. Salvaging
those would be guessing which part of a badly shaped answer to believe.
"""
if not isinstance(reply, str):
raise ValueError("the model returned no text")
text = reply.strip()
if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
text = text[3:-3].removeprefix("json")
return json.loads(text)JavaScript
/**
* Answer a question from your own documents: retrieval-augmented generation.
*
* Rung N3. The model only knows of your handbook what the prompt carries. So
* retrieval comes first — N0, N1 or N2 finds the passages — and the model
* writes the sentence from them.
*
* Everything below is plumbing, and the plumbing is where the bugs are: how
* many passages to send and how long each may be, what the model is allowed to
* say when they do not answer, how to decode a reply that is only probably
* JSON, and what to do when it cites a passage nobody sent.
*
* That last check earns its lines, and it is worth knowing exactly what it
* buys: it catches an invented source. It cannot catch an invented sentence
* hung on a real one: it compares identifiers, and never reads the sentence.
*/
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export const MAX_PASSAGES = 4;
export const MAX_CHARACTERS = 1500; // per passage
export const MAX_QUESTION = 1000; // characters
export const NO_ANSWER = 'je ne sais pas';
const PROMPT = (passages, question) => [
'Answer the question using only the passages below.',
`If they do not contain the answer, answer exactly: ${NO_ANSWER}`,
'Answer with JSON only: {"answer": "...", "sources": ["id", ...]},',
'where every id is one of the passage ids you were given.',
'',
'Passages:',
passages,
'',
`Question: ${question}`,
].join('\n');
export class AnswerUnavailable extends Error {}
export class AnswerNotGrounded extends Error {}
/** The first `max` characters, counted in code points as Python does, so no emoji is cut in two. */
const head = (text, max) => [...text.slice(0, 2 * max)].slice(0, max).join('');
/**
* @param {string} question
* @param {{id: string|number, text: string}[]} passages retrieved, best first
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
*/
export async function answer(question, passages, { client, attempts = 2, maxPassages = MAX_PASSAGES } = {}) {
// The provider bills every character of the prompt: refuse before any call.
if ([...question].length > MAX_QUESTION) {
throw new RangeError(`question longer than ${MAX_QUESTION} characters`);
}
const kept = passages.slice(0, maxPassages);
if (kept.length === 0 || !question.trim()) {
// Nothing retrieved, or nothing asked. There is nothing to answer from,
// and no reason to pay for a call that can only invent.
return { answer: NO_ANSWER, sources: [] };
}
client ??= await providerClient();
const block = kept.map((p) => `[${p.id}] ${head(p.text, MAX_CHARACTERS)}`).join('\n\n');
const reply = await ask(client, PROMPT(block, question), attempts);
const text = reply.answer.trim();
const sent = new Map(kept.map((p) => [String(p.id), p.id])); // ids may be numbers: compare as text
const unknown = reply.sources.filter((source) => !sent.has(String(source)));
if (unknown.length > 0) {
throw new AnswerNotGrounded(`the model cited ${unknown}, which it was never sent`);
}
const sources = reply.sources.map((source) => sent.get(String(source)));
if (!text) throw new AnswerUnavailable('the model answered without an answer');
if (text.toLowerCase().replace(/[ .!]+$/, '') === NO_ANSWER) { // "Je ne sais pas." is the same answer
return { answer: NO_ANSWER, sources };
}
if (sources.length === 0) {
throw new AnswerNotGrounded('an answer that cites nothing cannot be checked');
}
return { answer: text, sources };
}
async function ask(client, prompt, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
// Temperature zero narrows the sampling. It does not make the call
// deterministic: the provider documents chat completions as
// non-deterministic by default, and the entry says so.
const parsed = decode(await client.complete({ prompt, temperature: 0 }));
const isId = (s) => typeof s === 'string' || Number.isInteger(s);
if (typeof parsed?.answer === 'string' && Array.isArray(parsed.sources) && parsed.sources.every(isId)) {
return parsed;
}
lastError = new Error('the model answered something that is not the object asked for');
} catch (error) {
lastError = error;
}
}
throw new AnswerUnavailable(String(lastError));
}
/**
* JSON, or JSON wrapped whole in one code fence. No text at all (a refusal) is
* unusable, and so is anything else around the object: prose before or after
* it, a second block, or a fence opened and never closed. Salvaging those would
* be guessing which part of a badly shaped answer to believe.
*/
function decode(reply) {
if (typeof reply !== 'string') throw new Error('the model returned no text');
const trimmed = reply.trim();
const fences = trimmed.match(/```/g)?.length ?? 0;
if (trimmed.startsWith('```') && trimmed.endsWith('```') && fences === 2) {
const inner = trimmed.slice(3, -3);
return JSON.parse(inner.startsWith('json') ? inner.slice(4) : inner);
}
return JSON.parse(trimmed);
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer to a processor of the question asked and the content of the retrieved passages, with the contractual framing that implies
- Processing location and request retention to be confirmed with the provider
- Does not excuse you from your own duties regarding what the answer tells your readers
Breaking point
The grounding check reads the citations, not the answer. In the test the double’s reply cites the passage that was actually sent and carries a sentence that passage contradicts: the handbook says two and a half days a month, the answer announces thirty working days on arrival. Every check in the snippet passes, and the result carries only the passage’s identifier: you have to open the passage to see the contradiction, which is the work retrieval was supposed to save.
When to move up a level
There is no level above this one.
Further reading
- SQLite — FTS5, table virtuelle plein texte et fonction bm25()
- PostgreSQL — Full Text Search, tsvector et ts_rank
- Robertson et Zaragoza — The Probabilistic Relevance Framework: BM25 and Beyond
- Cormack, Clarke et Buettcher — Reciprocal Rank Fusion, la fusion employée en N2
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.