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.
- 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.
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`.
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:
| Term | df(t) | idf(t) = log10(N/df(t)) |
|---|---|---|
| the | 3 | log10(3/3) = 0.000 |
| cat | 2 | log10(3/2) = 0.176 |
| sat | 2 | log10(3/2) = 0.176 |
| on | 2 | log10(3/2) = 0.176 |
| mat | 1 | log10(3/1) = 0.477 |
| sofa | 1 | log10(3/1) = 0.477 |
| dog | 1 | log10(3/1) = 0.477 |
| barked | 1 | log10(3/1) = 0.477 |
| loudly | 1 | log10(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))`:
| Term | count(t, d1) | tf(t, d1) | idf(t) | tf × idf |
|---|---|---|---|---|
| the | 2 | 1 + log10(2) = 1.301 | 0.000 | 0.000 |
| cat | 1 | 1 + log10(1) = 1.000 | 0.176 | 0.176 |
| sat | 1 | 1.000 | 0.176 | 0.176 |
| on | 1 | 1.000 | 0.176 | 0.176 |
| mat | 1 | 1.000 | 0.477 | 0.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
| Pitfall | Why it happens | Fix |
|---|---|---|
| Computing IDF per query instead of over the fixed corpus | Confusing "document frequency in this batch" with the true corpus statistic | Fix 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 indexing | Only compute IDF for terms observed at least once, or use `+1` smoothing |
| Forgetting to normalize document vectors | Longer documents accumulate larger raw dot products, biasing similarity toward long documents | L2-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 first | Compute all `df` counts in a single pass, store in a map |
| Treating `tf-idf = 0` as "term absent" everywhere | For a stopword with `df(t)=N`, weight is legitimately zero even if term is present | Distinguish "zero weight" from "zero count" — don't conflate the two when debugging |
| Applying the same IDF table across drastically different corpora | IDF is corpus-relative; a rare word in one domain may be common in another | Recompute 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
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.
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.