Lab 07

Query Expansion, Relevance Feedback and Language Models

Query expansion Rocchio Language models Smoothing
Work in progress These notes are still being written and reviewed: content may change, and some parts may still contain mistakes.

Overview of the lab

Up to now our retrieval systems assumed that the query already contains the right words. But users often describe the same concept differently from the documents they want: the query car repair, the relevant document automobile maintenance. This is the vocabulary mismatch problem. Two classical ways to address it: improve the query (query expansion, relevance feedback), or model how likely a document is to generate the query (language-model retrieval).

  1. The vocabulary mismatch problem.
  2. Query expansion, and how it fails: query drift.
  3. Relevance feedback and Rocchio.
  4. Pseudo-relevance feedback, live on Cranfield.
  5. Language models: n-grams, maximum likelihood, perplexity.
  6. Smoothing n-gram models: Laplace, Good–Turing, backoff and interpolation.
  7. Language models for retrieval: query likelihood.
  8. The zero-probability problem: collection models and Jelinek–Mercer smoothing.
  9. Query likelihood on Cranfield.
  10. Comparing the retrieval paradigms, and the bridge to dense retrieval and RAG.
  11. Summary, and optional exercises.

Learning objectives

By the end you should be able to explain:

  • what vocabulary mismatch is, and the difference between query expansion and relevance feedback;
  • explicit vs pseudo-relevance feedback, and the intuition behind the Rocchio algorithm;
  • why query expansion can help and also hurt;
  • what a language model is, how n-gram probabilities are estimated, and how perplexity evaluates them;
  • the query-likelihood view of retrieval, and the difference between document and collection language models;
  • why smoothing is necessary;
  • how classical query expansion connects to modern retrieval, dense retrieval and RAG.
Part I

The vocabulary mismatch problem

A person immediately sees the relation between a query and a document that use different words. A purely lexical system sees only whether the strings match.

QuerycarrepairDocumentautomobilemaintenancemanual0 shared terms
QueryheartattacktreatmentDocumenttherapyformyocardialinfarction0 shared terms

TF-IDF and BM25 are powerful, but fundamentally rely on lexical evidence. They do not inherently know that car ≈ automobile, doctor ≈ physician, cheap ≈ inexpensive. For both pairs above the TF-IDF cosine and the BM25 score are exactly 0. This motivates techniques that alter or reinterpret the query.

Part II

Query expansion

📐 Query expansion

Adding terms to the original query: car repair → car automobile repair maintenance. The hope is to improve recall: a short query provides very little evidence, and expansion can retrieve documents that use different vocabulary.

  • Sources of expansion terms: thesauri, synonym dictionaries, ontologies, WordNet, user-selected relevant documents, top-ranked retrieved documents, historical query logs.
  • We focus on relevance feedback and pseudo-relevance feedback, because they connect directly to information retrieval.

Query expansion can fail: query drift

For jaguar speed, possible expansion terms are car, engine, animal, cat, wildlife. If the user meant the animal, adding vehicle-related terms creates query drift: the expansion changes the meaning of the original information need. python memory → python programming language snake reptile memory mixes unrelated senses. Query expansion is not automatically beneficial: the source and the weighting of expansion terms matter.

Try it: expand a query

Thirteen short invented documents; a document scores 1 for each original query term it contains and \(w\) for each expansion term. Tick expansion terms from a small thesaurus (grouped by word sense) and watch recall and precision. The relevant documents (green) follow the sense the user meant.

0.5
Part III

Relevance feedback and Rocchio

Relevance feedback uses information about which retrieved documents are relevant. It is one of the classical interactive ideas in IR.

query → retrieve documents → user marks relevant / non-relevant → modify query → retrieve again
  • Explicit relevance feedback: the user tells the system "D1 relevant, D2 not relevant, D3 relevant", and the system uses these labels to move the query toward relevant documents and away from non-relevant ones.
  • Vector-space intuition: recall the query and document vectors of Lab 04. If relevant documents cluster in one area of the vector space, move the query vector toward them; if non-relevant ones cluster elsewhere, move away from them.
📐 Rocchio algorithm
\[ \vec{q}_{new} = \alpha\,\vec{q}_{old} + \frac{\beta}{|D_r|} \sum_{d \in D_r} \vec{d} \;-\; \frac{\gamma}{|D_{nr}|} \sum_{d \in D_{nr}} \vec{d} \]

\(\vec{q}_{old}\) = original query vector; \(D_r\), \(D_{nr}\) = relevant and non-relevant documents; \(\alpha\), \(\beta\), \(\gamma\) = weights of the original query, the relevant documents and the non-relevant documents. Geometrically: start from the original query, move toward the relevant-document centroid, move away from the non-relevant centroid. This is more important than memorizing the formula.

Try it: Rocchio in two dimensions

The outline's example: query \([1.0, 0.0]\), relevant centroid \([0.6, 0.8]\), non-relevant centroid \([0.1, 0.9]\). Click a document to cycle it through relevant → non-relevant → unjudged. The dashed arrow is the new query; the list ranks the documents by cosine similarity before and after.

1
0.75
0.15

💡 \(\alpha = 1\), \(\beta = 0.75\), \(\gamma = 0.15\) are the values suggested in Manning, Raghavan & Schütze's Introduction to Information Retrieval (§9.1.1). \(\gamma\) is usually small: relevant documents resemble each other, non-relevant documents are "all the rest" and point in many directions. Negative term weights are usually set to 0.

What does Rocchio really do?

Two things: it reweights the original query terms, and it introduces terms from the relevant documents. The resulting query can contain terms that were not present originally: that is the expansion effect. So relevance feedback is not simply "append synonyms": it changes the query representation.

Try it: Rocchio on words

The query car repair over the collection of Part II, as term-frequency vectors normalized to length 1. Mark results relevant (R) or non-relevant (N); the new query's terms appear with their weights (new terms highlighted), and the collection is ranked again with the new query.

Part IV

Pseudo-relevance feedback

Explicit feedback is useful, but it requires user effort. Pseudo-relevance feedback (PRF, also blind feedback) removes that requirement: the system assumes that the top-ranked documents are probably relevant and uses them as feedback automatically.

original query → initial retrieval → top-k documents → assume relevant → extract useful terms → expanded query → retrieve again
  • Example: for aircraft wing drag the top documents contain aerodynamic, airfoil, lift, flow, wing, drag; a possible expanded query is aircraft wing drag aerodynamic airfoil flow. This may retrieve relevant documents that did not contain the original wording.
  • ✅ Strength: no user interaction is required.
  • ❌ Weakness: if the initial retrieval is poor, bad top documents → bad expansion terms → an even worse query. This is another form of query drift.

Try it: PRF on Cranfield

The optional exercise of the outline, live: retrieve with BM25 (Lab 04's BM25Okapi on text.lower().split()), take the top \(k\) documents, rank their words by mean TF-IDF weight (TfidfVectorizer(stop_words="english")), and add the best \(m\) that are not already in the query. Untick a term to leave it out. Grades are Cranfield's relevance judgments (Lab 05).

3
3

💡 The default query shows the weakness at once: document 486, judged not relevant (−1), is one of the three feedback documents; its word aerothermoelastic becomes the strongest expansion term, and 486 moves to rank 1. AP falls from 0.215 to 0.194.

prf.py Python
# texts, bm25 (BM25Okapi on text.lower().split()), queries: as in Lab 04
import re
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

tfv = TfidfVectorizer(stop_words="english")
X = tfv.fit_transform(texts)
vocab = tfv.get_feature_names_out()

def expand(query, k_docs=3, n_terms=3):
    scores = bm25.get_scores(query.lower().split())
    top = scores.argsort()[::-1][:k_docs]                 # assume these are relevant
    weights = np.asarray(X[top].mean(axis=0)).ravel()     # mean TF-IDF weight of each word
    q_terms = set(re.findall(r"\b\w\w+\b", query.lower()))
    new = [vocab[j] for j in np.argsort(-weights, kind="stable") if vocab[j] not in q_terms]
    return query + " " + " ".join(new[:n_terms])

expand(queries[0].text)
# 'what similarity laws must be obeyed when constructing aeroelastic models\nof heated
#  high speed aircraft . aerothermoelastic stressing structural'

💡 Evaluated with ir_measures over all 225 queries (top 100), PRF with \(k = 3\), \(m = 3\) raises BM25's MAP from 0.2406 to 0.2634, P@10 from 0.1991 to 0.2129 and Recall@100 from 0.6513 to 0.6866. On average it helps, but query by query it helps 116 queries and hurts 92. With \(m = 2\) it hurts more queries (107) than it helps (99), although MAP still rises. Averages hide drift.

First-half recap

vocabulary mismatch → query expansion → relevance feedback → Rocchio → pseudo-relevance feedback

Instead of modifying the query, can we build a probabilistic model of the document and ask how likely it is to produce the query? That leads to language-model retrieval. First, what a language model is.

Part V

Language models

📐 Language model

A probabilistic model that assigns probabilities to terms or sequences: it predicts the probability that a sequence of tokens belongs to a language.

  • Applications: whenever there are several options and we must choose the most likely, based on the context words:
    • compare sentences to tell which are the "good" ones;
    • spell-checking (Lab 03): for "I want some cugar in my coffee", cougar or sugar?
    • automatic speech recognition: the most likely sequence of words; machine translation, question answering, summarization;
    • information retrieval: a document is likely to answer a query well if it is likely to generate the query (Part VII).
  • Language models can score and sort sentences: P(I like apples) ≫ P(I lick apples). They encode some grammaticality: P(I was going to call) ≫ P(I will going to call).

First shot: the chain rule of probability

\[ P(w_1, \ldots, w_n) = \prod_{i=1}^{n} P(w_i \mid w_1, \ldots, w_{i-1}) \]

P(I, want, some, sugar, in, my, coffee) = P(I) · P(want | I) · P(some | I, want) · … · P(coffee | I, want, some, sugar, in, my). Estimate each factor by counting in a corpus, e.g. Jane Austen's Emma (192,427 tokens in NLTK's Gutenberg collection):

chain_rule.py Python
# import nltk; nltk.download("gutenberg")
import nltk

emma_words = list(nltk.corpus.gutenberg.words("austen-emma.txt"))
N = len(emma_words)

def count(seq):                        # how often the exact token sequence occurs
    n = len(seq)
    return sum(emma_words[i:i + n] == seq for i in range(N - n + 1))

for sentence in [["She", "was", "the", "youngest"],
                 ["I", "want", "some", "sugar", "in", "my", "coffee"]]:
    prob = count(sentence[:1]) / N                      # P(w1)
    for i in range(1, len(sentence)):
        num, den = count(sentence[:i + 1]), count(sentence[:i])
        print(f"P({sentence[i]} | {' '.join(sentence[:i])}) = {num}/{den}")
        prob *= num / den if den else 0.0
        if prob == 0: break
    print("Sentence probability:", prob)
# P(was | She) = 110/562
# P(the | She was) = 3/110
# P(youngest | She was the) = 1/3
# Sentence probability: 5.196775920219095e-06
# P(want | I) = 12/3178
# P(some | I want) = 0/12
# Sentence probability: 0.0

Problem: "I want some sugar in my coffee" is a perfectly good sentence, but "I want some" never occurs in Emma, so its probability is 0. We would need an enormous corpus to see all possible sentences often enough, and the possible sentences are too many: we will never see enough of them.

💡 Corrected from the notebook: it counted with " ".join(words).count(...), which also matches inside longer words (570 matches for "She" instead of its 562 tokens), and it skipped factors with a zero count, reporting 0.0045 for the sugar sentence instead of 0. It also left out \(P(w_1)\).

The Markov assumption: n-grams

Take only the \(l\) words before the given one: \(P(w_1, \ldots, w_n) \approx \prod_i P(w_i \mid w_{i-l}, \ldots, w_{i-1})\).

  • Unigram model: \(P(w_i \mid w_1, \ldots, w_{i-1}) \approx P(w_i)\). P(coffee | I, want, some, sugar, in, my) ≈ P(coffee).
  • Bigram model: condition only on the previous word, \(P(w_i \mid w_{i-1})\). P(coffee | I, want, some, sugar, in, my) ≈ P(coffee | my).
  • For more context, and more dependence between words: trigram, 4-gram, … models.
📐 Maximum-likelihood estimate (bigram)
\[ P(w_i \mid w_{i-1}) = \frac{c(w_{i-1}, w_i)}{c(w_{i-1})} \]

The count of the pair divided by the count of the first word. Sentences are padded with a start symbol [s] and an end symbol [/s], so that the first word and the end of the sentence are predicted too.

Try it: a bigram model

Trained on the notebook's five sentences (edit them if you like). Click a word in the table to see what follows it; type a sentence to score it. The smoothing options are explained in Part VI.

0.5
1What is the downside of using a unigram model?

It ignores word order and context completely: P(I like apples) = P(apples like I), and the most probable "next word" is always the same, the most frequent word (in Emma: the comma). It cannot tell good sentences from shuffled ones.

2With the bigram model of the five sentences, what is the most probable next word for (1) [s] Sam …, (2) [s] Sam I do …, (3) [s] Sam I am Sam …, (4) [s] do I like …?

Only the last word matters. (1) after Sam: I (3/5; [/s] 2/5). (2) after do: a tie, I or like (1/2 each). (3) after Sam again: I (3/5). (4) after like: [/s] (2/3; Sam 1/3).

3Which sentence is better: (5) [s] Sam I do I like [/s], (6) [s] Sam I am [/s], (7) [s] I do like Sam I am [/s]?

(6): \(\frac35 \cdot \frac35 \cdot \frac25 \cdot \frac12 = 0.072\). (5): \(\frac35 \cdot \frac35 \cdot \frac15 \cdot \frac12 \cdot \frac25 \cdot \frac23 = 0.0096\). (7): \(\frac15 \cdot \frac15 \cdot \frac12 \cdot \frac13 \cdot \frac35 \cdot \frac25 \cdot \frac12 = 0.0008\). Longer sentences multiply more factors below 1, so raw probabilities favour short sentences; perplexity (below) normalizes by length.

A bigram model of Emma

bigram_emma.py Python
import math, random
from collections import Counter, defaultdict
import nltk

emma_words = nltk.corpus.gutenberg.words("austen-emma.txt")
unigrams = Counter(emma_words)
bigrams = Counter(zip(emma_words, emma_words[1:]))      # exact token pairs

def p_bigram(prev, word):                                # MLE: c(prev, word) / c(prev)
    return bigrams[(prev, word)] / unigrams[prev]

sentence = ["she", "was", "to", "the"]
log_p = 0.0
for w1, w2 in zip(sentence, sentence[1:]):
    p = p_bigram(w1, w2)
    print(f"P({w2}|{w1}) = {bigrams[(w1, w2)]}/{unigrams[w1]} = {p:.4f}")
    log_p += math.log(p)                                 # add logs, do not multiply
print(f"log P = {log_p:.4f}, P = {math.exp(log_p):.3e}")
# P(was|she) = 218/1778 = 0.1226
# P(to|was) = 80/2385 = 0.0335
# P(the|to) = 236/5183 = 0.0455
# log P = -8.5830, P = 1.873e-04

following = defaultdict(Counter)
for (w1, w2), c in bigrams.items():
    following[w1][w2] = c

def generate_text(start_word, num_words=10, sample=True):
    sentence = [start_word]
    for _ in range(num_words):
        nxt = following[sentence[-1]]
        if not nxt:
            break
        words, counts = zip(*nxt.items())
        sentence.append(random.choices(words, weights=counts)[0] if sample
                        else max(nxt, key=nxt.get))
    return " ".join(sentence)

random.seed(0)
print(generate_text("I"))
print(generate_text("She"))
print(generate_text("She", sample=False))
# I was every thing in return of ." " You will
# She comforted away . Perry ," said Frank cut back earlier
# She was not be a very much to be a very

Try it: generate from Emma

The full bigram model of Emma (7,811 word types, 66,579 distinct bigrams) in your browser. Greedy always takes the most probable next word and soon loops; sampling draws the next word in proportion to its count.

💡 Corrected from the notebook: it built the bigram table with the same substring counting, so "a s" was counted 394 times (every "a s…" such as "a short", "a sort"), although the token pair (a, s) never occurs in Emma. That is why its greedy text read "She was a s a s a s". Its scoring cell also looked the table up the wrong way round, printing \(P(she \mid was) \approx 0.0029\) where \(P(was \mid she) = 0.1226\) was meant.

Problem 1: underflow

Multiplying many probabilities gives very small numbers, with a risk of underflow (the product rounds to 0). Work with logarithms: \(\log(p_1 p_2 p_3 p_4) = \log p_1 + \log p_2 + \log p_3 + \log p_4\). (The notebook's cell added the probabilities themselves, \(p_1 + p_2 + \ldots\), which is not a log-probability.)

Problem 2: zero counts, even in enormous corpora

  • Google Web 1T 5-gram (2006): 1,024,908,267,229 tokens of web text, 314,843,401 distinct bigrams, and it still keeps only n-grams seen at least 40 times.
  • Shakespeare: 884,647 tokens and 29,066 word types; he produced about 300,000 bigram types out of \(29{,}066^2 \approx 844\) million possible bigrams. Most possible bigrams were never seen (99.96%).

Evaluating a language model

  • Extrinsic: with respect to some task, e.g. how often it picks the correct spelling in a spell-checking task.
  • Intrinsic: how much probability the model gives to sentences from a test corpus (which should have the same distribution of words as the training data). The Shannon game: how well do we predict the next word, given some context?
📐 Perplexity
\[ PP(W) = P(w_1, w_2, \ldots, w_n)^{-\frac{1}{n}} \]

The inverse probability of the test text, normalized by the number of words: the average number of equally likely choices the model is "perplexed" between. Minimizing perplexity is maximizing probability. For a uniform distribution, perplexity equals the size of the vocabulary: a model that generates the digits 0–5 uniformly has perplexity 6.

4With the same training data, compute the perplexity of the bigram model on the test sentence "[s] I do like Sam".

\(P = P(I \mid [s]) \cdot P(do \mid I) \cdot P(like \mid do) \cdot P(Sam \mid like) = \frac15 \cdot \frac15 \cdot \frac12 \cdot \frac13 = \frac{1}{150}\). Four words are predicted, so \(PP = 150^{1/4} \approx 3.50\). (Type the sentence into the bigram playground to check.)

Part VI

Smoothing n-gram models

We want to avoid sparse statistics, and to decide what to do when a bigram count is 0.

Add-one (Laplace) and add-\(\beta\) smoothing

\[ P_{\text{add-1}}(w_i \mid w_{i-1}) = \frac{c(w_{i-1}, w_i) + 1}{c(w_{i-1}) + |V|} \qquad P_{\text{add-}\beta}(w_i \mid w_{i-1}) = \frac{c(w_{i-1}, w_i) + \beta}{c(w_{i-1}) + \beta |V|} \]
  • Pretend we saw each word one more time than we did. This moves probability mass from "the rich" to "the poor".
  • Why is it bad? Sometimes ~90% of the probability mass is spread across unseen events, and we must know \(V\) beforehand. In the bigram playground, with Laplace the unseen pair (Sam, do) gets 1/11, as much as a seen pair gets in some rows.
5Bigram model with Laplace smoothing on the same five sentences. Give P(do|[s]), P(do|Sam), P(Sam|[s]), P(Sam|do), P(I|Sam), P(I|do), P(like|I). Then: which is more probable, (8) [s] do Sam I like or (9) [s] Sam do I like?

\(|V| = 6\): the tokens that can follow a word (I, am, Sam, do, like, [/s]). Histories: [s] 5 times, Sam 5, I 5, do 2. P(do|[s]) = (1+1)/(5+6) = 2/11; P(do|Sam) = (0+1)/11 = 1/11; P(Sam|[s]) = (3+1)/11 = 4/11; P(Sam|do) = (0+1)/(2+6) = 1/8; P(I|Sam) = (3+1)/11 = 4/11; P(I|do) = (1+1)/8 = 1/4; P(like|I) = (2+1)/11 = 3/11.

(8) = \(\frac{2}{11} \cdot \frac18 \cdot \frac{4}{11} \cdot \frac{3}{11} = \frac{3}{1331} \approx 0.00225\); (9) = \(\frac{4}{11} \cdot \frac{1}{11} \cdot \frac14 \cdot \frac{3}{11} = \frac{3}{1331}\). Equally probable. Without smoothing both would be 0 (neither "do Sam" nor "Sam do" occurs).

Good–Turing

Intuition: you are fishing and caught 10 carp, 3 perch, 2 whitefish, 1 trout, 1 salmon, 1 eel = 18 fish. How likely is it that the next species is trout? 1/18. How likely is a new species (catfish, bass)? Use the things we saw once to estimate the new things: 3/18. Then the probability of trout must be less than 1/18.

📐 Good–Turing
\[ P(\text{unseen}) = \frac{N_1}{N} \qquad c^* = \frac{(c+1)\,N_{c+1}}{N_c} \qquad P^*(x) = \frac{c^*}{N} \]

\(N_c\) = the number of n-grams (species) that occur exactly \(c\) times; \(N\) = the total count.

Try it: a Good–Turing calculator

Edit the counts. The table shows \(N_c\), the re-estimated counts and probabilities.

  • The notebook's example is the words that follow "I" in the five sentences: am 2, like 2, do 1, so \(N = 5\), \(N_1 = 1\), \(N_2 = 2\). P(unseen) = 1/5; \(c^*(do) = 2 \cdot 2 / 1 = 4\), so \(P^*(do) = 4/5\) (load After "I").
  • Problems: when a word occurs \(c\) times but no word occurs \(c+1\) times, \(c^* = 0\) (am and like above, since \(N_3 = 0\)), and with tiny data \(c^*\) can even exceed \(c\) (do: 1 → 4). Real implementations smooth the \(N_c\) values first.
  • And if \(c(\text{amazing} \mid \text{weather}) = 0\) and \(c(\text{amazing} \mid \text{trout}) = 0\), smoothing gives both unseen bigrams the same probability, although the first should be more likely, as weather is the more frequent word. So combine unigram, bigram and trigram models.

Backoff and interpolation

  • Backoff: use the trigram if you have good evidence for it, otherwise the bigram, otherwise the unigram.
  • Interpolation: always mix all three, \(P(w_i \mid w_{i-2}, w_{i-1}) = \lambda_1 P(w_i \mid w_{i-2}, w_{i-1}) + \lambda_2 P(w_i \mid w_{i-1}) + \lambda_3 P(w_i)\), with \(\lambda_1 + \lambda_2 + \lambda_3 = 1\). Estimate the \(\lambda\)s on a development set.
  • Witten–Bell smoothing: \(\lambda\) depends on the context. With \(u(h)\) = the number of different words seen after the history \(h\) and \(c(h)\) = its count: \(P_{WB}(w \mid h) = \lambda_h P_{ML}(w \mid h) + (1 - \lambda_h) P_{WB}(w \mid h')\), where \(h'\) is the shorter history and \(1 - \lambda_h = \frac{u(h)}{u(h) + c(h)}\). A history followed by many different words is more likely to be followed by a new one, so it backs off more.

💡 Corrected from the notebook: it gave the weight \(\frac{u}{u + c}\) to the higher-order model and mixed bigram and trigram notation in one formula. In Witten–Bell, \(\frac{u}{u + c}\) is the weight of the lower-order model (Chen & Goodman, An Empirical Study of Smoothing Techniques for Language Modeling, 1998).

The notebook's exercise, with results: train unigram, bigram and trigram models on 90% of Emma, and compute their perplexity on the rest of Emma, on Persuasion (also Austen) and on Moby Dick (Melville), with add-one smoothing and with interpolation.

perplexity.py (one possible solution) Python
import math
from collections import Counter
from nltk.corpus import gutenberg

def sents(fileid):
    return [["<s>", "<s>"] + [w.lower() for w in s] + ["</s>"] for s in gutenberg.sents(fileid)]

emma = sents("austen-emma.txt")
cut = int(0.9 * len(emma))
train, test_emma = emma[:cut], emma[cut:]
tests = {"Emma (held out)": test_emma,
         "Persuasion (Austen)": sents("austen-persuasion.txt"),
         "Moby Dick (Melville)": sents("melville-moby_dick.txt")}

vocab = {w for s in train for w in s} | {"<unk>"}
unk = lambda s: [w if w in vocab else "<unk>" for w in s]
train = [unk(s) for s in train]
c1 = Counter(w for s in train for w in s[2:])
c2 = Counter(tuple(s[i-1:i+1]) for s in train for i in range(2, len(s)))
c3 = Counter(tuple(s[i-2:i+1]) for s in train for i in range(2, len(s)))
h1 = Counter(tuple(s[i-1:i]) for s in train for i in range(2, len(s)))
h2 = Counter(tuple(s[i-2:i]) for s in train for i in range(2, len(s)))
N, V = sum(c1.values()), len(vocab)

def p_uni(w): return (c1[w] + 1) / (N + V)                              # add-one
def p_bi(u, w): return (c2[(u, w)] + 1) / (h1[(u,)] + V)                 # add-one
def p_tri(t, u, w): return (c3[(t, u, w)] + 1) / (h2[(t, u)] + V)        # add-one
def p_interp(t, u, w, l=(0.6, 0.3, 0.1)):                                # MLE mixed, uni add-one
    tri = c3[(t, u, w)] / h2[(t, u)] if h2[(t, u)] else 0
    bi = c2[(u, w)] / h1[(u,)] if h1[(u,)] else 0
    return l[0] * tri + l[1] * bi + l[2] * p_uni(w)

def perplexity(test, p):
    logp, n = 0.0, 0
    for s in map(unk, test):
        for i in range(2, len(s)):
            logp += math.log(p(s[i-2], s[i-1], s[i])); n += 1
    return math.exp(-logp / n)

models = {"unigram, add-one": lambda t, u, w: p_uni(w),
          "bigram, add-one": lambda t, u, w: p_bi(u, w),
          "trigram, add-one": p_tri,
          "interpolated 3/2/1-gram": p_interp}
for name, test in tests.items():
    print(name, {m: round(perplexity(test, p)) for m, p in models.items()})
Test textUnknown wordsUnigram, add-oneBigram, add-oneTrigram, add-oneInterpolated (0.6 / 0.3 / 0.1)
Emma (held-out 10%)1.9%4177223,204208
Persuasion (Austen)5.0%5259123,694375
Moby Dick (Melville)16.5%1,0431,7165,0962,146
  • Add-one gets worse as \(n\) grows: a trigram history has been seen only a few times, so adding 1 for each of the 6,917 vocabulary words hands almost all the probability to unseen trigrams. Exactly the "90% of the mass" problem.
  • Interpolation wins on text like the training data (208 on Emma), but with untuned weights it is worse than the add-one unigram and bigram models on Moby Dick (2,146), where Austen's bigrams and trigrams mislead. That is why the \(\lambda\)s are tuned on a development set.
  • Different distribution, higher perplexity: the same model is least perplexed by Emma, then by another Austen novel, then by Melville.
Part VII

Language models for retrieval: query likelihood

For classical IR we build a unigram language model for each document, and ask: how likely is this document's model to generate the query? This is the query-likelihood model.

📐 Query likelihood
\[ q = w_1, w_2, \ldots, w_n \qquad score(q, d) = P(q \mid d) = \prod_{i=1}^{n} P(w_i \mid d) \]

For document \(d\), a language model \(P(w \mid d)\) over words \(w\); the product assumes the query words are independent (a unigram model). The simplest estimate is maximum likelihood, \(P(w \mid d) = \frac{tf(w, d)}{|d|}\), with \(tf(w, d)\) the frequency of \(w\) in \(d\) and \(|d|\) the document length.

  • Interpretation: for the query wing drag, a document gets a high score if its language model gives high probability to wing and drag. Instead of "how similar are the query and document vectors?" we ask "how likely is the query under the document's term distribution?"
  • Example: the document wing wing drag flow has \(P(wing \mid d) = 2/4\), \(P(drag \mid d) = 1/4\), \(P(flow \mid d) = 1/4\). For wing drag: \(P(q \mid d) = \frac24 \cdot \frac14 = 0.125\).
Part VIII

The zero-probability problem

The document wing wing flow and the query wing drag: \(P(drag \mid d) = 0\), so \(P(q \mid d) = 0\), even if the document is otherwise highly relevant. Because probabilities are multiplied, one unseen query term makes the whole query likelihood zero. The model is too brittle: we need smoothing.

  • Smoothing intuition: even if a word does not occur in this document, give it some probability based on the collection. Combine document evidence with collection evidence.
  • Collection language model: \(P(w \mid C) = \frac{tf(w, C)}{|C|}\), with \(tf(w, C)\) the total occurrences of \(w\) in the collection and \(|C|\) its total number of tokens. Common collection terms receive some background probability.
📐 Jelinek–Mercer smoothing
\[ P(w \mid d) = (1 - \lambda)\, P_{ML}(w \mid d) + \lambda\, P(w \mid C) \]

\(\lambda\) controls how much we trust the collection: \(\lambda = 0\) uses only the document, \(\lambda = 1\) ignores the document entirely; in practice something in between. This is the interpolation of Part VI, with the collection model playing the role of the lower-order model.

Why log probabilities? Query probabilities become extremely small (\(0.01 \cdot 0.03 \cdot 0.02 \cdots\)), so, as for n-grams, score with \(\log P(q \mid d) = \sum_{w \in q} \log P(w \mid d)\): numerically more stable, and the ranking is the same.

Try it: query likelihood with smoothing

Four tiny documents; the collection model is computed from all of them. At \(\lambda = 0\) any document missing a query word scores \(-\infty\). Edit the query or the documents.

0.20

💡 The other standard choice in IR is Dirichlet smoothing, \(P(w \mid d) = \frac{tf(w, d) + \mu P(w \mid C)}{|d| + \mu}\): it is Jelinek–Mercer with \(\lambda = \frac{\mu}{|d| + \mu}\), so short documents lean more on the collection, long ones more on their own counts.

Part IX

Query likelihood on Cranfield

The same Cranfield collection as in the previous labs, tokenized as for BM25 in Lab 04: f"{doc.title} {doc.text}".lower().split(). The collection has 247,273 tokens and 12,246 distinct ones.

collection.py Python
# pip install ir_datasets numpy scikit-learn
import ir_datasets
from collections import Counter

dataset = ir_datasets.load("cranfield")
docs = list(dataset.docs_iter())
queries = list(dataset.queries_iter())

texts = [f"{doc.title} {doc.text}".lower().split() for doc in docs]
doc_ids = [doc.doc_id for doc in docs]
titles = [doc.title for doc in docs]

collection_counts = Counter()
for tokens in texts:
    collection_counts.update(tokens)
collection_length = sum(collection_counts.values())
collection_length, len(collection_counts)
# (247273, 12246)
query_likelihood.py (the outline's version) Python
import math
from collections import Counter

def collection_prob(term):
    return collection_counts[term] / collection_length

def query_likelihood_score(query, doc_tokens, lam=0.2):
    doc_counts = Counter(doc_tokens)
    doc_len = len(doc_tokens)
    score = 0.0
    for term in query.lower().split():
        p_doc = doc_counts[term] / doc_len if doc_len > 0 else 0.0
        p_collection = collection_prob(term)
        p = (1 - lam) * p_doc + lam * p_collection
        if p > 0:
            score += math.log(p)
        else:
            return float("-inf")
    return score

def search_ql(query, k=5):
    scored = []
    for i, tokens in enumerate(texts):
        scored.append((query_likelihood_score(query, tokens, lam=0.2), i))
    top = sorted(scored, reverse=True)[:k]
    return [(doc_ids[i], titles[i], score) for score, i in top]

q = queries[0].text
print(q)
for result in search_ql(q):
    print(result)
# what similarity laws must be obeyed when constructing aeroelastic models
# of heated high speed aircraft .
# ('1400', 'the buckling shear stress of simply-supported infinitely\nlong plates ...', -inf)
# ('1399', 'buckling of transverse stiffened plates under shear .', -inf)
# ('1398', 'stability of rectangular plates under shear and bending\nforces .', -inf)
# ('1397', 'critical shear stress of an infinitely long simply\nsupported plate ...', -inf)
# ('1396', 'shear buckling of clamped and simply-supported infinitely\nlong plates ...', -inf)

❗ Something went wrong: all five scores are \(-\infty\) and the "results" are simply the last documents. The word obeyed occurs nowhere in Cranfield, so \(P(obeyed \mid C) = 0\): smoothing with the collection cannot help a word the collection has never seen, every document gets \(-\infty\), and sorted breaks the tie by document position. This happens for 69 of the 225 queries (e.g. obeyed, guides, and tokens with punctuation attached such as attack. or analytical,).

The fix: a term with \(P(w \mid C) = 0\) would multiply every document's score by the same factor, so it cannot change the ranking: skip it.

query_likelihood_fixed.py Python
def query_likelihood_score(query, doc_tokens, lam=0.2):
    doc_counts = Counter(doc_tokens)
    doc_len = len(doc_tokens)
    score = 0.0
    for term in query.lower().split():
        p_collection = collection_prob(term)
        if p_collection == 0:        # not in the collection: same for every document, skip it
            continue
        p_doc = doc_counts[term] / doc_len if doc_len > 0 else 0.0
        score += math.log((1 - lam) * p_doc + lam * p_collection)
    return score

for result in search_ql(queries[0].text):
    print(result)
# ('486', 'similarity laws for aerothermoelastic testing .', -109.74176354210674)
# ('13', 'similarity laws for stressing heated wings .', -110.76355526688077)
# ('1268', 'stable combustion of a high-velocity gas in a heated\nboundary layer .', -111.8573212457509)
# ('184', 'scale models for thermo-aeroelastic research .', -111.89052019470697)
# ('51', 'theory of aircraft structural models subjected to aerodynamic\nheating and external loads .', -112.01345789126577)

What to take from the demo is the pipeline, not the implementation: document → term distribution → smoothed document language model; query → probability under each document model → ranking.

Try it: query likelihood vs BM25 on Cranfield

The fixed query likelihood, computed in your browser. Change \(\lambda\) and the query; compare the top 10 with BM25's, with Cranfield's relevance grades. The plot shows MAP over all 225 queries as a function of \(\lambda\) (computed when you open it), against BM25 and TF-IDF.

0.20

query likelihood   BM25   TF-IDF

💡 Checked against ir_measures (top 100 per query): the outline's query likelihood (\(\lambda = 0.2\), ranked as search_ql ranks) reaches MAP 0.1517, the fixed one 0.2127, and the best \(\lambda\) on the grid above (0.80) 0.2286. On Cranfield with these simple whitespace tokens, query likelihood stays below BM25 (0.2406) and TF-IDF (0.2737); do not conclude that one paradigm is better from one collection and one tokenization (Lab 05).

Part X

Comparing the retrieval paradigms

Different mathematical views of the same broad problem: rank documents for a query.

MethodMain interpretationIn short
TF-IDF + cosinevector similarityquery and document → vectors → geometric similarity
BM25weighted lexical relevanceterm overlap + IDF + TF saturation + length normalization
Query likelihoodprobability of the query under the document modelone language model per document → probability of generating the query
Rocchio / PRFmodify the query using feedbackworks on top of any of the above
Part XI

Bridge to dense retrieval and RAG

Classical query expansion tries to solve vocabulary mismatch by adding words (car → automobile). Dense retrieval attacks the same problem differently: instead of changing the query text, it maps both the query and the document into learned vectors (embeddings) and retrieves by semantic similarity.

classical lexical retrieval → query expansion / language models → semantic representations → dense retrieval
  • RAG systems often fail because the retriever does not find the right evidence, and vocabulary mismatch is one reason.
  • Classical methods fix it with query expansion, feedback and probabilistic term models; modern RAG systems may additionally use dense retrieval, hybrid retrieval, query rewriting, multi-query retrieval and reranking.
  • The problem is old. The tools have changed.

Summary: key takeaways

user and document use different words → query expansion → relevance feedback → pseudo-relevance feedback → language-model retrieval → smoothing → next: semantic / dense retrieval
  1. Lexical retrieval can fail because users and documents use different vocabulary.
  2. Query expansion can improve recall, but poor expansion can cause query drift.
  3. Relevance feedback moves the query toward relevant documents and away from non-relevant ones.
  4. Query-likelihood retrieval ranks documents by how likely their language models are to generate the query.
  5. Smoothing is essential, because unseen query terms otherwise produce zero probability.

Optional exercises

  • BM25 vs query likelihood. For five Cranfield queries: print the top five results of each system; identify where the rankings differ; inspect whether vocabulary mismatch appears to explain the difference. Do not claim one method is better without the evaluation framework of Lab 05. (The comparison widget shows both lists side by side.)
  • Pseudo-relevance feedback. Take one Cranfield query; retrieve the top 3 BM25 documents; find their highest-weight TF-IDF terms; add 2–3 new terms; run retrieval again and compare the rankings. Did the query become more specific? Did it drift? Did new relevant-looking documents appear? (The PRF widget and prf.py do this.)
  • n-gram models (notebook). Train unigram, bigram and trigram models on some NLTK texts (what is available); compute their perplexity on text from the same and from a different distribution; compare add-one with backoff and interpolation. One solution is in Part VI; try tuning the \(\lambda\)s on a development set.