suppressPackageStartupMessages({
library(digest)
library(dplyr)
library(huggingfaceR)
library(jsonlite)
library(purrr)
library(readr)
library(reticulate)
library(stringr)
library(tibble)
library(tidyr)
library(tokenizers)
})
source("R/use-nlg.R")
hash_lines <- function(path) {
digest(
paste(read_lines(path), collapse = "\n"),
algo = "sha256",
serialize = FALSE
)
}
handbook_path <- "data/riverton/riverton-handbook.csv"
judgments_path <- "data/riverton/riverton-search-judgments.csv"
handbook <- read_csv(
handbook_path,
na = character(),
col_types = cols(
passage_id = col_character(),
topic = col_character(),
text = col_character(),
source = col_character(),
author_note = col_character()
)
)
judgments <- read_csv(
judgments_path,
na = character(),
col_types = cols(
query_id = col_character(),
query = col_character(),
probe_type = col_character(),
passage_id = col_character(),
relevant = col_integer(),
judged_by = col_character()
)
)
handbook_metadata <- read_csv(
"data/riverton/riverton-handbook-metadata.csv",
na = character(),
col_types = cols(
artifact = col_character(),
description = col_character(),
source = col_character(),
license = col_character(),
created_on = col_character(),
rows = col_integer(),
fingerprint = col_character()
)
)
queries <- judgments |>
distinct(query_id, query, probe_type) |>
arrange(query_id)
miss_context <- handbook |>
filter(passage_id %in% c("H13", "H15")) |>
select(passage_id, topic, text)
judgment_summary <- judgments |>
group_by(query_id, query, probe_type) |>
summarise(
relevant_passage = {
relevant_ids <- passage_id[relevant == 1L]
if (length(relevant_ids) == 0L) {
"not applicable"
} else {
paste(relevant_ids, collapse = ", ")
}
},
.groups = "drop"
)Building a semantic search index
Compare lexical, dense, and fused rankings without pretending a top hit is an answer
A parent asked the handbook, “Where can my kid stay while I study at night?” The answer lived in passage H13, but the wording did not say child care.
This lesson uses a related search query, somewhere for my kid during night class. Under the lexical scoring used later in this lesson, which keeps stop words and does no stemming, H13 ranks second because it shares only the function words for and during; the class-related distractor H15 shares class. That rank does not come from the meaning of kid or night. If stop words were removed, H13 would receive no lexical score for this query.
Semantic search indexing prepares a searchable structure before the next query arrives. A lexical index stores terms and counts. A dense index stores one vector for each passage, using the same local embedding model for passages and queries. Both indexes can return a ranked list; neither can decide by itself that the top passage is relevant.
The Riverton handbook in this lesson is an invented teaching fixture about fictional programs and policies.
This lesson shows how to:
- build a lexical inverted index with explicit identifier tokenization;
- score the index with a fixed BM25 formula;
- mean-pool local MiniLM token vectors into one passage vector;
- record index metadata so stale vectors are caught;
- fuse lexical and dense ranks with reciprocal rank fusion; and
- read recall and reciprocal rank without naming a winner.
Load the frozen handbook and judgments
The handbook, queries, and relevance judgments were written before this page ran any search code. The judgments cover every query against every passage. They are page-author reference labels, not ground truth.
| Passage | Topic | Text |
|---|---|---|
| H13 | child care | Free child care is available in Room 104 during evening classes for children aged 3 to 10. |
| H15 | laptops | Laptops are provided in class for Data Support Certificate students and may not be taken home. |
The table above starts with H13 because it answers the parent’s question. H15 is a distractor about laptops and class. A search method that always returns a top row can still put a distractor first.
| Query | Query text | Probe type | Relevant passage |
|---|---|---|---|
| S1 | somewhere for my kid during night class | vocabulary mismatch | H13 |
| S2 | DSC-104 | exact identifier with near misses | H09 |
| S3 | money while I train | vocabulary mismatch | H08 |
| S4 | free ride to class | vocabulary mismatch | H05 |
| S5 | weekend forklift classes | negated fact | H11 |
| S6 | what should I bring to enroll | paraphrase | H07 |
| S7 | can I take the laptop home | lexical overlap | H15 |
| S8 | cafeteria lunch menu | out of scope | not applicable |
Build terms for a lexical inverted index
An inverted index stores, for each term, the passages that contain it. The tokenizer matters. A common word tokenizer splits DSC-104 into dsc and 104; that makes it share 104 with Room 104. This lesson keeps course-like identifiers as one term with the pattern [a-z]+-\d+|[a-z]+|\d+. It lowercases text, keeps stop words, and does no stemming, so class and classes remain different terms.
tokenize_index_terms <- function(text) {
str_extract_all(
str_to_lower(text, locale = "en"),
"[a-z]+-\\d+|[a-z]+|\\d+"
)
}
identifier_example <- tibble(
text = c("Course DSC-104 meets in Room 104."),
common_word_tokens = list(tokenizers::tokenize_words(text)[[1]]),
index_terms = list(tokenize_index_terms(text)[[1]])
) |>
summarise(
text = first(text),
common_word_tokens = map_chr(common_word_tokens, paste, collapse = ", "),
index_terms = map_chr(index_terms, paste, collapse = ", ")
)
passage_terms <- handbook |>
transmute(
passage_id,
term = tokenize_index_terms(text)
) |>
unnest_longer(term, values_to = "term")
posting_preview <- passage_terms |>
filter(term %in% c("dsc-104", "104", "dsc-105")) |>
count(term, passage_id, name = "count") |>
arrange(term, passage_id)| Example text | Common word tokens | Index terms |
|---|---|---|
| Course DSC-104 meets in Room 104. | course, dsc, 104, meets, in, room, 104 | course, dsc-104, meets, in, room, 104 |
| Term | Passage | Count |
|---|---|---|
| 104 | H13 | 1 |
| dsc-104 | H09 | 1 |
| dsc-105 | H10 | 1 |
These index terms are what the scoring formula in the next section counts. If a system needs exact course-code search, dsc-104 must be a term, not an accident of two smaller terms.
Score query terms with BM25
BM25 gives more weight to rare query terms, discounts long passages, and lets repeated terms help less each time. This lesson uses Lucene’s positive idf form, with k1 = 1.2 and b = 0.75, fixed before any query runs.
k1 <- 1.2
b <- 0.75
document_count <- nrow(handbook)
passage_lengths <- passage_terms |>
count(passage_id, name = "dl")
average_length <- mean(passage_lengths$dl)
document_frequency <- passage_terms |>
distinct(passage_id, term) |>
count(term, name = "df") |>
mutate(
idf = log(1 + (document_count - df + 0.5) / (df + 0.5))
)
postings <- passage_terms |>
count(passage_id, term, name = "tf") |>
left_join(passage_lengths, by = join_by(passage_id)) |>
left_join(document_frequency, by = join_by(term)) |>
mutate(
bm25_weight = idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / average_length))
)
rank_bm25 <- function(query_id, query) {
query_terms <- tibble(term = tokenize_index_terms(query)[[1]]) |>
count(term, name = "query_tf")
postings |>
inner_join(query_terms, by = join_by(term)) |>
group_by(passage_id) |>
summarise(score = sum(bm25_weight * query_tf), .groups = "drop") |>
filter(score > 0) |>
arrange(desc(score), passage_id) |>
mutate(query_id = query_id, rank = row_number())
}
bm25_ranks <- pmap_dfr(
list(queries$query_id, queries$query),
rank_bm25
)
default_passage_terms <- handbook |>
transmute(
passage_id,
term = tokenizers::tokenize_words(text)
) |>
unnest_longer(term, values_to = "term")
default_lengths <- default_passage_terms |>
count(passage_id, name = "dl")
default_average_length <- mean(default_lengths$dl)
default_document_frequency <- default_passage_terms |>
distinct(passage_id, term) |>
count(term, name = "df") |>
mutate(
idf = log(1 + (document_count - df + 0.5) / (df + 0.5))
)
default_postings <- default_passage_terms |>
count(passage_id, term, name = "tf") |>
left_join(default_lengths, by = join_by(passage_id)) |>
left_join(default_document_frequency, by = join_by(term)) |>
mutate(
bm25_weight = idf *
(tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * dl / default_average_length))
)
default_s2_terms <- tibble(
term = tokenizers::tokenize_words("DSC-104")[[1]]
) |>
count(term, name = "query_tf")
default_s2_preview <- default_postings |>
inner_join(default_s2_terms, by = join_by(term)) |>
group_by(passage_id) |>
summarise(score = sum(bm25_weight * query_tf), .groups = "drop") |>
filter(score > 0) |>
left_join(handbook |> select(passage_id, topic), by = join_by(passage_id)) |>
arrange(desc(score), passage_id) |>
mutate(rank = row_number()) |>
slice_head(n = 3) |>
select(passage_id, topic, rank, score)
bm25_preview <- bm25_ranks |>
filter(query_id %in% c("S1", "S2")) |>
slice_min(rank, n = 3, by = query_id) |>
left_join(
handbook |> select(passage_id, topic),
by = join_by(passage_id)
) |>
select(query_id, rank, passage_id, topic, score)
top_margin <- function(ranks, query) {
scores <- ranks |>
filter(query_id == query) |>
arrange(rank) |>
pull(score)
if (length(scores) < 2L) {
return(Inf)
}
scores[[1]] - scores[[2]]
}| Query | Rank | Passage | Topic | BM25 score |
|---|---|---|---|---|
| S1 | 1 | H15 | laptops | 3.648 |
| S1 | 2 | H13 | child care | 3.565 |
| S1 | 3 | H14 | deadline | 1.355 |
| S2 | 1 | H09 | course codes | 2.252 |
| Passage | Topic | Default-tokenizer rank | BM25 score |
|---|---|---|---|
| H09 | course codes | 1 | 3.407 |
| H13 | child care | 2 | 1.935 |
| H10 | refresher workshop | 3 | 1.704 |
BM25 handles DSC-104 cleanly because the tokenizer kept the identifier. With default word splitting, the right passage still stays first in this render, but Room 104 and DSC-105 become partial matches that fill the rest of the top three. BM25 still struggles when the query says “money while I train” and the passage says “training stipend.”
Make dense passage vectors
The dense index uses the pinned sentence-transformers/all-MiniLM-L6-v2 model. The local Hugging Face feature-extraction pipeline returns one vector per word piece. For each text, this lesson averages those vectors, then scales the result to length 1.
The model’s own files say the sentence-transformer wrapper uses mean pooling and a normalization module. The model card says sentence-transformers truncates inputs longer than 256 word pieces by default and that training used sequences of at most 128 tokens. The page applies the model’s pooling steps one text at a time, so the pipeline output has no padding tokens (filler tokens that a batch adds so every text in it has the same length); the helper is valid only for that one-text call pattern.
The model manifest records an Apache-2.0 license. This lesson uses English (en) handbook text and does not claim the same behavior for other languages or domains.
embedder <- load_nlg_pipeline(
"minilm_l6_v2",
"feature-extraction"
)
model_dir <- file.path(
"data-raw",
".cache",
"nlg-models",
embedder$metadata$local_directory
)
modules_config <- fromJSON(file.path(model_dir, "modules.json"))
pooling_config <- fromJSON(file.path(model_dir, "1_Pooling", "config.json"))
sentence_config <- fromJSON(file.path(model_dir, "sentence_bert_config.json"))
bert_config <- fromJSON(file.path(model_dir, "config.json"))
tokenizer_config <- fromJSON(file.path(model_dir, "tokenizer_config.json"))
extract_token_matrix <- function(feature_item) {
item <- feature_item
if (
length(item) == 1L &&
is.list(item[[1]]) &&
!is.numeric(item[[1]])
) {
item <- item[[1]]
}
do.call(rbind, item)
}
mean_pool_l2 <- function(feature_item) {
token_matrix <- extract_token_matrix(feature_item)
vector <- colMeans(token_matrix)
vector / sqrt(sum(vector^2))
}
embed_one_text <- function(text) {
output <- embedder$pipeline(text)
mean_pool_l2(output[[1]])
}
token_counts <- tibble(
passage_id = handbook$passage_id,
model_tokens = map_int(
handbook$text,
\(text) nlg_token_count(embedder$tokenizer, text)
)
)
stopifnot(max(token_counts$model_tokens) <= 256L)
first_output <- embedder$pipeline(handbook$text[[1]])
first_attention <- embedder$tokenizer(
handbook$text[[1]],
add_special_tokens = TRUE,
return_attention_mask = TRUE
)$attention_mask
passage_embeddings <- map(handbook$text, embed_one_text)
embedding_matrix <- do.call(rbind, passage_embeddings)
rownames(embedding_matrix) <- handbook$passage_id
dense_metadata <- tibble(
field = c(
"model",
"revision",
"pooling",
"normalization",
"prefix",
"runtime length checked here",
"training sequence length on model card",
"casing",
"dimension"
),
value = c(
embedder$metadata$model_id,
embedder$metadata$revision,
"mean over returned token vectors",
"L2 unit length",
"none",
as.character(sentence_config$max_seq_length),
"at most 128 tokens",
paste0("uncased: ", tokenizer_config$do_lower_case),
as.character(ncol(embedding_matrix))
)
)| Metadata field | Recorded value |
|---|---|
| model | sentence-transformers/all-MiniLM-L6-v2 |
| revision | 1110a243fdf4706b3f48f1d95db1a4f5529b4d41 |
| pooling | mean over returned token vectors |
| normalization | L2 unit length |
| prefix | none |
| runtime length checked here | 256 |
| training sequence length on model card | at most 128 tokens |
| casing | uncased: TRUE |
| dimension | 384 |
| Passage | Word-piece tokens |
|---|---|
| H02 | 36 |
| H10 | 35 |
| H09 | 32 |
| H08 | 31 |
| H06 | 30 |
All passages are short enough to embed without truncation. The lesson embeds one text at a time, so the pooling helper never averages padding tokens.
Record stale-index checks
An index is tied to its passages and model conventions. The embedding dimension alone cannot detect a swap: several small English embedding models can produce 384-dimensional vectors. The safer record includes model revision, pooling, prefixes, tokenizer behavior, library versions, lexical settings, and passage fingerprints.
Show the stale-index checks
passage_fingerprints <- handbook |>
transmute(
passage_id,
passage_sha256 = map_chr(
text,
\(value) digest(value, algo = "sha256", serialize = FALSE)
)
)
handbook_fingerprint <- hash_lines(handbook_path)
index_record <- tibble(
key = c(
"model_id",
"revision",
"license",
"pooling",
"normalization",
"query_prefix",
"passage_prefix",
"max_seq_length",
"training_sequence_length",
"tokenizer_casing",
"lexical_token_pattern",
"lexical_stop_words",
"lexical_stemming",
"bm25_idf",
"bm25_k1",
"bm25_b",
"handbook_fingerprint",
"passage_fingerprint_count",
"similarity",
"search_type",
"huggingfaceR",
"reticulate",
"transformers",
"torch",
"python_tokenizers"
),
value = c(
embedder$metadata$model_id,
embedder$metadata$revision,
embedder$metadata$license,
"mean pooling over real token vectors",
"L2 normalization",
"none",
"none",
as.character(sentence_config$max_seq_length),
"at most 128 tokens",
paste0("do_lower_case=", tokenizer_config$do_lower_case),
"[a-z]+-\\d+|[a-z]+|\\d+",
"kept",
"none",
"Lucene positive idf",
as.character(k1),
as.character(b),
handbook_fingerprint,
as.character(nrow(passage_fingerprints)),
"cosine, computed as dot product of unit vectors",
"exact scan over 15 passages",
as.character(packageVersion("huggingfaceR")),
as.character(packageVersion("reticulate")),
as.character(reticulate::py_to_r(reticulate::import("transformers")$`__version__`)),
as.character(reticulate::py_to_r(reticulate::import("torch")$`__version__`)),
as.character(reticulate::py_to_r(reticulate::import("tokenizers")$`__version__`))
)
)
metadata_matches <- function(record, model_id, revision, pooling) {
expected <- c(model_id = model_id, revision = revision, pooling = pooling)
actual <- record |>
filter(key %in% names(expected)) |>
select(key, value) |>
deframe()
identical(actual[names(expected)], expected)
}
metadata_check <- tibble(
check = c("current model", "changed pooling example"),
ok_to_query = c(
metadata_matches(
index_record,
embedder$metadata$model_id,
embedder$metadata$revision,
"mean pooling over real token vectors"
),
metadata_matches(
index_record,
embedder$metadata$model_id,
embedder$metadata$revision,
"CLS pooling"
)
)
)
edited_passage_check <- passage_fingerprints |>
filter(passage_id == "H13") |>
mutate(
edited_sha256 = digest(
paste0(
handbook$text[handbook$passage_id == "H13"],
" Extra sentence."
),
algo = "sha256",
serialize = FALSE
),
fingerprint_matches = passage_sha256 == edited_sha256,
action = if_else(
fingerprint_matches,
"reuse stored vector",
"re-embed this passage"
)
)| Index key | Value |
|---|---|
| model_id | sentence-transformers/all-MiniLM-L6-v2 |
| revision | 1110a243fdf4706b3f48f1d95db1a4f5529b4d41 |
| license | Apache-2.0 |
| pooling | mean pooling over real token vectors |
| normalization | L2 normalization |
| query_prefix | none |
| passage_prefix | none |
| max_seq_length | 256 |
| training_sequence_length | at most 128 tokens |
| tokenizer_casing | do_lower_case=TRUE |
| lexical_token_pattern | [a-z]+-\d+|[a-z]+|\d+ |
| lexical_stop_words | kept |
| lexical_stemming | none |
| bm25_idf | Lucene positive idf |
| bm25_k1 | 1.2 |
| bm25_b | 0.75 |
| handbook_fingerprint | e8f6bd47e297f4ecc489169f5ca6e2154a1d3316136bfb5f7be26dcc39725a64 |
| passage_fingerprint_count | 15 |
| similarity | cosine, computed as dot product of unit vectors |
| search_type | exact scan over 15 passages |
| huggingfaceR | 2.1.0 |
| reticulate | 1.46.0 |
| transformers | 4.57.6 |
| torch | 2.9.1+cpu |
| python_tokenizers | 0.22.2 |
| Check | Safe to query |
|---|---|
| current model | TRUE |
| changed pooling example | FALSE |
| Passage | Fingerprint matches | Index action |
|---|---|---|
| H13 | FALSE | re-embed this passage |
If a passage fingerprint changes, only that passage needs a new vector. If the model revision or pooling changes, the stored vectors cannot be mixed with new query vectors.
Rank queries with dense vectors and fusion
Dense search embeds the query with the same model and ranks passages by cosine similarity. The hybrid below uses reciprocal rank fusion, or RRF. It adds 1 / (k + rank) from each ranked list, with k = 60. RRF uses ranks because BM25 scores and cosine scores are on different scales.
query_token_counts <- tibble(
query_id = queries$query_id,
model_tokens = map_int(
queries$query,
\(text) nlg_token_count(embedder$tokenizer, text)
)
)
stopifnot(max(query_token_counts$model_tokens) <= 256L)
query_embeddings <- map(queries$query, embed_one_text)
query_matrix <- do.call(rbind, query_embeddings)
rownames(query_matrix) <- queries$query_id
dense_scores <- query_matrix %*% t(embedding_matrix)
dense_ranks <- as_tibble(as.data.frame(dense_scores), rownames = "query_id") |>
pivot_longer(
-query_id,
names_to = "passage_id",
values_to = "score"
) |>
arrange(query_id, desc(score), passage_id) |>
group_by(query_id) |>
mutate(rank = row_number()) |>
ungroup()
rrf_k <- 60
rrf_ranks <- bind_rows(
bm25_ranks |> transmute(query_id, passage_id, method = "BM25", rank),
dense_ranks |> transmute(query_id, passage_id, method = "dense", rank)
) |>
mutate(rrf_component = 1 / (rrf_k + rank)) |>
group_by(query_id, passage_id) |>
summarise(score = sum(rrf_component), .groups = "drop") |>
arrange(query_id, desc(score), passage_id) |>
group_by(query_id) |>
mutate(rank = row_number()) |>
ungroup()
top_hits <- bind_rows(
bm25_ranks |> mutate(method = "BM25 lexical", score_type = "BM25"),
dense_ranks |> mutate(method = "dense", score_type = "cosine"),
rrf_ranks |> mutate(method = "RRF hybrid", score_type = "RRF")
) |>
filter(rank == 1L) |>
left_join(
queries |> select(query_id, query),
by = join_by(query_id)
) |>
left_join(
handbook |> select(passage_id, topic),
by = join_by(passage_id)
) |>
select(method, query_id, query, passage_id, topic, score_type, score)
bm25_no_match_rows <- queries |>
filter(!query_id %in% unique(bm25_ranks$query_id)) |>
transmute(
method = "BM25 lexical",
query_id,
query,
passage_id = "no lexical match",
topic = "no lexical match",
score_type = "BM25",
score = NA_real_
)
top_hits_display <- bind_rows(top_hits, bm25_no_match_rows) |>
arrange(query_id, method)| Method | Query | Query text | Top passage | Top topic | Score type | Score |
|---|---|---|---|---|---|---|
| BM25 lexical | S1 | somewhere for my kid during night class | H15 | laptops | BM25 | 3.648 |
| RRF hybrid | S1 | somewhere for my kid during night class | H13 | child care | RRF | 0.033 |
| dense | S1 | somewhere for my kid during night class | H13 | child care | cosine | 0.362 |
| BM25 lexical | S2 | DSC-104 | H09 | course codes | BM25 | 2.252 |
| RRF hybrid | S2 | DSC-104 | H09 | course codes | RRF | 0.033 |
| dense | S2 | DSC-104 | H09 | course codes | cosine | 0.557 |
| BM25 lexical | S3 | money while I train | no lexical match | no lexical match | BM25 | not applicable |
| RRF hybrid | S3 | money while I train | H08 | stipend | RRF | 0.016 |
| dense | S3 | money while I train | H08 | stipend | cosine | 0.295 |
| BM25 lexical | S4 | free ride to class | H13 | child care | BM25 | 3.243 |
| RRF hybrid | S4 | free ride to class | H13 | child care | RRF | 0.032 |
| dense | S4 | free ride to class | H11 | forklift schedule | cosine | 0.460 |
| BM25 lexical | S5 | weekend forklift classes | H11 | forklift schedule | BM25 | 5.265 |
| RRF hybrid | S5 | weekend forklift classes | H11 | forklift schedule | RRF | 0.033 |
| dense | S5 | weekend forklift classes | H11 | forklift schedule | cosine | 0.789 |
| BM25 lexical | S6 | what should I bring to enroll | H07 | enrollment documents | BM25 | 3.659 |
| RRF hybrid | S6 | what should I bring to enroll | H07 | enrollment documents | RRF | 0.033 |
| dense | S6 | what should I bring to enroll | H07 | enrollment documents | cosine | 0.584 |
| BM25 lexical | S7 | can I take the laptop home | H15 | laptops | BM25 | 2.514 |
| RRF hybrid | S7 | can I take the laptop home | H15 | laptops | RRF | 0.033 |
| dense | S7 | can I take the laptop home | H15 | laptops | cosine | 0.582 |
| BM25 lexical | S8 | cafeteria lunch menu | no lexical match | no lexical match | BM25 | not applicable |
| RRF hybrid | S8 | cafeteria lunch menu | H02 | contact | RRF | 0.016 |
| dense | S8 | cafeteria lunch menu | H02 | contact | cosine | 0.244 |
S8 asks for a cafeteria lunch menu. The judgments say no passage is relevant. BM25 returns no lexical match after zero-score rows are excluded, while dense search and RRF still return a top passage. That is a property of similarity search, not evidence that the handbook contains a lunch-menu answer.
Evaluate the ranks query by query
The table below reports each method on each query. Recall at 1 asks whether the relevant passage appears first. Recall at 3 asks whether it appears in the first three. Because these probes have at most one relevant passage, recall at k is the same as success at k. Reciprocal rank is 1 / rank for the first relevant passage. S8 has no relevant passage, so these columns are not applicable.
Show the evaluation code
evaluate_ranks <- function(ranks, method_name) {
judgments |>
left_join(
ranks |> select(query_id, passage_id, rank, score),
by = join_by(query_id, passage_id)
) |>
group_by(query_id, query, probe_type) |>
summarise(
relevant_total = sum(relevant),
relevant_rank = {
relevant_ranks <- rank[relevant == 1L]
relevant_ranks <- relevant_ranks[!is.na(relevant_ranks)]
if (length(relevant_ranks) == 0L) {
NA_integer_
} else {
min(relevant_ranks)
}
},
top_passage = {
scored_passages <- passage_id[!is.na(rank)]
scored_ranks <- rank[!is.na(rank)]
if (length(scored_ranks) == 0L) {
NA_character_
} else {
scored_passages[which.min(scored_ranks)]
}
},
top_score = {
scored_scores <- score[!is.na(rank)]
scored_ranks <- rank[!is.na(rank)]
if (length(scored_ranks) == 0L) {
NA_real_
} else {
scored_scores[which.min(scored_ranks)]
}
},
recall_at_1 = {
relevant_passages <- passage_id[relevant == 1L]
top_passages <- passage_id[!is.na(rank) & rank <= 1L]
if (length(relevant_passages) == 0L) {
NA_real_
} else {
sum(relevant_passages %in% top_passages) /
length(relevant_passages)
}
},
recall_at_3 = {
relevant_passages <- passage_id[relevant == 1L]
top_passages <- passage_id[!is.na(rank) & rank <= 3L]
if (length(relevant_passages) == 0L) {
NA_real_
} else {
sum(relevant_passages %in% top_passages) /
length(relevant_passages)
}
},
reciprocal_rank = if_else(
relevant_total == 0L,
NA_real_,
if_else(is.na(relevant_rank), 0, 1 / relevant_rank)
),
.groups = "drop"
) |>
mutate(method = method_name)
}
rank_evaluation <- bind_rows(
evaluate_ranks(bm25_ranks, "BM25 lexical"),
evaluate_ranks(dense_ranks, "dense"),
evaluate_ranks(rrf_ranks, "RRF hybrid")
) |>
select(
method,
query_id,
probe_type,
relevant_rank,
top_passage,
recall_at_1,
recall_at_3,
reciprocal_rank
) |>
arrange(query_id, method)
paired_summary <- rank_evaluation |>
filter(!is.na(reciprocal_rank)) |>
select(query_id, method, reciprocal_rank) |>
pivot_wider(names_from = method, values_from = reciprocal_rank) |>
mutate(
across(
c(`BM25 lexical`, dense, `RRF hybrid`),
\(value) coalesce(value, 0)
)
) |>
summarise(
dense_above_bm25 = sum(dense > `BM25 lexical`),
tied = sum(dense == `BM25 lexical`),
bm25_above_dense = sum(dense < `BM25 lexical`),
rrf_above_dense = sum(`RRF hybrid` > dense),
rrf_tied_dense = sum(`RRF hybrid` == dense),
dense_above_rrf = sum(`RRF hybrid` < dense),
rrf_above_bm25 = sum(`RRF hybrid` > `BM25 lexical`),
rrf_tied_bm25 = sum(`RRF hybrid` == `BM25 lexical`),
bm25_above_rrf = sum(`RRF hybrid` < `BM25 lexical`)
)
miss_table <- rank_evaluation |>
filter(
is.na(relevant_rank) |
recall_at_1 == 0 |
recall_at_3 == 0
) |>
mutate(
recall_at_1 = if_else(is.na(recall_at_1), NA_real_, recall_at_1),
recall_at_3 = if_else(is.na(recall_at_3), NA_real_, recall_at_3)
)
display_metric <- function(value) {
case_when(
is.na(value) ~ "not applicable",
TRUE ~ sprintf("%.3f", value)
)
}
display_rank <- function(value, relevant_total) {
case_when(
relevant_total == 0L ~ "not applicable",
is.na(value) ~ "not retrieved",
TRUE ~ as.character(value)
)
}
display_top <- function(value) {
if_else(is.na(value), "no lexical match", value)
}
rank_evaluation_display <- rank_evaluation |>
left_join(
judgments |>
group_by(query_id) |>
summarise(relevant_total = sum(relevant), .groups = "drop"),
by = join_by(query_id)
) |>
mutate(
relevant_rank = display_rank(relevant_rank, relevant_total),
top_passage = case_when(
relevant_total == 0L & method == "BM25 lexical" ~ "no lexical match",
relevant_total == 0L ~ top_passage,
TRUE ~ display_top(top_passage)
),
recall_at_1 = display_metric(recall_at_1),
recall_at_3 = display_metric(recall_at_3),
reciprocal_rank = display_metric(reciprocal_rank)
) |>
select(-relevant_total)
miss_table_display <- miss_table |>
left_join(
judgments |>
group_by(query_id) |>
summarise(relevant_total = sum(relevant), .groups = "drop"),
by = join_by(query_id)
) |>
mutate(
relevant_rank = display_rank(relevant_rank, relevant_total),
top_passage = case_when(
relevant_total == 0L & method == "BM25 lexical" ~ "no lexical match",
relevant_total == 0L ~ top_passage,
TRUE ~ display_top(top_passage)
),
recall_at_1 = display_metric(recall_at_1),
recall_at_3 = display_metric(recall_at_3),
reciprocal_rank = display_metric(reciprocal_rank)
) |>
select(-relevant_total)| Method | Query | Probe type | Relevant rank | Top passage | Recall at 1 | Recall at 3 | Reciprocal rank |
|---|---|---|---|---|---|---|---|
| BM25 lexical | S1 | vocabulary mismatch | 2 | H15 | 0.000 | 1.000 | 0.500 |
| RRF hybrid | S1 | vocabulary mismatch | 1 | H13 | 1.000 | 1.000 | 1.000 |
| dense | S1 | vocabulary mismatch | 1 | H13 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S2 | exact identifier with near misses | 1 | H09 | 1.000 | 1.000 | 1.000 |
| RRF hybrid | S2 | exact identifier with near misses | 1 | H09 | 1.000 | 1.000 | 1.000 |
| dense | S2 | exact identifier with near misses | 1 | H09 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S3 | vocabulary mismatch | not retrieved | no lexical match | 0.000 | 0.000 | 0.000 |
| RRF hybrid | S3 | vocabulary mismatch | 1 | H08 | 1.000 | 1.000 | 1.000 |
| dense | S3 | vocabulary mismatch | 1 | H08 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S4 | vocabulary mismatch | not retrieved | H13 | 0.000 | 0.000 | 0.000 |
| RRF hybrid | S4 | vocabulary mismatch | 10 | H13 | 0.000 | 0.000 | 0.100 |
| dense | S4 | vocabulary mismatch | 4 | H11 | 0.000 | 0.000 | 0.250 |
| BM25 lexical | S5 | negated fact | 1 | H11 | 1.000 | 1.000 | 1.000 |
| RRF hybrid | S5 | negated fact | 1 | H11 | 1.000 | 1.000 | 1.000 |
| dense | S5 | negated fact | 1 | H11 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S6 | paraphrase | 1 | H07 | 1.000 | 1.000 | 1.000 |
| RRF hybrid | S6 | paraphrase | 1 | H07 | 1.000 | 1.000 | 1.000 |
| dense | S6 | paraphrase | 1 | H07 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S7 | lexical overlap | 1 | H15 | 1.000 | 1.000 | 1.000 |
| RRF hybrid | S7 | lexical overlap | 1 | H15 | 1.000 | 1.000 | 1.000 |
| dense | S7 | lexical overlap | 1 | H15 | 1.000 | 1.000 | 1.000 |
| BM25 lexical | S8 | out of scope | not applicable | no lexical match | not applicable | not applicable | not applicable |
| RRF hybrid | S8 | out of scope | not applicable | H02 | not applicable | not applicable | not applicable |
| dense | S8 | out of scope | not applicable | H02 | not applicable | not applicable | not applicable |
| Method | Query | Probe type | Relevant rank | Top passage | Recall at 1 | Recall at 3 | Reciprocal rank |
|---|---|---|---|---|---|---|---|
| BM25 lexical | S1 | vocabulary mismatch | 2 | H15 | 0.000 | 1.000 | 0.500 |
| BM25 lexical | S3 | vocabulary mismatch | not retrieved | no lexical match | 0.000 | 0.000 | 0.000 |
| BM25 lexical | S4 | vocabulary mismatch | not retrieved | H13 | 0.000 | 0.000 | 0.000 |
| RRF hybrid | S4 | vocabulary mismatch | 10 | H13 | 0.000 | 0.000 | 0.100 |
| dense | S4 | vocabulary mismatch | 4 | H11 | 0.000 | 0.000 | 0.250 |
| BM25 lexical | S8 | out of scope | not applicable | no lexical match | not applicable | not applicable | not applicable |
| RRF hybrid | S8 | out of scope | not applicable | H02 | not applicable | not applicable | not applicable |
| dense | S8 | out of scope | not applicable | H02 | not applicable | not applicable | not applicable |
| Dense above BM25 | Dense-BM25 ties | BM25 above dense | RRF above dense | RRF-dense ties | Dense above RRF | RRF above BM25 | RRF-BM25 ties | BM25 above RRF |
|---|---|---|---|---|---|---|---|---|
| 3 | 4 | 0 | 0 | 6 | 1 | 3 | 4 | 0 |
Dense search fixes the child-care wording mismatch in S1 and the stipend wording mismatch in S3. It misses the transit query at the top three. BM25 does well on identifiers and exact wording, but it has no nonzero score for some paraphrases. RRF helps S1 and S3 and keeps several exact hits, yet it still misses the transit query at the top three. For S4, dense ranks the relevant H05 at 4, while RRF ranks it at 10 because H05 appears only in the dense list and receives no BM25 rank contribution. These are paired demonstrations on seven author-written probes, not a benchmark.
Larger evaluations do not give one method a permanent crown. BEIR reported BM25 as a strong zero-shot baseline across varied retrieval tasks, while dense passage retrieval reported large gains on open-domain question answering; entity-question work found dense retrievers can miss questions about rarely seen named entities. One DSC-104 probe in a 15-passage handbook cannot show that dense search handles identifiers.
What approximate search would change
This lesson scans all 15 passage vectors exactly. A larger system may use approximate nearest neighbour search to avoid comparing a query with every vector. In that context, ANN recall means agreement with exact nearest-neighbour search. It is not the same as relevance recall, which asks whether a human-judged relevant passage was retrieved. A fast ANN index can match exact search well and still retrieve an irrelevant passage.
An index of private text is private data. Embeddings can leak information about their source text, so storing vectors does not anonymize the handbook or any real collection.
Choosing an embedding model now
The embedding model here is small and several years old. Newer open models, such as EmbeddingGemma and the Qwen3 embedding models, score higher on public benchmarks like MMTEB, and some can run on a laptop. A benchmark rank does not reliably predict performance on your own documents, and a new model means re-embedding the whole index, because vectors from different models cannot be compared. Judge any change on a small set of your own queries with relevance judgments written in advance, as this page does, and keep a lexical list beside the dense one.
What to remember
- A lexical index stores terms, counts, document frequency, and passage length.
- Identifier tokenization has to be explicit before BM25 can match codes.
- Dense search stores model-specific vectors, not free-standing meanings.
- Query-time metadata must match the stored index metadata.
- Similarity search returns a ranked row even when no relevant passage exists.
- Recall from relevance judgments and ANN recall answer different questions.
The dense list rescued the child-care query, but the no-menu query showed why every search result still needs a relevance check.
Sources
- Dense Passage Retrieval for Open-Domain Question Answering
- Robertson and Zaragoza on BM25
- Sentence-BERT
- all-MiniLM-L6-v2 model card
- Sentence-Transformers semantic search documentation
- BEIR benchmark paper
- Simple Entity-Centric Questions Challenge Dense Retrievers
- Reciprocal Rank Fusion
- HNSW approximate nearest-neighbour search
- Text Embeddings Reveal Almost As Much As Text
- MMTEB: Massive Multilingual Text Embedding Benchmark
- EmbeddingGemma technical report
- Qwen3 Embedding technical report