TF-IDF Weighting

Imagine searching a library of ten million news articles for documents about "quantum entanglement." A naive Bag-of-Words Model treats every word as equally important. So common words like "the" and "said" dominate the similarity score. They drown out the rare, meaningful terms that actually signal topic. TF-IDF Weighting solves this by asking a simple question for every word in every document. How often does this term appear here, and how rare is it across the whole collection? This idea was formalized in the 1970s from earlier intuitions about term specificity. It became the backbone of information retrieval for decades. Search engines, document classifiers, recommendation systems, and plagiarism detectors all leaned on some variant of TF-IDF. This was long before neural embeddings existed. Many still use it as a fast, interpretable baseline. Others use it as a feature alongside learned representations. Its appeal is that it requires no training data, no gradient descent, and no neural network — just counting. The big picture is that TF-IDF converts a bag-of-words vector into something more useful. That raw vector only encodes counts. TF-IDF turns it into a weighted vector that encodes informativeness. A word appearing in every document (like "the") gets pushed toward zero weight. A word appearing in only a handful of documents (like "entanglement") gets amplified. The result is a vector space where cosine similarity between documents actually correlates with topical similarity. It no longer correlates with shared filler words. This article builds TF-IDF from first principles. It covers the two factors (term frequency and inverse document frequency), the standard formulas and their variants, and a full worked numerical trace. It also covers the vector-space model it enables, and the pitfalls that trip up implementations. It closes by connecting TF-IDF to the statistical foundations that later feed into Text Classification Fundamentals.

Key takeaways
  • TF-IDF weights each term by how often it appears in a document (TF) times how rare it is across the corpus (IDF).
  • IDF down-weights common words and up-weights rare, discriminative words, without any manual stopword list.
  • The standard formula is `w(t,d) = tf(t,d) × log(N / df(t))`, with several common variants for smoothing.
  • Documents become vectors in a `|V|`-dimensional space; cosine similarity measures topical closeness.
  • Computing all weights takes `O(|D| × |V|_avg)` time using an inverted index, not `O(|D| × |V|)`.
  • TF-IDF is a strong, interpretable baseline that predates and still complements neural embeddings.
The core insight

TF-IDF scores a word as important in a document precisely when it is frequent there but rare everywhere else.

The idea

Term Frequency (TF) measures how often a term `t` occurs in a document `d`, capturing the intuition that a word mentioned five times is more central to that document's topic than a word mentioned once. Raw counts are usually transformed — most commonly with a logarithm — because the difference between 1 and 2 occurrences matters more than the difference between 50 and 51.

Inverse Document Frequency (IDF) measures how rare a term is across the entire document collection `D`. If a term appears in nearly every document, it carries little information about what distinguishes any particular document, so IDF pushes its weight down. If a term appears in only a few documents, it is highly discriminative, so IDF pushes its weight up. Formally, `df(t)` is the number of documents containing `t` at least once, and IDF is a decreasing function of `df(t)`.

TF-IDF is the product of these two signals: ``` w(t, d) = tf(t, d) × idf(t) ``` The multiplication is the crucial design choice: a term only receives high weight when it is both locally frequent (informative about this document) and globally rare (informative about the corpus). A term that is locally frequent but globally common (like "said" in news text) gets cancelled out by a near-zero IDF. A term that is globally rare but never appears in this document contributes zero because `tf(t,d) = 0`. Each document is then represented as a vector in a `|V|`-dimensional space (`V` = vocabulary), turning text similarity into geometric similarity — the basis of the classic vector space model for information retrieval, building directly on the Bag-of-Words Model representation.

How it works

Step 1 — Build vocabulary and counts. Tokenize every document and build a term-document count matrix, or more efficiently an inverted index mapping each term to the list of documents it appears in and its count there.

Step 2 — Compute term frequency. For each term-document pair, compute a TF score. The raw count `count(t,d)` is one option; a common alternative is log-scaled TF: ``` tf(t, d) = 1 + log10(count(t, d)) if count(t, d) > 0 tf(t, d) = 0 otherwise ``` Log-scaling compresses the effect of very high counts, reflecting diminishing marginal informativeness.

Step 3 — Compute document frequency and inverse document frequency. For each term, count `df(t)`, the number of documents it appears in at least once. Then: ``` idf(t) = log10(N / df(t)) ``` where `N = |D|` is the total number of documents. A smoothed variant adds 1 to the denominator and/or numerator to avoid division by zero and to prevent terms appearing in every document from getting exactly `idf = 0` in edge cases: ``` idf(t) = log( (1 + N) / (1 + df(t)) ) + 1 (scikit-learn style smoothing) ```

Step 4 — Multiply. Combine the two into a single weight per term-document pair, forming the TF-IDF vector for each document.

Step 5 — Normalize (optional but standard). L2-normalize each document vector so that document length does not artificially inflate similarity scores: ``` v(d) = w(d) / ||w(d)||_2 ```

Step 6 — Use the vectors. Compute cosine similarity between a query vector and each document vector for search, or feed the TF-IDF vectors directly into a downstream classifier or clustering algorithm. ```pseudocode function BUILD_TFIDF(documents):

# documents: list of D token lists N = length(documents) df = empty map from term -> integer # document frequency tf_raw = empty map from (term, doc_id) -> integer

# Pass 1: raw counts and document frequency for doc_id, tokens in enumerate(documents): seen_in_this_doc = empty set for term in tokens: tf_raw[(term, doc_id)] += 1 if term not in seen_in_this_doc: df[term] += 1 seen_in_this_doc.add(term)

# Pass 2: compute IDF once per term idf = empty map from term -> float for term in df.keys(): idf[term] = log10(N / df[term])

# Pass 3: compute weighted, normalized vectors vectors = empty map from doc_id -> sparse vector for doc_id in range(N): vec = empty sparse vector for term in distinct_terms(documents[doc_id]): count = tf_raw[(term, doc_id)] tf = 1 + log10(count) # log-scaled TF, count > 0 guaranteed vec[term] = tf idf[term] norm = sqrt(sum(vv for v in vec.values())) if norm > 0: for term in vec.keys(): vec[term] = vec[term] / norm vectors[doc_id] = vec return vectors, idf

function COSINE_SIMILARITY(vec_a, vec_b): dot = 0 for term in vec_a.keys(): if term in vec_b: dot += vec_a[term] * vec_b[term] return dot # vectors already L2-normalized, so dot = cosine ``` The pseudocode handles two edge cases explicitly: `count(t,d) = 0` never reaches the `log10` call (the loop only iterates over terms actually present in the document, so `count > 0` is guaranteed), and the zero-vector case (an empty document) is guarded before dividing by `norm`.

Figure
TF-IDF pipeline: from raw text to a weighted, normalized document vector
Corpus D d1: "cat sat mat" d2: "cat sat sofa" d3: "dog barked" N = 3 documents Term counts (TF) cat: d1=1, d2=1 sat: d1=1, d2=1 mat: d1=1 dog: d3=1 df(cat)=2, df(dog)=1 IDF per term idf(cat) = log(3/2) idf(dog) = log(3/1) rare terms → higher weight TF × IDF vector d1 = [0.30, 0.30, 0.53, 0] d2 = [0.30, 0.30, 0, 0.53] d3 = [0, 0, 0, 0.71] (order: cat,sat,mat,dog/sofa) L2-normalized Vector space: documents as points, cosine = similarity "dog" axis "cat" axis d1 d2 d3 d1 and d2 point in nearly the same direction (share "cat","sat") d3 points elsewhere (only "dog")

Complexity & correctness

Time complexity. Let `N = |D|` be the number of documents and `L` the average document length in tokens. Building the raw counts and document-frequency table requires a single pass over every token in every document: `O(N · L) = O(total tokens in corpus)`. Computing IDF requires one pass over the vocabulary `|V|`: `O(|V|)`. Computing the final weighted vectors requires, for each document, one pass over its distinct terms, which is at most `O(L)` per document, so `O(N · L)` overall. Total: ``` O(total tokens) + O(|V|) = O(N · L) ``` since `|V| ≤ total tokens` always. This is linear in corpus size, not quadratic — the naive mistake would be to compute `df(t)` by rescanning the whole corpus for every term, which costs `O(|V| · N · L)`; using a single pass with a hash map avoids this entirely.

Space complexity. The count table and vocabulary require `O(|V| + total tokens)` in the worst case, but because most terms appear in few documents, the count matrix is stored sparsely, typically as an inverted index: for each term, a posting list of `(doc_id, count)` pairs. Sparse storage costs `O(number of nonzero (term, doc) pairs)`, which is far smaller than the dense `N × |V|` matrix for any real corpus, since a document only ever uses a tiny fraction of the vocabulary.

Query time. Computing cosine similarity between a query and all `N` documents naively costs `O(N · |V|)`, but using the inverted index — only visiting documents that share at least one term with the query — costs `O(sum over query terms of |posting list|)`, which is typically far smaller than `N`.

Correctness argument. The core claim to justify is that TF-IDF weighting produces a more discriminative representation than raw counts. Consider two documents sharing a stopword `t` with `df(t) = N` (appears in every document). Then: ``` idf(t) = log(N / N) = log(1) = 0 ``` so `w(t, d) = tf(t,d) × 0 = 0` for every document — the term contributes nothing to any similarity computation, which is exactly the desired behavior since a term present everywhere carries zero information about which documents are related. Conversely, a term appearing in exactly one document has maximal `idf(t) = log(N)`, the largest possible value, and it can only ever contribute weight to that one document, correctly marking it as highly document-specific. More formally, IDF is a monotonically decreasing function of `df(t)`: as `df(t)` increases from `1` to `N`, `idf(t) = log(N/df(t))` decreases monotonically from `log(N)` to `0`. This monotonicity is the entire correctness property required: rarer terms (small `df`) always receive weight greater than or equal to more common terms, for any fixed TF. This matches the information-theoretic intuition that a term seen in `df(t)` out of `N` documents carries roughly `log(N / df(t))` bits of information about document identity — IDF is literally an empirical estimate of self-information under a uniform prior over documents.

Tradeoffs. TF-IDF is a bag-of-words method: it discards word order, syntax, and polysemy — "bank" (river) and "bank" (finance) get one shared weight regardless of context. It also has no notion of semantic similarity between different words ("car" and "automobile" are orthogonal dimensions). These limitations motivate the move toward dense, learned embeddings, but TF-IDF remains attractive because it is deterministic, requires no training, is fully interpretable (every weight traces to a count), and is extremely cheap to compute and update incrementally as new documents arrive.

Worked example

Consider a tiny corpus of three documents: ``` d1: "the cat sat on the mat" d2: "the cat sat on the sofa" d3: "the dog barked loudly" ``` `N = 3`. After removing pure counting (no stopword removal, to show IDF handles "the" automatically), the document frequencies are:

Termdf(t)idf(t) = log10(N/df(t))
the3log10(3/3) = 0.000
cat2log10(3/2) = 0.176
sat2log10(3/2) = 0.176
on2log10(3/2) = 0.176
mat1log10(3/1) = 0.477
sofa1log10(3/1) = 0.477
dog1log10(3/1) = 0.477
barked1log10(3/1) = 0.477
loudly1log10(3/1) = 0.477

Now compute TF-IDF for document `d1` ("the cat sat on the mat"), using log-scaled TF `tf(t,d) = 1 + log10(count(t,d))`:

Termcount(t, d1)tf(t, d1)idf(t)tf × idf
the21 + log10(2) = 1.3010.0000.000
cat11 + log10(1) = 1.0000.1760.176
sat11.0000.1760.176
on11.0000.1760.176
mat11.0000.4770.477

Notice that "the" appears twice in `d1` — the single most frequent term by raw count — yet its final weight is exactly zero, because it appears in every document and contributes nothing to distinguishing `d1` from anything else. Meanwhile "mat," appearing only once, receives the highest weight of any term in the document, because it is unique to `d1` in this corpus. This is the entire point of TF-IDF made concrete: raw frequency and final importance can be inversely related. L2-normalizing the raw weight vector `[0.176, 0.176, 0.176, 0.477]` (norm ≈ `sqrt(0.176²·3 + 0.477²) ≈ 0.559`) gives the final unit vector `≈ [0.315, 0.315, 0.315, 0.853]` for the terms {cat, sat, on, mat}. Repeating this for `d2` yields a vector sharing high weight on {cat, sat, on} but with "sofa" instead of "mat," so cosine similarity between `d1` and `d2` will be moderately high (they share three discriminative-ish terms) while similarity with `d3` (which shares only the zero-weighted "the") will be essentially zero — correctly reflecting that `d1` and `d2` are about similar things and `d3` is not.

Common pitfalls & traps

PitfallWhy it happensFix
Computing IDF per query instead of over the fixed corpusConfusing "document frequency in this batch" with the true corpus statisticFix IDF from a stable reference corpus; recompute only when the corpus meaningfully changes
Division by zero when a term never appears (`df(t)=0`)Term outside vocabulary, or off-by-one in indexingOnly compute IDF for terms observed at least once, or use `+1` smoothing
Forgetting to normalize document vectorsLonger documents accumulate larger raw dot products, biasing similarity toward long documentsL2-normalize every vector before comparing with cosine similarity
Rescanning the whole corpus per term to get `df(t)`Not building an inverted index or hash map firstCompute all `df` counts in a single pass, store in a map
Treating `tf-idf = 0` as "term absent" everywhereFor a stopword with `df(t)=N`, weight is legitimately zero even if term is presentDistinguish "zero weight" from "zero count" — don't conflate the two when debugging
Applying the same IDF table across drastically different corporaIDF is corpus-relative; a rare word in one domain may be common in anotherRecompute IDF per domain/corpus rather than reusing a generic table blindly

Pioneers & history

TF-IDF grew out of early information retrieval research at Cornell, where scientists were building the first computerized document search and indexing systems, formalizing intuitions that had been circulating informally about term specificity.

Origins · 1 minKaren Spärck Jones and how this was discovered
KS
Karen Spärck Jones
1935–2007 · Cambridge computer scientist and information retrieval pioneer

Spärck Jones formalized inverse document frequency in 1972, in a landmark paper on statistical interpretation of term specificity, providing the mathematical justification for why rare terms should carry more weight in retrieval. Her later work on natural language understanding and evaluation methodology shaped how the entire field measures progress, and she remains one of the most cited figures in information retrieval and computational linguistics.

History
1957
— Hans Peter Luhn proposes frequency-based measures of term significance for automatic indexing.
1972
— Karen Spärck Jones formalizes inverse document frequency as a statistical measure of term specificity.
1975
— Gerard Salton and colleagues integrate TF and IDF into the vector space model for automatic retrieval.
1988
— Salton and Buckley publish a systematic comparison of TF-IDF weighting variants, cementing standard formulas.
1998
onward — TF-IDF becomes a default baseline feature representation in search engines and text classifiers.

Where this matters

Search engines use TF-IDF or its close descendant BM25 to rank documents against a query, forming the retrieval stage of virtually every classical information retrieval system and many modern hybrid search pipelines that combine sparse lexical scores with dense neural retrieval. Document clustering and topic modeling pipelines often use TF-IDF vectors as input features before applying k-means or matrix factorization. Plagiarism and near-duplicate detection systems compare TF-IDF vectors via cosine similarity to flag suspiciously similar documents. Recommender systems for text-heavy content (news, articles, product descriptions) use TF-IDF to build content-based similarity scores. Even in the era of transformer-based embeddings, TF-IDF remains a standard baseline in benchmarking, a common feature in hybrid retrieval-augmented generation pipelines (sparse + dense retrieval), and a fast fallback when labeled training data or GPU compute is unavailable — a natural bridge into the statistical foundations of Text Classification Fundamentals, where TF-IDF vectors often serve as the input features to a classifier before neural methods are introduced.

Ask the tutor: How would you modify the TF-IDF formula to handle a streaming corpus where documents arrive continuously and the vocabulary keeps growing?

Why use a logarithm for term frequency instead of the raw count?

Raw counts overstate the importance of very frequent terms: a word appearing 50 times is not really 50 times "more about" the document than a word appearing once — after a point, additional occurrences add little new information. The logarithm compresses this scale, so going from 1 to 2 occurrences matters more than going from 50 to 51. The common formula `tf = 1 + log10(count)` for `count > 0` keeps the value at exactly 1.0 for a single occurrence (so it doesn't vanish) while dampening the effect of high counts. Some systems skip log-scaling entirely and use raw counts or binary presence, especially for short documents where counts rarely get large; the choice is a design tradeoff, not a fixed rule.

Why does IDF use a logarithm rather than a plain ratio N/df(t)?

Without the logarithm, a term appearing in only 1 out of a million documents would get a weight of 1,000,000, utterly dwarfing every other feature and making the vector space numerically unstable. The logarithm compresses this huge dynamic range into a manageable scale while preserving the essential ordering: rarer terms still get strictly higher IDF than common ones, since log is monotonically increasing. This mirrors information theory, where `log(N/df(t))` approximates the number of bits of information gained from observing that a term occurred, given a uniform prior over which document a random term-occurrence came from.

What is the difference between TF-IDF and BM25?

BM25 is a refinement of the TF-IDF idea used in modern search engines, adding two key improvements: term-frequency saturation (extra occurrences of a term contribute diminishing returns, controlled by a parameter `k1`) and document-length normalization (a term match in a short document is treated as stronger evidence than the same match in a long document, controlled by parameter `b`). Where classic TF-IDF uses `1 + log(tf)`, BM25 uses a smooth saturating function `tf·(k1+1) / (tf + k1·(1 - b + b·|d|/avgdl))`. BM25 generally ranks better empirically and is the default scoring function in systems like Elasticsearch and Lucene, but TF-IDF remains simpler to explain, implement, and reason about analytically.

Does TF-IDF work well for very short texts, like tweets or search queries?

TF-IDF weakens on very short texts because term frequency carries little signal when most terms appear only zero or one time — there is no frequency distribution within the document to exploit. IDF still provides useful discrimination (rare words in a short query are still informative), but the overall vector becomes extremely sparse, and two short documents may share zero terms even when topically related, since TF-IDF has no mechanism for recognizing synonyms or paraphrases. This is one major reason dense embeddings, which generalize across paraphrases via learned semantic geometry, often outperform TF-IDF specifically on short-text tasks like query understanding or tweet classification.

Is TF-IDF a supervised or unsupervised technique?

TF-IDF is entirely unsupervised — it requires no labels, only the raw text of the corpus. It is computed purely from document statistics (term counts and document frequencies), which is exactly why it can be applied to any collection of text without needing any annotated training examples. This makes it attractive as a preprocessing step before supervised learning: TF-IDF vectors are frequently used as input features to supervised classifiers (like logistic regression or SVMs) in the classic pipeline for Text Classification Fundamentals, where the labels are only needed at the classifier stage, not for computing the feature representation itself.

Can TF-IDF handle out-of-vocabulary words at query time?

Not directly. Since IDF is precomputed from a fixed corpus vocabulary, any term in a query that never appeared in the training corpus has no corresponding IDF value and is typically just dropped, contributing zero weight to the query vector. This is a meaningful limitation compared to subword-based neural tokenizers, which can represent unseen words as combinations of known subword pieces. In production systems, this is usually handled by periodically rebuilding the vocabulary and IDF table from an updated corpus snapshot, accepting that there will always be some lag between real-world vocabulary and the system's indexed vocabulary.

How does TF-IDF relate to the bag-of-words model?

TF-IDF is built directly on top of the Bag-of-Words Model: both represent a document as an unordered collection of word counts, discarding grammar and word order entirely. The difference is purely in how those counts are transformed into final numeric weights before being used for similarity or classification. Bag-of-words alone gives you raw or normalized counts; TF-IDF re-weights those same counts using corpus-wide document frequency statistics, converting a purely local count vector into a vector that also encodes global informativeness. Every limitation of bag-of-words — no word order, no syntax, no semantics — is inherited unchanged by TF-IDF.

Before this

Where this leads