Skip to content
Select theme

What TF-IDF actually measures

Step 5 of 12, and the postings lists start answering questions. Every posting gains a term frequency, every document gains a length, and haystack can rank: for a query term it now produces a score per document instead of a list of ids. The scorer is TF-IDF in its classic form, and on this corpus it gets the most obvious query wrong — a long page that mentions “borrow” in passing outranks the page that is about borrowing. That failure is the document length bias, and it is the reason step 6 exists.

The starting state is tag search-ch04, the end of step 4: an index that saves to disk and loads back. The end state of this step is tagged search-ch05; the whole file is at the end of this page, and the checkout commands follow it.

Since step 2 a posting has been a bare document id. That answers “which documents contain this term”, and document frequency — how many documents contain it — comes for free, as the length of the postings list. TF-IDF needs two more numbers, and neither is on disk:

  • Term frequency. How often the term appears inside one document. A page that says “borrow” four times is more about borrowing than one that says it once.
  • Document length. How many terms the document holds. Without it, the extra mentions a long document makes look like enthusiasm rather than length.

Both are counted while the text is in hand, at indexing time; neither can be recovered from the file afterwards. So the file format changes. Version 1 held ids only. Version 2 holds the rest, and refuses a version 1 file rather than guessing.

add_document tokenises the text and sorts the terms. Step 2 sorted them to deduplicate; now the same sorted list is walked in runs, and a run of equal terms is that term’s frequency in the document:

[("dog", 1), ("fox", 3)]

Each distinct term becomes one posting, and the run’s length rides along in it as tf. The document’s length is the term count before deduplication — every term the tokeniser kept. Stop words are not in the index, so they are not in the length either: a document’s length is the number of terms that could match something.

Document frequency is postings.len(), the thing step 4 wrote to disk. The inverse document frequency turns it into a weight:

idf = ln(N / df)

where N is the number of documents and df the document frequency. The form dates to Karen Sparck Jones’s 1972 paper on term specificity, and it has outlived every replacement anyone has proposed for it:

idf = ln(6 / df):
df=1 idf=1.7918
df=2 idf=1.0986
df=3 idf=0.6931
df=4 idf=0.4055
df=5 idf=0.1823
df=6 idf=0.0000

The curve is the whole idea. A term in every document scores zero — ln(N/N) is ln 1 — because it cannot separate one document from another. A term in one document scores ln N, the most there is. The log is a compression, not a law: without it the ratio is unbounded, and on a million-document corpus a term in one document would be worth a million times a term in all of them, one rare word drowning every other signal in the score. The natural log of a million is about 13.8 — still the top of the range, but a range.

What the log buys in the middle is a fixed step: halving the document frequency adds ln 2 to the weight, whether df goes from 100 to 50 or from 2 to 1.

TF-IDF multiplies the two: the term’s frequency in the document, times the inverse of its document frequency. Nothing else enters.

doc 5 tf=5 score=0.9116
doc 1 tf=4 score=0.7293
doc 0 tf=1 score=0.1823
doc 2 tf=1 score=0.1823
doc 3 tf=1 score=0.1823

Sorting floats needs a decision. f64 is not Ord, because of NaN; total_cmp gives every float a place in a total order, and sort_by is stable, so documents with equal scores keep the order they arrived in. Postings arrive in ascending document order, so ties come out in document order, and the ranking is a function of the index alone — no hash seed, no run-to-run drift.

Those five postings are the demo corpus’s real “borrow” postings, and the ranking they produce is wrong in an instructive way. From the full run below:

postings for "borrow" — term frequency, then document length:
docs/ownership.md tf=1 len=15
docs/borrow-checker.md tf=4 len=12
docs/lifetimes.md tf=1 len=13
docs/iterators.md tf=1 len=13
docs/style-guide.md tf=5 len=57
query "borrow", ranked by tf * idf:
0.9116 docs/style-guide.md tf=5 len=57
0.7293 docs/borrow-checker.md tf=4 len=12
0.1823 docs/ownership.md tf=1 len=15
0.1823 docs/lifetimes.md tf=1 len=13
0.1823 docs/iterators.md tf=1 len=13

The style guide wins. It is 57 terms long and mentions “borrow” five times in passing, in a paragraph of advice about something else. The borrow checker page is 12 terms long and mentions it four times, because both of its sentences are about borrowing. TF-IDF’s discount is across documents — it asks how many documents contain the term, and never how long the document is. Term frequency is a count, and a long document has more room for counts. That is the document length bias: not a bug in the formula, a property of it.

The second query shows the same scorer behaving:

query "owner", ranked by tf * idf:
2.1972 docs/ownership.md tf=2 len=15
1.0986 docs/borrow-checker.md tf=1 len=12

“owner” is in two documents; ownership.md mentions it twice and wins. Here term frequency does honest work, because the more frequent document is also the more relevant one. The scorer has no opinion about length. Documents do.

Step 6 replaces the scorer, and the test that pins this ranking changes with it.

The format from step 4 gains two fields:

  • per document — a u32 term count, straight after the path.
  • per posting — a u32 term frequency, straight after the document id.

Everything else is where it was. The version constant moves to 2, and the demo’s last line is the version field earning its keep — the same file with the version patched to 1:

a version 1 file: unsupported index version 1

A version 1 file does not hold the numbers the ranker needs, and guessing them is not an option.

One more refusal joins the loader’s. Step 4’s loader accepted a hand-edited file whose postings list ran backwards, and predicted it would confuse the ranker that step 5 was about to build. The ranker itself would have survived it — the scores do not change — but ties would come out in whatever order the file happened to hold, and the ascending, duplicate-free invariant that add_document builds on, and that step 7’s positions will need, would be gone. So the loader checks it now:

the postings list for "x" is not in ascending document order

One file: Posting, Document, the run-length counter, the scorer, the version 2 format with its new refusal, and a sixth corpus document. The block below is the file at tag search-ch05, byte for byte.

From the haystack directory:

Terminal window
cargo run
6 documents, 84 distinct terms
saved to haystack.idx: 2131 bytes
saved again: 2131 bytes, byte-identical: true
loaded back: 6 documents, 84 terms
postings for "borrow" — term frequency, then document length:
docs/ownership.md tf=1 len=15
docs/borrow-checker.md tf=4 len=12
docs/lifetimes.md tf=1 len=13
docs/iterators.md tf=1 len=13
docs/style-guide.md tf=5 len=57
query "borrow", ranked by tf * idf:
0.9116 docs/style-guide.md tf=5 len=57
0.7293 docs/borrow-checker.md tf=4 len=12
0.1823 docs/ownership.md tf=1 len=15
0.1823 docs/lifetimes.md tf=1 len=13
0.1823 docs/iterators.md tf=1 len=13
query "owner", ranked by tf * idf:
2.1972 docs/ownership.md tf=2 len=15
1.0986 docs/borrow-checker.md tf=1 len=12
a version 1 file: unsupported index version 1

The opening lines are the round trip from step 4, unchanged in shape: the same index, saved twice, byte-identical, loaded back. What is new is everything after it — postings that carry frequencies, two ranked queries, and one refusal. The sixth document is the corpus gaining a deliberately long page; the ranking above is what it costs.

The tests:

Terminal window
cargo test
running 33 tests
test tests::a_doubled_consonant_from_the_suffix_is_collapsed ... ok
test tests::a_missing_file_is_an_io_error ... ok
test tests::a_missing_term_is_empty_not_an_error ... ok
test tests::a_newer_version_is_refused ... ok
test tests::a_postings_list_that_is_not_ascending_is_refused ... ok
test tests::a_repeated_term_records_how_often_it_repeated ... ok
test tests::a_stem_needs_three_characters ... ok
test tests::a_term_repeated_in_one_document_lists_it_once ... ok
test tests::a_term_that_is_not_utf8_is_refused ... ok
test tests::a_term_in_two_documents_lists_both ... ok
test tests::a_version_1_file_is_refused ... ok
test tests::a_wrong_magic_is_refused ... ok
test tests::a_saved_index_loads_back_identical ... ok
test tests::add_document_returns_the_id_it_gave ... ok
test tests::case_folding_is_unicode_aware ... ok
test tests::a_truncated_file_is_refused ... ok
test tests::a_terms_frequency_survives_a_reload ... ok
test tests::casing_and_punctuation_are_folded_away ... ok
test tests::distinct_terms_in_one_document_get_one_posting_each ... ok
test tests::an_empty_index_round_trips ... ok
test tests::document_length_counts_the_terms_the_tokeniser_kept ... ok
test tests::doubling_that_is_not_the_suffixs_fault_survives ... ok
test tests::idf_falls_as_the_document_frequency_rises ... ok
test tests::idf_is_zero_for_a_term_in_every_document ... ok
test tests::ids_are_in_insertion_order ... ok
test tests::inflections_share_one_term ... ok
test tests::rank_keeps_the_input_order_of_equal_scores ... ok
test tests::rank_puts_the_highest_score_first ... ok
test tests::stop_words_are_not_indexed ... ok
test tests::saving_twice_writes_the_same_bytes ... ok
test tests::raw_term_frequency_lets_the_longer_document_win ... ok
test tests::trailing_bytes_are_refused ... ok
test tests::the_dictionary_is_written_in_sorted_order ... ok
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Thirty-three tests: the twenty-two from the earlier steps, and eleven around the new numbers — term frequencies, document lengths, the scorer’s ordering, the version 1 refusal, and the ascending check. The order is shuffled on every run, so nothing should be read into it.

This step’s state is tagged search-ch05 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/search-engine
git checkout search-ch05

The tag contains Cargo.toml, the README and src/main.rs in full. If your build misbehaves, diff your files against the tag’s — the drifting line is usually findable that way. The track overview keeps the step list current.

Step 6, Ranking with BM25, replaces the scorer: two constants, k1 and b, at the values Lucene ships, turn the raw count into a saturating one and divide the length out.