Show similar articles
Offer three or four other articles from the site, under the one being read, that cover the same subject.
No generative AI needed
négligeableInstantYour data never leaves
The verdict
N1 depends neither on tagging nor on a model. N0 is free and unit testable, but it only sees what somebody remembered to tag, and the day tagging slips, the table empties or fills with perfect neighbours that nothing connects; N1 reads the article and lets the corpus decide which words matter, for the same table built offline. Its price is a stop list per language, without which the test pairs the knife-sharpening article with the site announcement: move up to N2 when your corpus says the same thing in two languages, not before, or every build of the table has a model to load.
TF-IDF over the text, cosine similarity, computed offline
No generative AI needed
Lightweight classic modelN1négligeableInstant
Python
"""
Related articles from the text itself: TF-IDF, then cosine similarity.
Rung N1. N0 only sees what someone remembered to tag. This reads the article,
and lets the corpus decide which words matter: the rarer a word across the
corpus, the more it weighs. Unlike a tag on every article at N0, a word in every
article still weighs something, which is why the stop list below is part of
the job, one per language.
Still computed offline, once per corpus change, and it returns a neighbour
table of the same shape as N0. The page-rendering code does not change when
you move from one rung to the next, which is what makes the move cheap.
"""
import unicodedata
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel
def article_text(article: dict) -> str:
"""
The title counts twice.
A word in a title is a stronger claim about the subject than the same word
buried in the fourth paragraph, and repeating the title is the cheapest
way to say so to a bag-of-words model.
"""
title, body = article["title"] or "", article["body"] or "" # a NULL column is empty
# NFC: the same accented word typed on two systems is one token, not two halves.
return unicodedata.normalize("NFC", f"{title} {title} {body}")
def build_neighbour_table(
articles: list[dict],
k: int = 5,
minimum: float = 0.05,
stop_words: list[str] | None = None,
) -> dict:
"""
Return `{article id: [(neighbour id, score), ...]}`, best neighbour first.
`stop_words` is a per-language list, so it belongs to the caller and not
to this function. Without one, the words every article shares still count:
on the test corpus, the knife-sharpening article gets the site announcement
as a neighbour.
`minimum` is that floor, not a knob to be tweaked until the block looks
full: below it, two articles share ordinary words and nothing else, and
showing no block beats showing a wrong one.
"""
if len(articles) < 2:
return {article["id"]: [] for article in articles}
# Rows come out l2-normalised, so their dot product is already a cosine.
vectoriser = TfidfVectorizer(stop_words=stop_words)
try:
matrix = vectoriser.fit_transform(article_text(a) for a in articles)
except ValueError: # no word left at all: nothing to compare, no neighbours
return {article["id"]: [] for article in articles}
scores = linear_kernel(matrix)
table = {}
for index, article in enumerate(articles):
neighbours = [
(other["id"], round(float(scores[index][position]), 3))
for position, other in enumerate(articles)
if position != index
]
# Ties broken by identifier, so that two builds give the same page.
neighbours = sorted(
(n for n in neighbours if n[1] > minimum),
key=lambda neighbour: (-neighbour[1], neighbour[0]),
)
table[article["id"]] = neighbours[:k]
return tableJavaScript
/**
* Related articles from the text itself: TF-IDF, then cosine similarity.
*
* Rung N1. N0 only sees what someone remembered to tag. This reads the
* article, and lets the corpus decide which words matter: the rarer a word
* across the corpus, the more it weighs. Unlike a tag on every article at N0, a
* word in every article still weighs something, which is why the stop list
* below is part of the job, one per language.
*
* Written out rather than pulled from a library, because TF-IDF is a count, a
* logarithm and a division. The tokens and the weighting below are
* scikit-learn's defaults, so this ranks a corpus as the Python version does.
*/
// Runs of two or more letters, digits or underscores, as scikit-learn's
// default token pattern. A one-letter word, such as the C of "Programming in
// C", is dropped with the rest.
const TOKEN = /[\p{L}\p{N}_]{2,}/gu;
export function tokenise(text, stopWords = new Set()) {
return (text.toLowerCase().match(TOKEN) ?? []).filter((term) => !stopWords.has(term));
}
/** The title counts twice: a word in a title is a stronger claim. */
export function articleText(article) {
const title = article.title ?? '';
const body = article.body ?? ''; // a NULL column is empty
// NFC: the same accented word typed on two systems is one token, not two halves.
return `${title} ${title} ${body}`.normalize('NFC');
}
/** One l2-normalised TF-IDF vector per document, as a Map of term to weight. */
export function vectorise(texts, stopWords = new Set()) {
const documents = texts.map((text) => tokenise(text, stopWords));
const appearances = new Map();
for (const terms of documents) {
for (const term of new Set(terms)) appearances.set(term, (appearances.get(term) ?? 0) + 1);
}
// Smoothed, as scikit-learn does by default: as if one extra document held
// every term once. A term present everywhere weighs 1, the least possible.
const idf = (term) => Math.log((1 + documents.length) / (1 + appearances.get(term))) + 1;
return documents.map((terms) => {
const vector = new Map();
for (const term of terms) vector.set(term, (vector.get(term) ?? 0) + 1);
for (const [term, count] of vector) vector.set(term, count * idf(term));
const norm = Math.hypot(...vector.values());
if (norm) for (const [term, weight] of vector) vector.set(term, weight / norm);
return vector;
});
}
/** Both vectors are unit length, so their dot product is the cosine. */
function cosine(first, second) {
let total = 0;
for (const [term, weight] of first) total += weight * (second.get(term) ?? 0);
return total;
}
/**
* Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
*
* `stopWords` is a per-language list, so it belongs to the caller and not to
* this function. Without one, the words every article shares still count: on
* the test corpus, the knife-sharpening article gets the site announcement as
* a neighbour.
*
* `minimum` is that floor, not a knob to be tweaked until the block looks
* full: below it, two articles share ordinary words and nothing else, and
* showing no block beats showing a wrong one.
*/
export function buildNeighbourTable(articles, { k = 5, minimum = 0.05, stopWords = [] } = {}) {
if (articles.length < 2) return Object.fromEntries(articles.map((a) => [a.id, []]));
const vectors = vectorise(articles.map(articleText), new Set(stopWords));
const table = {};
articles.forEach((article, index) => {
const neighbours = [];
articles.forEach((other, position) => {
if (position === index) return;
const score = Math.round(cosine(vectors[index], vectors[position]) * 1000) / 1000;
if (score > minimum) neighbours.push([other.id, score]);
});
// Ties broken by identifier, so that two builds give the same page.
neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
table[article.id] = neighbours.slice(0, k);
});
return table;
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
The table shows the whole ranking, floor included: the articles above the floor are the ones that would appear under the article, the others are dropped. A zero is not a low score, it is the absence of any shared word, stop words aside.
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
| Article in the corpus | Closeness | In the block |
|---|---|---|
| Baking a dense rye loaf | 0.67 | yes |
| Cuire un pain de seigle dense | 0.08 | yes |
| Comment nous joindre pendant les travaux | 0.00 | no |
| Entretenir un levain naturel | 0.00 | no |
| Keeping a sourdough starter alive | 0.00 | no |
| Kimchi en bocal | 0.00 | no |
| Pickled cucumbers in brine | 0.00 | no |
| The office is moving in June | 0.00 | no |
2 articles out of 8 clear the floor of 0.05.
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
- Low footprint
What this adds to your regulatory scope
- No specific scope added: the corpus never leaves your infrastructure
- No prior training: the weighting is recomputed from your own corpus alone, each time the table is built
Breaking point
TF-IDF compares strings, not meanings. The test puts the English sourdough article next to its French twin: their cosine is exactly zero, not merely low. In the same corpus an office-move announcement, which shares nothing with the English article but function words and “day”, scores strictly higher; without “day”, it still comes first. No threshold rescues that.
When to move up a level
Your corpus says the same thing in two vocabularies: two languages, or one author’s jargon against another’s plain words.
The four options
- N0Tag overlap, weighted by how rare each tag isFree · Instant
- RecommendedN1TF-IDF over the text, cosine similarity, computed offlinenégligeable · Instant
- N2Self-hosted document embeddings, compared by cosineInexpensive · Fast
- N3Not worth it here
The other options
N0Tag overlap, weighted by how rare each tag isFree · Instant
Tag overlap, weighted by how rare each tag is
Rule and classic algorithmN0FreeInstant
Python
"""
Related articles from tag overlap, weighted by how rare each tag is.
Rung N0. Standard library, deterministic, and computed once for the whole
corpus instead of on every page view: the neighbours of an article do not
depend on who is reading it, so no reader should ever wait for this.
Counting shared tags is the obvious version, and it is the wrong one. A tag
every article carries says nothing about any of them; a tag three articles
carry says almost everything about those three. Weighting each tag by its
rarity is the whole difference between a useful block and one that files the
site announcement next to every article on the site.
"""
import math
import unicodedata
from collections import Counter
def tag_set(tags) -> set[str]:
"""Each tag once, in NFC, so that a tag typed on two systems meets itself."""
if isinstance(tags, str):
# A lone string would be read letter by letter, and "blog" would meet "gloss".
raise TypeError("tags must be a list of strings, not a string")
return {unicodedata.normalize("NFC", tag) for tag in tags}
def tag_weights(articles: list[dict]) -> dict[str, float]:
"""
Inverse document frequency of every tag in the corpus.
log(corpus size / tag count) is exactly zero for a tag carried by every
article. That is the point, not a rounding accident: such a tag must not
create any similarity at all.
"""
counts = Counter(tag for article in articles for tag in tag_set(article["tags"]))
return {tag: math.log(len(articles) / count) for tag, count in counts.items()}
def _norm(vector: dict[str, float]) -> float:
return math.sqrt(sum(weight * weight for weight in vector.values()))
def similarity(first_tags, second_tags, weights: dict[str, float]) -> float:
"""Cosine between two tag sets, each weighted by tag rarity."""
first = {tag: weights.get(tag, 0.0) for tag in tag_set(first_tags)}
second = {tag: weights.get(tag, 0.0) for tag in tag_set(second_tags)}
shared = sum(first[tag] * second[tag] for tag in first.keys() & second.keys())
norms = _norm(first) * _norm(second)
# An article carrying only universal tags has a null vector, and no
# neighbours. Saying nothing is the honest answer here.
return shared / norms if norms else 0.0
def build_neighbour_table(articles: list[dict], k: int = 5, minimum: float = 0.0) -> dict:
"""
Return `{article id: [(neighbour id, score), ...]}`, best neighbour first.
Built offline, for the whole corpus at once, when an article is published
or retagged. Rendering the block on a page is then a lookup in this table,
never a search.
"""
weights = tag_weights(articles)
table = {}
for article in articles:
neighbours = []
for other in articles:
if other["id"] == article["id"]:
continue
score = round(similarity(article["tags"], other["tags"], weights), 3)
if score > minimum:
neighbours.append((other["id"], score))
# Ties broken by identifier, so that two builds give the same page.
neighbours.sort(key=lambda neighbour: (-neighbour[1], neighbour[0]))
table[article["id"]] = neighbours[:k]
return tableJavaScript
/**
* Related articles from tag overlap, weighted by how rare each tag is.
*
* Rung N0. No dependency, deterministic, and computed once for the whole
* corpus instead of on every page view: the neighbours of an article do not
* depend on who is reading it, so no reader should ever wait for this.
*
* Counting shared tags is the obvious version, and it is the wrong one. A tag
* every article carries says nothing about any of them; a tag three articles
* carry says almost everything about those three. Weighting each tag by its
* rarity is the whole difference between a useful block and one that files
* the site announcement next to every article on the site.
*/
/**
* Inverse document frequency of every tag in the corpus.
*
* log(corpus size / tag count) is exactly zero for a tag carried by every
* article. That is the point, not a rounding accident: such a tag must not
* create any similarity at all.
*/
/** Each tag once, in NFC, so that a tag typed on two systems meets itself. */
export function tagSet(tags) {
// A lone string would be read letter by letter, and "blog" would meet "gloss".
if (typeof tags === 'string') throw new TypeError('tags must be an array of strings, not a string');
return new Set(Array.from(tags, (tag) => tag.normalize('NFC')));
}
export function tagWeights(articles) {
const counts = new Map();
for (const article of articles) {
for (const tag of tagSet(article.tags)) counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
const weights = new Map();
for (const [tag, count] of counts) weights.set(tag, Math.log(articles.length / count));
return weights;
}
function norm(vector) {
return Math.hypot(...vector.values());
}
/** Cosine between two tag sets, each weighted by tag rarity. */
export function similarity(firstTags, secondTags, weights) {
const vector = (tags) => new Map([...tagSet(tags)].map((t) => [t, weights.get(t) ?? 0]));
const first = vector(firstTags);
const second = vector(secondTags);
let shared = 0;
for (const [tag, weight] of first) shared += weight * (second.get(tag) ?? 0);
const norms = norm(first) * norm(second);
// An article carrying only universal tags has a null vector, and no
// neighbours. Saying nothing is the honest answer here.
return norms ? shared / norms : 0;
}
/**
* Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
*
* Built offline, for the whole corpus at once, when an article is published
* or retagged. Rendering the block on a page is then a lookup in this table,
* never a search.
*/
export function buildNeighbourTable(articles, { k = 5, minimum = 0 } = {}) {
const weights = tagWeights(articles);
const table = {};
for (const article of articles) {
const neighbours = [];
for (const other of articles) {
if (other.id === article.id) continue;
const score = Math.round(similarity(article.tags, other.tags, weights) * 1000) / 1000;
if (score > minimum) neighbours.push([other.id, score]);
}
// Ties broken by identifier, so that two builds give the same page.
neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
table[article.id] = neighbours.slice(0, k);
}
return table;
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the corpus never leaves your infrastructure
- No profiling of the reader: the neighbour table is the same for everyone
Breaking point
A badly tagged corpus produces no sound similarity at all, and the failure is total rather than degraded. The test builds two of them: in the first, every article carries “blog” and “article”, whose weight is exactly zero; in the second, every article carries a tag nobody else carries. Both tables come out empty, and the block appears on no page. One article more and the failure turns around: let a fourth article go without “article”, and the other three become perfect neighbours, at 1.0, with nothing in common but a generic tag.
When to move up a level
The table you build has empty rows, or neighbours at 1.0 that share nothing but a generic tag: your tags sit on every article or nearly, or on exactly one.
N2Self-hosted document embeddings, compared by cosineInexpensive · Fast
Self-hosted document embeddings, compared by cosine
Small self-hosted specialised modelN2InexpensiveFast
Python
"""
Related articles from self-hosted document embeddings.
Rung N2. N1 compares words. Two articles that cover the same subject with two
vocabularies, or in two languages, share no term and score exactly zero. The
encoder named below is a multilingual one, trained so that a sentence and its
translation land close together: that is what could bring the French and the
English piece on sourdough together, which the test's local double does not do.
It reads only the start of a long article (128 tokens in Python, 512 in
JavaScript) and truncates the rest. That difference is a trap: the same article
gets two different vectors depending on which runtime encoded it, with no error
anywhere. Build the table and encode a new article with the same one. And for
articles, encoding the title and the standfirst is usually better than the whole
text cut off mid-paragraph: they say what the piece is about, and they fit.
The table is still built offline, once per corpus change, and it has the same
shape as the one N0 and N1 return: the page-rendering code never changes.
What changes is what the build now runs: a model whose weights are downloaded
once, then loaded each time the table is built.
The encoder is a parameter with a real default, so the reader sees the loading
code while the test injects a local double.
"""
import math
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
class EncodingFailed(RuntimeError):
"""The encoder could not be run, or returned something unusable."""
def article_text(article: dict) -> str:
"""One string per article. An encoder wants a sentence, not a bag of words."""
return f"{article['title'] or ''}. {article['body'] or ''}" # a NULL column is empty
def unit(vector) -> list[float]:
"""Normalise once, so that a cosine is a dot product afterwards."""
values = [float(value) for value in vector]
norm = sum(value * value for value in values) ** 0.5
return [value / norm for value in values] if norm else values
def build_neighbour_table(articles: list[dict], encoder=None, k: int = 5,
minimum: float = 0.0) -> dict:
"""Return `{article id: [(neighbour id, score), ...]}`, best neighbour first."""
if len(articles) < 2:
return {article["id"]: [] for article in articles}
texts = [article_text(article) for article in articles]
if encoder is None: # pragma: no cover - downloads and loads the weights
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer(MODEL_NAME)
# One batched call, over the whole corpus, every time the table is built.
try:
vectors = [unit(vector) for vector in encoder.encode(texts)]
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(texts):
raise EncodingFailed(f"{len(vectors)} vectors returned for {len(texts)} articles")
# A NaN would silently empty the table, a short vector would score 1.0 with anything.
if any(len(v) != len(vectors[0]) or not all(map(math.isfinite, v)) for v in vectors):
raise EncodingFailed("vectors of different sizes, or values that are not finite numbers")
table = {}
for index, article in enumerate(articles):
neighbours = []
for position, other in enumerate(articles):
if position == index:
continue
score = round(sum(x * y for x, y in zip(vectors[index], vectors[position])), 3)
if score > minimum:
neighbours.append((other["id"], score))
# Ties broken by identifier, so that two builds give the same page.
neighbours.sort(key=lambda neighbour: (-neighbour[1], neighbour[0]))
table[article["id"]] = neighbours[:k]
return tableJavaScript
/**
* Related articles from self-hosted document embeddings.
*
* Rung N2. N1 compares words. Two articles that cover the same subject with two
* vocabularies, or in two languages, share no term and score exactly zero. The
* encoder named below is a multilingual one, trained so that a sentence and its
* translation land close together: that is what could bring the French and the
* English piece on sourdough together, which the test's local double does not do.
* It reads only the start of a long article (128 tokens in Python, 512 in
* JavaScript) and truncates the rest. That difference is a trap: the same article
* gets two different vectors depending on which runtime encoded it, with no error
* anywhere. Build the table and encode a new article with the same one. And for
* articles, encoding the title and the standfirst is usually better than the whole
* text cut off mid-paragraph: they say what the piece is about, and they fit.
*
* The table is still built offline, once per corpus change, and it has the same
* shape as the one N0 and N1 return: the page-rendering code never changes.
* What changes is what the build now runs: a model whose weights are downloaded
* once, then loaded each time the table is built.
*
* The encoder is a parameter with a real default, so the reader sees the loading
* code while the test injects a local double.
*/
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
export class EncodingFailed extends Error {}
/** One string per article. An encoder wants a sentence, not a bag of words. */
export function articleText(article) {
return `${article.title ?? ''}. ${article.body ?? ''}`; // a NULL column is empty
}
/** 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;
}
/**
* Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
*
* @param {object[]} articles
* @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 buildNeighbourTable(articles, { encoder, k = 5, minimum = 0 } = {}) {
if (articles.length < 2) return Object.fromEntries(articles.map((a) => [a.id, []]));
const texts = articles.map(articleText);
if (!encoder) {
// Downloads and loads the weights, so it is never reached in the tests.
const { pipeline } = await import('@huggingface/transformers');
const extract = await pipeline('feature-extraction', MODEL_NAME);
encoder = { encode: async (batch) => (await extract(batch, { pooling: 'mean' })).tolist() };
}
// One batched call, over the whole corpus, every time the table is built.
let vectors;
try {
vectors = (await encoder.encode(texts)).map(unit);
} catch (error) {
throw new EncodingFailed(String(error));
}
if (vectors.length !== texts.length) {
throw new EncodingFailed(`${vectors.length} vectors returned for ${texts.length} articles`);
}
// A NaN would silently empty the table, a short vector would score 1.0 with anything.
if (vectors.some((v) => v.length !== vectors[0].length || !v.every(Number.isFinite))) {
throw new EncodingFailed('vectors of different sizes, or values that are not finite numbers');
}
const table = {};
articles.forEach((article, index) => {
const neighbours = [];
articles.forEach((other, position) => {
if (position === index) return;
const cosine = vectors[index].reduce((sum, value, d) => sum + value * vectors[position][d], 0);
const score = Math.round(cosine * 1000) / 1000;
if (score > minimum) neighbours.push([other.id, score]);
});
// Ties broken by identifier, so that two builds give the same page.
neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
table[article.id] = neighbours.slice(0, k);
});
return table;
}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
- The corpus is encoded on your own infrastructure: nothing goes to a third party
- The model weights come from a third party, with the licence and provenance that implies checking
Breaking point
An article gets a single vector, which blends everything it holds: a rambling paragraph added to the rye loaf article — the oven, the board, the jar of jam, the pan — is enough to cost it its place as first neighbour, and the vectors in that test come from the local double. And the named model only reads the start of a long article, 128 tokens in Python and 512 in JavaScript: what lies beyond does not dilute the vector, it never enters it — so build the table and query it with the same runtime, or the same article will have two vectors.
When to move up a level
There is no level above this one here: past it you do not change algorithm, you cut articles into passages and index the passages. Before going that far, for articles, encode the title and the standfirst rather than the whole text cut off mid-second-paragraph: they state the subject, and they fit in the window.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Recomputing similarity through a model call on every page view, for a result that does not change between two publications, is waste. The ranking depends neither on the reader nor on the moment: the only conceivable gain is its quality, and it would be paid for on every view instead of once per publication.
Further reading
- Introduction to Information Retrieval — pondération tf-idf, un terme pèse ce qu’il est rare
- Introduction to Information Retrieval — produit scalaire et cosinus entre deux documents
- scikit-learn — TfidfVectorizer, et le lissage de l’idf employé par l’extrait
- Sentence-Transformers — documentation des encodeurs auto-hébergés
Where the figures come from
- sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2, `sentence_bert_config.json` : `"max_seq_length": 128` — le modèle du niveau N2 côté Python ne lit que les 128 premiers jetons d’un article
- Xenova/paraphrase-multilingual-MiniLM-L12-v2, `tokenizer_config.json` : `model_max_length` 512 — la conversion ONNX chargée par l’extrait JavaScript coupe quatre fois plus loin, d’où deux vecteurs possibles pour un même article long
- SBERT — Computing Embeddings, « Input Sequence Length » : chaque modèle porte une longueur maximale sous `model.max_seq_length`, et « longer texts will be truncated to the first `model.max_seq_length` tokens »
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.