Chapter 2: The Analysis Chain¶
A lexical index cannot use raw prose directly. It needs a repeatable rule that turns both documents and queries into comparable terms. That rule is an analysis chain: a tokenizer discovers token boundaries and attributes, then ordered filters transform or remove tokens. The deceptively difficult part is preserving information that later query types need. MiniLucene's smallest but most important example is the position gap left by a removed stopword.
Learning objectives¶
By the end of this chapter, you can:
- distinguish a tokenizer from a token filter;
- read a MiniLucene
Tokenas term, position, and source offsets; - explain why stopword removal must not renumber positions;
- predict the difference between standard and keyword analysis; and
- verify phrase behavior using an in-memory index.
Mechanism: attributes survive the pipeline¶
src/minilucene/analysis/model.py defines the immutable Token. Its
__post_init__ validates that the term is non-empty, the position and offsets
are non-negative, and the end offset is not before the start. These checks make
bad analyzer output fail near its source.
The pipeline abstractions live in src/minilucene/analysis/pipeline.py.
Tokenizer.tokenize produces a tuple of tokens; TokenFilter.apply consumes an
iterable and returns another tuple. Analyzer.analyze runs exactly one
tokenizer and then each configured filter in order:
def analyze(self, text: str) -> tuple[Token, ...]:
tokens = self.tokenizer.tokenize(text)
for token_filter in self.filters:
tokens = token_filter.apply(tokens)
return tokens
This is composition, not an inheritance tree. A filter can change one
attribute while preserving the others. LowercaseFilter.apply uses
dataclasses.replace to change only term; position and offsets survive.
StopwordFilter.apply removes matching tokens but deliberately returns the
survivors unchanged.
The standard implementation is in
src/minilucene/analysis/standard.py. StandardTokenizer.tokenize applies the
Unicode-aware regular expression \w+. For every match it records:
term: the exact matched substring before filtering;position: the match's ordinal among all tokenizer output;start_offset: the Python string index where the match begins; andend_offset: the exclusive index where it ends.
StandardAnalyzer composes that tokenizer with lowercase and stopword filters.
The default stopword set contains only "the", keeping the educational behavior
easy to inspect. KeywordAnalyzer, by contrast, uses KeywordTokenizer and no
filters. A non-empty input becomes one token at position zero whose offsets span
the full string. It is appropriate for identifiers and exact labels, not prose.
Why the gap matters¶
Consider fast the search. The tokenizer assigns positions 0, 1, and 2.
Lowercasing changes no coordinates. Stopword filtering removes the token at
position 1 but leaves fast@0 and search@2. If the filter compressed the
survivors to positions 0 and 1, the index would claim that fast and search
were adjacent even though another source token intervened.
That false adjacency would make an exact phrase query for "fast search"
match fast the search. MiniLucene prevents it at analysis time. In
src/minilucene/index/memory.py, RamIndexBuilder.add_prepared groups each
term's positions and stores them in Posting.positions. The phrase matcher can
therefore require the expected consecutive offsets instead of relying on term
co-occurrence.
Offsets solve a different problem. They locate token text in the original
stored string. LowercaseFilter may turn Fast into the searchable term
fast, but the offsets still point to characters 0 through 4 of Fast.
MiniLucene's highlighter can re-analyze a stored text field, select matching
spans, escape the original content, and wrap the correct original characters.
Positions answer “where in token order?”; offsets answer “where in the source
string?” They are not interchangeable.
Document analysis and query analysis¶
RamIndexBuilder.prepare_document in
src/minilucene/index/memory.py calls the private _analyze function for each
indexed field. _analyze selects StandardAnalyzer or KeywordAnalyzer from
the schema's analyzer_name. This binds analysis to the field contract.
The query parser also needs compatible normalization. Query text is parsed into a query AST and the matching layer compares its normalized terms with indexed terms. The key invariant is not “run any tokenizer everywhere”; it is “document and query terms for one field must inhabit the same vocabulary.” An exact keyword field and a standard text field intentionally inhabit different vocabularies.
Analyzer choice is part of the index contract¶
Analysis cannot be changed casually after documents have been indexed. Suppose
old documents were lowercased but new queries retained case. A query for
FAST would look for a dictionary entry that was never written. Suppose a
later analyzer began removing another stopword: old and new segments would
encode phrase positions under different rules. MiniLucene reduces that drift
by persisting the complete field definitions in schema.json and checking
Schema.fingerprint in Index.open.
The fingerprint proves equality of declared field configuration, not equality
of arbitrary analyzer code. Built-in analyzer names currently map to fixed
factories in _analyze; there is no plugin registry or analyzer-version field.
If those implementations changed incompatibly without a format or schema
change, an old index could be queried under new behavior. That simplification
teaches a production lesson: analyzer identity includes configuration and
implementation version, not merely a human name.
Multi-valued fields expose another position issue in real systems. Lucene can insert a position gap between successive values so a phrase does not accidentally span two values. MiniLucene accepts one string per field, so it has no multi-value gap policy. The same coordinate-system idea applies, but that case is outside this project's contract.
Analysis is usually selected per field because identifiers, titles, natural-language bodies, paths, and code have different vocabularies. Applying one supposedly smart analyzer everywhere can destroy exact identifiers or fail to normalize prose. MiniLucene offers only two choices, but their contrast teaches the decision.
What this implementation does not claim¶
The regex tokenizer recognizes Unicode word characters, but it is not a full linguistic segmenter. Languages without whitespace boundaries, stemming, lemmatization, synonyms, decompounding, and domain-specific normalization need additional components. Even English punctuation behavior follows Python's regular expression semantics rather than Lucene's StandardTokenizer rules. The pipeline boundary makes extensions conceivable; it does not make the default analyzer universally correct.
Compared with Apache Lucene¶
Apache Lucene models analysis with Analyzer, Tokenizer, TokenStream, and
token attributes such as CharTermAttribute, PositionIncrementAttribute, and
OffsetAttribute. A removed token is commonly represented through the next
surviving token's position increment. Lucene analyzers are reusable streaming
components and include rich standard, language, synonym, and normalization
support.
MiniLucene keeps materialized tuples and absolute positions. This is easier to
read but allocates all tokens at once. Its StandardTokenizer is a small
\w+ regex, its default stopword list is one word, and its schema supports
only the built-in standard and keyword analyzer names. It does not reproduce
Lucene's Unicode Text Segmentation behavior or analysis SPI. The field boost is
also fixed in MiniLucene's schema, whereas modern Lucene favors query-time
boosting. Consult the Lucene mapping and the analysis,
phrase, and highlighting rows of the
behavior matrix.
Hands-on experiment: observe the gap¶
Run from the repository root:
export UV_CACHE_DIR=/tmp/minilucene-uv-cache
uv run --offline python - <<'PY'
from minilucene.analysis import StandardAnalyzer, Token
from minilucene import MemoryIndex, Schema, TextField
from minilucene.query import PhraseQuery
tokens = StandardAnalyzer().analyze("Fast THE search")
print([(t.term, t.position, t.start_offset, t.end_offset) for t in tokens])
try:
Token("", 0, 0, 0)
except ValueError as error:
print(type(error).__name__, str(error))
index = MemoryIndex(Schema(body=TextField(stored=True)))
index.add_document(body="fast the search")
index.add_document(body="fast search")
result = index.search(PhraseQuery("body", ("fast", "search")))
print(result.total_hits, [dict(hit.stored_fields) for hit in result.hits])
PY
Measured output:
[('fast', 0, 0, 4), ('search', 2, 9, 15)]
ValueError term must be non-empty
1 [{'body': 'fast search'}]
Three details deserve attention. THE becomes the lowercase stopword before it
is removed. search retains position 2, proving the gap survived. Its offsets
still point to the original source substring beginning at character 9. The
phrase matches only the genuinely adjacent second document.
For a focused regression check, run:
uv run --offline pytest -q tests/unit/analysis/test_pipeline.py \
tests/contract/test_query_matching.py
The repository's measured result is:
23 passed in 0.06s
Timing is machine-dependent; the pass count is the acceptance signal.
Exercises¶
-
Understanding: Why is lowercasing allowed to preserve offsets even when the replacement term may have a different representation?
Reference answer
Offsets describe the original source span, not indexes into the normalized term. They are later used to recover/highlight source text.
-
Understanding: Would storing only term frequency be enough for exact phrase matching?
Reference answer
No. Frequency says how often a term occurs but not whether terms occur at consecutive compatible positions. Phrase matching needs positions.
-
Hands-on: Run the script with
"Fast, search!"and predict the tokens before executing. Acceptance: two lowercase tokens appear at positions 0 and 1, with offsets matching the original words.Reference answer
The expected tuple is
[('fast', 0, 0, 4), ('search', 1, 6, 12)]. Punctuation is a separator and is not included in either offset span. -
Hands-on: Without changing
src/, constructStandardAnalyzer(stopwords=frozenset({"fast"}))and analyze"fast reliable search". Acceptance:reliablestays at position 1 andsearchat 2.Reference answer
Import
StandardAnalyzer, pass the custom set, and print the tokens. The result demonstrates that removal of the first token does not shift the remaining coordinate system.
Summary¶
Analysis is a controlled loss of information: punctuation and stopwords may disappear, case may normalize, but positions and source offsets survive because later mechanisms need them. MiniLucene's tuple pipeline makes that contract visible. The next chapter follows those analyzed tokens into the RAM inverted index, where terms become posting lists and document lengths become scoring norms.