Machine Learning

TF-IDF: How Search Engines Rank Words by Importance

Type "jaguar" into a 30-billion-page index and the engine has milliseconds to decide which documents matter. Counting how often "jaguar" appears is a start, but the word "the" appears far more often in every document and means nothing. TF-IDF — term frequency times inverse document frequency — is the arithmetic that resolves this: it up-weights words that are frequent here but rare everywhere, and it has powered production retrieval since the 1970s. The whole scheme is a single multiplication over an inverted index, computable in one pass with no training, no gradients, and no neural network.

Karen Spärck Jones formalized the inverse document frequency idea in a landmark 1972 paper, and Gerard Salton's SMART system wove it into the vector-space model. Fifty years later, TF-IDF (and its refinement BM25) is still the default lexical scorer inside Lucene, Elasticsearch, and scikit-learn's TfidfVectorizer.

  • InventedIDF: Spärck Jones, 1972
  • Scoretf(t,d) × log(N / df(t))
  • Query timeO(Σ df(t)) via inverted index
  • SpaceO(nnz) sparse, ≪ |V|·|D|
  • Best forLexical ranking, no training
  • Used inLucene, Elasticsearch, scikit-learn

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The core idea and the invariant

TF-IDF assigns a weight to every (term, document) pair. The weight is a product of two intuitions pulling in opposite directions:

  • Term Frequency, tf(t, d) — how important is term t inside document d? More occurrences ⇒ more relevant. The rawest form is a count; common variants dampen it (see below).
  • Inverse Document Frequency, idf(t) — how discriminating is term t across the whole corpus? A word in every document tells you nothing; a word in three documents is a strong signal. idf(t) = log(N / df(t)), where N is the document count and df(t) is the number of documents containing t.

The score is simply w(t, d) = tf(t, d) × idf(t). The governing invariant is monotonicity in both arguments: for fixed corpus statistics, w increases with tf, and for fixed tf, w increases as df(t) falls. A term that is frequent locally and rare globally — the definition of a topical keyword — earns the highest weight. Stop-words like "the" have df(t) ≈ N, so idf ≈ log(1) = 0, and they are automatically annihilated without any hand-maintained stop-list.

The logarithm is not cosmetic. df(t) is roughly Zipf-distributed, so raw N/df spans many orders of magnitude; log compresses that into a well-behaved additive scale and keeps a term that appears in 1 doc from outscoring one in 100 docs by a factor of 100 rather than a more sensible ~2×.

Building the model step by step

Given a corpus of documents D, construction is a single streaming pass plus one derived table:

  • Tokenize and count. For each document, lowercase, split, optionally stem, and count occurrences to get tf(t, d). Simultaneously, for each distinct term seen in d, increment the document-frequency counter df(t).
  • Compute idf. After the pass, for every term in the vocabulary compute idf(t) = log(N / df(t)). scikit-learn uses the smoothed form idf(t) = log((1 + N) / (1 + df(t))) + 1 to avoid division by zero and keep every weight strictly positive.
  • Weight and store. For each posting, store w(t, d) = tf(t, d) × idf(t) in the inverted index: a map from term → posting list of (docID, weight) pairs.
  • Normalize. Divide each document's weight vector by its L2 norm so long and short documents compete fairly; this is what turns dot products into cosine similarity.

Scoring a query q reuses the same weights. Represent q as a TF-IDF vector, then for each query term walk its posting list and accumulate into per-document score accumulators:

score = defaultdict(float)          # docID -> running score
for t in query_terms:
    idf_t = idf[t]
    for (docID, w_td) in postings[t]:   # only docs that CONTAIN t
        score[docID] += w_td * (tf(t, q) * idf_t)
return top_k(score, k)              # heap of size k

The key efficiency win: you iterate only over documents that actually contain a query term — the inverted index skips the enormous majority that don't. A top-k min-heap of size k keeps the best results in O(log k) per update.

Complexity analysis

Let N be documents, |V| the vocabulary size, and nnz the number of non-zero (term, document) postings (nnz ≪ N·|V| because the document–term matrix is sparse).

  • Index construction: one pass over all tokens is Θ(total tokens) time; the resulting inverted index is Θ(nnz) space. Computing idf over the vocabulary adds O(|V|).
  • Query evaluation: for a query with terms Q, work is O(Σ_{t∈Q} df(t)) — the sum of posting-list lengths — plus O(m log k) to maintain the heap over the m touched documents. For rare, discriminating query terms this is tiny; the danger term is a high-df word whose list is long.
  • Vectorizing a new document of length L against a fixed model: O(L) to tokenize and look up cached idf values, versus a naïve dense matrix that would be O(|V|).

Why the sparse representation matters: a 1M-document corpus with a 500k vocabulary has a 5×10¹¹-cell dense matrix, but typical text fills well under 0.1% of it, so nnz is a few hundred million entries stored in CSR/posting form. Storing dense would be terabytes; storing sparse is gigabytes. Cosine similarity between two vectors is O(nnz of the smaller vector) using a hash-merge, not O(|V|).

Because idf(t) depends only on corpus-global df, it is precomputed once and shared across all queries — the per-query cost carries no logarithm-of-vocabulary factor, only the posting traversal.

Trade-offs and when to reach for it

TF-IDF wins precisely where its assumptions hold: bag-of-words, exact-match, lexical relevance with no labeled training data.

  • Use it when you need a strong, explainable baseline in minutes; when queries and documents share vocabulary (legal search, code search, log search, product catalogs); when you need CPU-only serving at scale; or as the first-stage retriever that a heavier re-ranker refines.
  • Avoid it when relevance hinges on meaning rather than surface form. TF-IDF treats "car" and "automobile" as unrelated dimensions — orthogonal vectors, cosine 0 — so it has zero recall for synonyms and cannot resolve polysemy ("bank" the river vs. the institution).
  • The BM25 refinement. Plain tf grows linearly, but the 10th occurrence of a word rarely means 10× the relevance. Okapi BM25 (Robertson & Spärck Jones) replaces tf with a saturating function tf·(k₁+1) / (tf + k₁·(1 − b + b·|d|/avgdl)), with typical k₁ ≈ 1.2 and b ≈ 0.75. It bounds the contribution of any single term and normalizes for document length. BM25 is the modern default and is what Lucene/Elasticsearch actually ship.

The honest framing: TF-IDF is high-precision on exact matches and cheap, dense embeddings are high-recall on meaning and expensive. Production hybrid retrieval fuses both — a TF-IDF/BM25 lexical score and a vector-similarity score combined by reciprocal-rank fusion — because each covers the other's blind spot.

Where it runs in the real world

TF-IDF is one of the most-deployed formulas in software, usually hiding one layer down inside a search library or ML pipeline.

  • Apache Lucene / Elasticsearch / Solr / OpenSearch. The default similarity was classic TF-IDF for years and switched to BM25 in Lucene 6 (2016). Every full-text query you fire at Elasticsearch computes a per-term idf and a length-normalized tf under the hood.
  • scikit-learn. TfidfVectorizer turns a text corpus into a sparse CSR matrix in a couple of lines; it is the standard front-end for spam filters, topic classifiers, and clustering, feeding Naive Bayes, logistic regression, or SVM downstream.
  • Recommendation and dedup. Content-based recommenders represent items as TF-IDF vectors and rank by cosine similarity; near-duplicate detection and plagiarism checks compare TF-IDF fingerprints.
  • Keyword extraction and summarization. The highest-weight terms in a document are, by construction, its keywords — the basis of extractive summarizers and tag suggesters.
  • RAG pre-filtering. Even modern LLM retrieval-augmented-generation stacks often keep a BM25 lexical stage to catch exact identifiers, product codes, and rare proper nouns that dense embeddings blur.

The reason for its longevity is operational, not academic: no training loop, deterministic and debuggable weights, and it fits a data structure — the inverted index — that databases have optimized to death.

Pitfalls, edge cases, and variants

The formula is simple, which means the failure modes live in the details.

  • Division by zero / unseen terms. A query term absent from the corpus has df(t) = 0. Raw log(N/0) is undefined; the standard fix is smoothing — add 1 to numerator and denominator — which is why scikit-learn's smoothed idf never blows up and never emits a zero weight.
  • Length bias. Without normalization, long documents accumulate higher raw scores simply by being long. L2-normalizing the vector (cosine) or BM25's b·|d|/avgdl term corrects this; forgetting it is the single most common bug.
  • Sublinear tf. The log-frequency variant uses 1 + log(tf) instead of tf so that a term appearing 1,000 times doesn't dominate one appearing 10 times — the same saturation intuition BM25 formalizes.
  • Corpus drift. idf is global, so adding, removing, or re-crawling documents changes df(t) and therefore every stored weight. Streaming indexes recompute idf periodically or approximate it; getting this wrong makes yesterday's scores incomparable to today's.
  • Vocabulary explosion. With no min-df/max-df cutoffs, typos and hapax legomena bloat |V|. Standard practice prunes terms below a document-frequency floor and above a ceiling (which also strips residual stop-words).
  • Semantic blindness. The unfixable one: TF-IDF has no notion that words relate. This is not a bug to patch but the boundary of the model — cross it and you need embeddings.
TF-IDF versus common alternatives for text scoring and retrieval
MethodCapturesCost to buildQuery costMisses
Raw term count (BoW)Local frequencyO(tokens)O(Σ df)Common-word dominance
TF-IDFLocal × global rarityO(tokens)O(Σ df)Semantics, synonyms
BM25TF saturation + length normO(tokens)O(Σ df)Semantics
Word embeddingsDistributional semanticsO(corpus)·epochsO(k·d) ANNExact-match precision
Dense (BERT) retrievalContextual meaningGPU-hoursANN over N d-dim vectorsRare terms, cost

Frequently asked questions

Why multiply tf and idf instead of just using word counts?

Raw counts let ubiquitous words like "the" and "is" dominate every document, drowning out topical signal. idf(t) = log(N/df(t)) collapses to ≈0 for words in nearly every document and grows for rare ones, so the product surfaces terms that are frequent here but rare everywhere — exactly the definition of a discriminating keyword.

What is the query-time complexity, and why is the inverted index essential?

Scoring a query costs O(Σ df(t)) over its terms — the total length of the touched posting lists — plus O(m log k) to keep a top-k heap. The inverted index is what makes this cheap: you iterate only over documents that contain a query term and skip the vast majority that don't, instead of scanning all N documents.

How is TF-IDF different from BM25, and which should I use?

TF-IDF's term frequency grows linearly and unboundedly; BM25 saturates it (the 10th occurrence adds almost nothing) via k₁ and normalizes for document length via b. BM25 is the modern default and is what Lucene and Elasticsearch ship — prefer it for retrieval; plain TF-IDF is fine as a feature extractor for downstream classifiers.

When does TF-IDF break down?

It fails whenever relevance depends on meaning rather than surface form. "Car" and "automobile" become orthogonal dimensions (cosine similarity 0), so synonyms and paraphrases are invisible to it, and polysemous words like "bank" aren't disambiguated. For semantic matching you need word or sentence embeddings, often fused with BM25 in a hybrid retriever.

Does adding documents change existing scores?

Yes. idf depends on the global document frequency df(t) and the corpus size N, so any change to the collection alters idf and therefore every stored weight. Production systems recompute idf periodically or maintain streaming approximations; comparing scores across different index snapshots without this is a subtle but real bug.

How does TF-IDF relate to cosine similarity and the vector-space model?

TF-IDF produces the weight vectors; the vector-space model (Salton's SMART system) places documents and queries as vectors in |V|-dimensional space, and cosine similarity ranks them by the angle between vectors. Because vectors are L2-normalized, the cosine is just a dot product — and it runs in O(nnz) over the sparse representation, not O(|V|).