Compare lexical, dense, and fused rankings without pretending a top hit is an answer
systems
semantic search
retrieval
Learn how a small search index stores terms, vectors, metadata, and relevance checks for one fictional handbook.
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.
Note
The Riverton handbook in this lesson is an invented teaching fixture about fictional programs and policies.
TipWhat you will learn
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.
The child-care passage and a nearby class-related distractor
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.
knitr::kable( judgment_summary,format ="html",escape =TRUE,col.names =c("Query", "Query text", "Probe type", "Relevant passage"),caption ="Author-written relevance judgments used for the search checks",row.names =FALSE)
Author-written relevance judgments used for the search checks
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.
Explicit tokenization keeps an identifier together
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
knitr::kable( posting_preview,col.names =c("Term", "Passage", "Count"),caption ="Identifier and room-number postings under the lesson tokenizer",row.names =FALSE)
Identifier and room-number postings under the lesson tokenizer
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.
BM25 top passages for the child-care and course-code probes
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
knitr::kable( default_s2_preview |>mutate(score =round(score, 3)),col.names =c("Passage", "Topic", "Default-tokenizer rank", "BM25 score"),caption ="Default word splitting keeps the right course-code passage first but fills the rest of the top three with partial matches",row.names =FALSE)
Default word splitting keeps the right course-code passage first but fills the rest of the top three with partial matches
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_itemif (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_maskpassage_embeddings <-map(handbook$text, embed_one_text)embedding_matrix <-do.call(rbind, passage_embeddings)rownames(embedding_matrix) <- handbook$passage_iddense_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)) ))knitr::kable( dense_metadata,col.names =c("Metadata field", "Recorded value"),caption ="Dense index metadata that must match at query time",row.names =FALSE)
Dense index metadata that must match at query time
Longest handbook passages remain inside the 256-word-piece runtime limit
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.
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" ) )knitr::kable( index_record,format ="html",escape =TRUE,col.names =c("Index key", "Value"),caption ="Index metadata needed before reusing stored vectors",row.names =FALSE)
Index metadata needed before reusing stored vectors
knitr::kable( metadata_check,col.names =c("Check", "Safe to query"),caption ="A changed pooling convention fails closed before search",row.names =FALSE)
A changed pooling convention fails closed before search
Check
Safe to query
current model
TRUE
changed pooling example
FALSE
knitr::kable( edited_passage_check |>select(passage_id, fingerprint_matches, action),col.names =c("Passage", "Fingerprint matches", "Index action"),caption ="A changed passage fingerprint triggers re-embedding for that passage",row.names =FALSE)
A changed passage fingerprint triggers re-embedding for that passage
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.
Top passage per method; BM25 returns no lexical match for S3 and S8
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.
Per-query retrieval results from exhaustive author-written judgments
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
knitr::kable( miss_table_display,col.names =c("Method","Query","Probe type","Relevant rank","Top passage","Recall at 1","Recall at 3","Reciprocal rank" ),caption ="Every miss and every no-relevant-passage query remains visible",row.names =FALSE)
Every miss and every no-relevant-passage query remains visible
Paired reciprocal-rank counts on the seven judged queries
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.
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.