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

snippets/show-similar-articles/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 table

JavaScript

snippets/show-similar-articles/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

interactive

The 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.

yours to edit

Recomputed on every keystroke.

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 the code makes of it

What it returns

Article in the corpusClosenessIn the block
Baking a dense rye loaf0.67yes
Cuire un pain de seigle dense0.08yes
Comment nous joindre pendant les travaux0.00no
Entretenir un levain naturel0.00no
Keeping a sourdough starter alive0.00no
Kimchi en bocal0.00no
Pickled cucumbers in brine0.00no
The office is moving in June0.00no

2 articles out of 8 clear the floor of 0.05.

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

  1. N0Tag overlap, weighted by how rare each tag isFree · Instant
  2. RecommendedN1TF-IDF over the text, cosine similarity, computed offlinenégligeable · Instant
  3. N2Self-hosted document embeddings, compared by cosineInexpensive · Fast
  4. 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

snippets/show-similar-articles/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 table

JavaScript

snippets/show-similar-articles/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

snippets/show-similar-articles/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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 table

JavaScript

snippets/show-similar-articles/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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

Where the figures come from

Metadata