Skip to content
Select theme

Phrase and boolean queries

Step 7 of 12, and the index starts remembering where words sit. Step 6 left haystack with a BM25 ranker that answers one term at a time and has no way to ask about two together. This step gives every posting the positions of its occurrences, moves the index file to version 3, and builds two kinds of query on top: phrases, which ask for words next to each other in order, and boolean queries with AND, OR and NOT.

The starting state is tag search-ch06, the end of step 6: postings that carry term frequencies, documents that carry lengths, and a scorer that uses both. The end state of this step is tagged search-ch07. The corpus, the tokeniser and the scorer are untouched — the single-term ranking in the demo below prints the same scores step 6 did. What changes is what a posting holds, and what can be asked of it.

A position is the index of a term in the sequence the tokeniser produced for a document. That sequence is what survives step 3: lowercased, split on anything that is not a letter or a digit, stop words dropped, inflections stemmed. A document’s length is the size of the sequence, and a term’s positions are the slots it occupies in it.

Take docs/borrow-checker.md. Its first sentence, “The borrow checker enforces the borrowing rules at compile time”, contributes seven terms — “the” is a stop word, so “borrow” is the first term the index sees and sits at position 0, with “checker” at 1. The second sentence adds five more, two of them “borrow”, at 7 and 10. Nothing of the original text survives: casing, punctuation and the stop words are gone before the first position is counted.

Positions fall out of the run counting step 2 already did. Each term is tagged with its index before the sort, the sort groups one term’s occurrences together, and each run becomes a posting carrying its positions. The term frequency step 5 added is the length of that list — which is why a posting does not carry a frequency any more.

The block below is the engine’s indexing path with the tokeniser replaced by terms that are already normalised, so the positions are the only machinery on screen. Document 0 is docs/borrow-checker.md in full, document 1 is docs/ownership.md:

borrow doc 0: [0, 3, 7, 10]
borrow doc 1: [1]
checker doc 0: [1]
checker doc 1: []
value doc 0: [11]
value doc 1: [7]
owner doc 0: [9]
owner doc 1: [9, 10]

A posting is now two fields where it was three:

/// One entry in a term's postings list: the document, and where each
/// occurrence of the term sits in it.
#[derive(Debug, PartialEq)]
struct Posting {
/// The document this posting refers to.
doc: u32,
/// Where each occurrence sits in the document's term sequence,
/// ascending. One position per occurrence, so the term frequency
/// is the length of this list.
positions: Vec<u32>,
}

Version 2 wrote a document id and a term frequency in eight bytes per posting. Version 3 writes the document id, a position count and the positions themselves. The frequency is gone as a field: it was always the number of positions, and a file that carried both could disagree with itself. On disk one posting is now the id, the count, and four bytes per position.

The corpus grows from 2131 bytes to 2619: four bytes for each of the 122 term occurrences across the 103 postings. The count per posting costs nothing new, because it sits where the frequency used to. A version 2 file is refused like every other version this build does not write, and the demo says so:

a version 2 file: unsupported index version 2

With positions in the index, “borrow checker” becomes a different question from “borrow” and “checker” anywhere in the text. The algorithm is short. A document can only match if it holds every term, so the candidates are the first term’s postings. For each candidate, one occurrence of the first term has to be followed by the second one position later, the third two later, and so on.

The block below is that algorithm over the corpus’s real postings — document 0 is docs/borrow-checker.md, document 1 docs/ownership.md:

["borrow", "checker"]: doc 0 true, doc 1 false
["checker", "borrow"]: doc 0 false, doc 1 false
["value", "owner"]: doc 0 false, doc 1 false

On the corpus, “borrow checker” matches two documents: docs/borrow-checker.md, where the words open the first sentence, and docs/lifetimes.md, which contains “The borrow checker uses lifetimes”. The phrase is the same question in both. Reversed — “checker borrow” — it matches nothing anywhere.

AND does not ask where. “value AND owner” finds two documents, because both words are in both; the phrase “value owner” finds none, because in docs/ownership.md “value” sits at 7 and the first “owner” at 9, and in docs/borrow-checker.md the order is the other way round. Co-occurrence against adjacency is the whole of what positions buy.

One trade-off arrives from step 3 and lands here. Stop words are dropped before positions are counted, so a phrase can match across one: “owner value” finds “the owner of the value”, where the words are three apart in the text. Lucene’s default stop filter behaves the same way, and its documentation says why: with a position increment of one per kept token, “blue is the sky” indexes as blue and sky next to each other, so the phrase “blue sky” finds it. Leaving a gap for each removed stop word is possible there and is not done here, which means a phrase containing a stop word is asking a question this index cannot answer precisely.

The mistake this step invites is the offset. enumerate hands back a usize and the positions are u32, so the offset needs a cast:

error[E0308]: mismatched types
--> src/main.rs:6:53
|
6 | println!("{}", positions.contains(&(start + 1 + offset)));
| ^^^^^^ expected `u32`, found `usize`
error[E0277]: cannot add `usize` to `u32`
--> src/main.rs:6:51
|
6 | println!("{}", positions.contains(&(start + 1 + offset)));
| ^ no implementation for `u32 + usize`
|
= help: the trait `Add<usize>` is not implemented for `u32`
help: the following other types implement trait `Add<Rhs>`
--> /Users/rango/.rustup/toolchains/1.99.0-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/arith.rs:98:9
|
98 | const impl Add for $t {
| ^^^^^^^^^^^^^^^^^^^^^ `u32` implements `Add`
...
113 | add_impl! { usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f16 f32 f64 f128 }
| ---------------------------------------------------------------------------------- in this macro invocation
|
::: /Users/rango/.rustup/toolchains/1.99.0-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/internal_macros.rs:22:9
|
22 | const impl $imp<$u> for &$t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&u32` implements `Add<u32>`
...
33 | const impl $imp<&$u> for $t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `u32` implements `Add<&u32>`
...
44 | const impl $imp<&$u> for &$t {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&u32` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)
Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.

The query language is small. A query is whitespace-separated tokens: bare words, “quoted phrases”, and the operators AND, OR and NOT. The operators are capitals only, so a document about the word “and” stays searchable. Operators evaluate left to right with equal precedence — “fox OR dog AND cat” is “(fox OR dog) AND cat”, which is not how Lucene reads it. NOT applies to the operand that follows it, and between two operands it reads as AND: “borrow NOT checker” is “borrow AND NOT checker”. Two operands with no operator between them mean AND as well, so “borrow checker” and “borrow AND checker” ask the same question.

Every operand is normalised by the same tokeniser the index uses. A stop word in a query matches nothing, exactly as it does in a single-term search; a hyphenated word is one word that normalises to two terms, so “borrow-checker” is the phrase they form, which is what the hyphen meant in the text.

The block below is the evaluation loop with the postings replaced by a fixed table, so the set algebra is the only thing on screen. It splits on whitespace; the engine’s tokeniser also keeps quoted phrases together, and it is in the full file at the end of this page:

fox AND dog -> {2}
fox OR dog -> {0, 1, 2}
fox NOT dog -> {0}
fox dog -> {2}
fox OR dog AND cat -> {2}
fox AND -> refused: an operator is missing its operand

The last line is the grammar refusing to guess: an operator with nothing on one side of it is an error, not a query with a missing half. The engine reports it the same way on the corpus — query "borrow AND" prints “an operator in this query is missing its operand”.

Step 6’s rank stays for the single-term case. A boolean query ranks its matches by summing one BM25 contribution per positive term — the terms that selected documents, with NOT’s operands left out. A query with no positive terms, “NOT checker” say, still returns its documents, but every score is zero and the order is document order: there is nothing to rank on.

The sum is pinned by a test: on three documents holding “fox”, “dog” and both, the document with both scores exactly what each term alone gives it, added together. In the demo below, “borrow OR value” scores docs/borrow-checker.md by both terms — 0.3321 for “borrow” plus what “value” adds — which is why it sits above the documents that hold only one of them.

One file: the positions in the postings, the phrase matcher, the query grammar and its evaluator, the scorer over several terms, and the tests. The block below is the file at tag search-ch07, byte for byte.

From the haystack directory:

Terminal window
cargo run
6 documents, 84 distinct terms
saved to haystack.idx: 2619 bytes
saved again: 2619 bytes, byte-identical: true
loaded back: 6 documents, 84 terms
postings for "borrow" — count, then positions:
docs/ownership.md tf=1 positions=[1]
docs/borrow-checker.md tf=4 positions=[0, 3, 7, 10]
docs/lifetimes.md tf=1 positions=[5]
docs/iterators.md tf=1 positions=[10]
docs/style-guide.md tf=5 positions=[10, 13, 16, 21, 26]
query "borrow", ranked by BM25:
0.3321 docs/borrow-checker.md tf=4 len=12
0.2564 docs/style-guide.md tf=5 len=57
0.2139 docs/lifetimes.md tf=1 len=13
0.2139 docs/iterators.md tf=1 len=13
0.2042 docs/ownership.md tf=1 len=15
phrase "borrow checker":
docs/borrow-checker.md
docs/lifetimes.md
phrase "checker borrow" — the other order:
no documents
"value AND owner":
docs/ownership.md
docs/borrow-checker.md
phrase "value owner":
no documents
query "borrow AND checker":
1.6520 docs/borrow-checker.md
1.5026 docs/lifetimes.md
query "borrow OR value":
0.8192 docs/borrow-checker.md
0.6584 docs/ownership.md
0.4897 docs/style-guide.md
0.4871 docs/error-handling.md
0.2139 docs/lifetimes.md
0.2139 docs/iterators.md
query "borrow NOT lifetime":
0.3321 docs/borrow-checker.md
0.2564 docs/style-guide.md
0.2139 docs/iterators.md
0.2042 docs/ownership.md
query "borrow AND":
an operator in this query is missing its operand
a version 2 file: unsupported index version 2

The tests:

Terminal window
cargo test
running 64 tests
test tests::a_newer_version_is_refused ... ok
test tests::a_missing_file_is_an_io_error ... ok
test tests::a_one_word_phrase_is_that_words_postings ... ok
test tests::a_phrase_can_match_across_a_dropped_stop_word ... ok
test tests::a_doubled_consonant_from_the_suffix_is_collapsed ... ok
test tests::a_missing_term_is_empty_not_an_error ... ok
test tests::a_phrase_matches_adjacent_terms_in_order ... ok
test tests::a_phrase_across_several_documents ... ok
test tests::a_phrase_matches_inside_a_longer_document ... ok
test tests::a_hyphenated_word_is_a_phrase_in_disguise ... ok
test tests::a_multi_term_query_sums_one_contribution_per_term ... ok
test tests::a_phrase_needs_the_terms_next_to_each_other ... ok
test tests::a_postings_list_that_is_not_ascending_is_refused ... ok
test tests::a_query_that_breaks_the_grammar_is_refused ... ok
test tests::a_query_with_no_positive_terms_scores_zero ... ok
test tests::a_quoted_phrase_needs_adjacency_but_the_same_words_do_not ... ok
test tests::a_repeated_term_records_where_each_copy_sits ... ok
test tests::a_frequent_term_in_a_relevant_document_still_wins ... ok
test tests::a_stem_needs_three_characters ... ok
test tests::a_stop_word_in_a_query_matches_nothing ... ok
test tests::a_term_in_two_documents_lists_both ... 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_saved_index_loads_back_identical ... ok
test tests::a_version_1_file_is_refused ... ok
test tests::a_version_2_file_is_refused ... ok
test tests::a_terms_positions_survive_a_reload ... ok
test tests::a_wrong_magic_is_refused ... ok
test tests::a_truncated_file_is_refused ... ok
test tests::add_document_returns_the_id_it_gave ... ok
test tests::an_empty_index_has_no_average_length ... ok
test tests::an_empty_phrase_matches_nothing ... ok
test tests::an_empty_query_is_refused ... ok
test tests::an_empty_index_round_trips ... ok
test tests::and_keeps_the_documents_with_both_terms ... ok
test tests::average_length_is_the_mean_of_the_document_lengths ... ok
test tests::bm25_puts_the_highest_score_first ... ok
test tests::case_folding_is_unicode_aware ... ok
test tests::casing_and_punctuation_are_folded_away ... ok
test tests::distinct_terms_in_one_document_get_one_posting_each ... ok
test tests::document_length_counts_the_terms_the_tokeniser_kept ... ok
test tests::double_negation_cancels ... ok
test tests::doubling_that_is_not_the_suffixs_fault_survives ... ok
test tests::equal_documents_keep_document_order ... 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::bm25_lets_the_shorter_document_win ... ok
test tests::ids_are_in_insertion_order ... ok
test tests::inflections_share_one_term ... ok
test tests::k1_of_zero_makes_frequency_irrelevant ... ok
test tests::length_normalisation_off_leaves_length_alone ... ok
test tests::length_normalisation_on_favours_the_short_document ... ok
test tests::not_removes_the_documents_with_the_term ... ok
test tests::operators_evaluate_left_to_right ... ok
test tests::or_keeps_the_documents_with_either_term ... ok
test tests::positions_are_ascending ... ok
test tests::positions_skip_the_stop_words_around_them ... ok
test tests::stop_words_are_not_indexed ... ok
test tests::term_frequency_saturates ... ok
test tests::the_term_frequency_is_the_position_count ... ok
test tests::the_dictionary_is_written_in_sorted_order ... ok
test tests::two_terms_with_no_operator_mean_and ... ok
test tests::saving_twice_writes_the_same_bytes ... ok
test tests::trailing_bytes_are_refused ... ok
test result: ok. 64 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Sixty-four tests: the forty from step 6, and twenty-four around the new machinery — positions in order, phrases across documents and across dropped stop words, each operator, the evaluation order, the refusals, and the version 3 round trip. The order is shuffled on every run, so nothing should be read into it.

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

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

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 8, Fuzzy matching and typo tolerance, searches the dictionary itself by edit distance, so a query with a typo in it finds the term the reader meant.