Component · L4
Query Parser
github.com/juanmmm21/query-parser-autocomplete
A query parser with operator support, spellcheck, and prefix/n-gram based autocomplete suggestions.
- Lines
- 1,616
- Files
- 34
- Test files
- 12
- Commits
- 17
- Started
- 9 Jul 2026
- Last commit
- 9 Jul 2026
- Python 100%
● Where it sits
Layer 4 of Beacon — query serving.
L1 Ingestion
L2 Indexing
L3 Ranking & relevance
L4 Query serving
↳ Flagship
Feeds into
Index ShardsSame layer
Index ShardsREADME · 7 min read
Commits per day
Project 8/10 of the beacon-search-engine ecosystem — Query Service category.
Repository: github.com/juanmmm21/query-parser-autocomplete
The entry point of the query pipeline: turns a raw string a user types into a search box into a structured, normalized query with operator support (exact phrases, exclusions, basic filters), corrects likely typos against the real vocabulary of the crawled index, and serves ranked, sub-100ms autocomplete suggestions as the user types — the layer that stands between "a text box" and "a search engine that understands what you meant."
What problem it solves
A real search box is not LIKE '%query%'. Users misspell terms, want an
exact phrase honored literally, want to exclude a term, want to scope a
search to a site or file type, and expect suggestions to appear before they
finish typing. None of that is something bm25-ranking-engine should have
to know about — it expects a clean, already-structured query. This project
is the layer that absorbs that messiness: a hand-written recursive-descent
style scanner parses operators without ever throwing on malformed input, a
length-bucketed bounded edit-distance search corrects likely typos against
the index's actual vocabulary (never a generic dictionary), and two
prefix tries — one over frequent whole queries, one over vocabulary terms —
serve ranked completions fast enough for interactive typing.
Role in beacon-search-engine
inverted-index-builder
│ postings.jsonl (term -> document_frequency)
▼
┌─────────────────────────────────────────────────┐
│ query-parser-autocomplete │ (this project)
│ parse (operators) + spellcheck + autocomplete │
└───────────────────┬───────────────────┬───────────┘
│ │
│ ParsedQuery │ Suggestion[]
▼ ▼
bm25-ranking-engine beacon-search-console
(live suggestions)
query-parser-autocomplete sits upstream of the whole ranking pipeline: it
reads the on-disk vocabulary of
inverted-index-builder
(for spellcheck and term-level autocomplete, using the exact same
Unicode/casefold normalization that project applies when tokenizing, so
corrected and completed terms actually match the index's vocabulary), and
produces the structured ParsedQuery that
bm25-ranking-engine
consumes, plus the ranked suggestion list
beacon-search-console
shows live. It never imports either project's Python package — integration
is always through the on-disk format each documents, per
beacon-search-engine's polyrepo contract. It does not depend on
pagerank-link-analysis or learning-to-rank-reranker at all: those sit
further downstream in the ranking pipeline, past the point this project's
output enters it.
Goal and skills demonstrated
- A hand-written query grammar that never crashes on malformed input:
quotes that are never closed, a dangling
field:, a-followed by a space — every one of these degrades to the simplest reasonable interpretation (seequery_parser.py's docstring) instead of raising, the same "never fail the whole pipeline on one bad input" principle../CLAUDE.md(section 2.D) applies to malformed HTML, applied here to malformed query text. - Bounded-cost spellcheck at real vocabulary scale:
spellcheck.pynever runs edit distance against the whole vocabulary — it buckets terms by length (a term more thanmax_distancecharacters longer or shorter than the query term can never be withinmax_distanceedits of it) so each correction only touches a small neighborhood of candidates, andedit_distance.py's DP aborts a row early once its minimum already exceeds the bound, both hand-written from scratch (nopython-Levenshtein, no spellcheck library —../AGENTS.md). - A prefix trie built for the latency budget it has to meet: descending
a prefix is
O(len(prefix)), and collecting matches only walks the subtree under that prefix, never the full vocabulary — see Latency below for a measured number, not just an asymptotic claim. - A clearly documented synthetic data source, not a hidden one: v1 has
no real search traffic yet (
beacon-search-consoledoesn't exist until project 10/10), soquery_log.py's synthetic query log generator is documented in full — Zipf-shaped rank-based frequencies, deterministic under a fixed seed — exactly the same transparency principlelearning-to-rank-reranker's synthetic training set follows. - Train/serve-style consistency treated as a correctness concern: this
project reimplements
inverted-index-builder's exact tokenization algorithm (NFC normalization,casefold(), the same token-boundary regex) independently, not approximately — a spellcheck correction or an autocomplete match is worthless if it doesn't actually exist as a term in the real index.
How it works
- Parsing the grammar (
query_parser.py): a hand-written character scanner walks the raw query once, recognizing exact phrases ("..."), term exclusion (-term), and basic filters (field:value, includingfield:"quoted value") — deliberately not a single giant regex, so that malformed input degrades predictably instead of failing to match at all. This step never touches the index: it only understands grammar, not spelling. - Normalizing (
normalization.py): every free term and phrase term is tokenized with the exact algorithminverted-index-builderuses to build the index (NFC normalization, Unicode-aware letter/digit runs,casefold()— neverlower(), which does not fold characters like the Germanß); filter values are normalized as a whole string instead (site:example.commust stayexample.com, not split into["example", "com"]). - Reading the real vocabulary (
index_reader.py): an independent, minimal reader ofinverted-index-builder's uncompressedmanifest.json+postings.jsonlformat — onlytermanddocument_frequencyare needed here, sodocuments.jsonlandstats.jsonare never even parsed. A compressedindex-compression-codecindex is rejected with a clear error, same convention aslearning-to-rank-reranker. - Spellchecking (
spellcheck.py,edit_distance.py): each free query term not already in the vocabulary is corrected against the closest vocabulary term within a bounded edit distance (2 by default), searched only within a length-bucketed neighborhood — never a brute-force scan of the whole vocabulary. Terms inside an exact phrase or an exclusion are never corrected: quoting or excluding a term is the user asking for it literally. - Autocomplete (
trie.py,query_log.py,autocomplete.py): two prefix tries — one over frequent whole queries (real completions like"python tuto"→"python tutorial for beginners"), one over vocabulary terms (completes only the word currently being typed, e.g."search engi"→"search engine", even when the log has no matching query) — are merged by suggestion text, keeping the maximum score on a collision, and ranked by score descending with an alphabetical tie-break for determinism. - Orchestration (
pipeline.py,__main__.py):parse_and_correct_querycombines steps 1–4;build_autocomplete_index/suggestcombine steps 3, 5. Both accept theprotocols.TermCorrector/PrefixSuggesterinterfaces, so tests exercise pipeline logic with in-memory doubles without rebuilding a real index each time.
Architecture
src/query_parser_autocomplete/
├── models.py # ParsedQuery, Filter, QueryCorrection, VocabularyTerm, ...
├── protocols.py # TermCorrector / PrefixSuggester interfaces
├── normalization.py # NFC + casefold tokenizer, matching inverted-index-builder
├── edit_distance.py # bounded, early-exit Levenshtein distance
├── index_reader.py # independent reader of the index vocabulary
├── query_parser.py # hand-written scanner: phrases, exclusions, filters
├── spellcheck.py # length-bucketed bounded-distance correction
├── trie.py # prefix trie, weighted by frequency
├── query_log.py # query log IO + documented synthetic generator
├── autocomplete.py # merges query-trie + term-trie suggestions
├── pipeline.py # parse_and_correct_query(), suggest()
└── __main__.py # CLI (`parse`, `suggest`, `generate-query-log`)
Requirements and installation
- Python
>=3.11 - Zero runtime dependencies — spellcheck (edit distance) and autocomplete
(trie) are implemented from scratch, per
../AGENTS.md.
git clone https://github.com/juanmmm21/query-parser-autocomplete.git
cd query-parser-autocomplete
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Usage (CLI)
# Parse a query with combined operators against a real index, with spellcheck
query-parser-autocomplete parse ./index \
'"exact phrase" pyhton -deprecated site:example.com'
# Same, without spellcheck, writing the result to a file for bm25-ranking-engine
query-parser-autocomplete parse ./index "python tutorial" \
--no-spellcheck --output ./parsed-query.json
# Autocomplete suggestions for a prefix, against a real query log
query-parser-autocomplete suggest ./index "python tuto" \
--query-log ./query_log.jsonl --limit 5
# Materialize a synthetic query log once, to reuse across `suggest` calls
query-parser-autocomplete generate-query-log ./index ./query_log.jsonl \
--num-queries 500 --seed 42
Try it end-to-end against the tiny fixture checked into
samples/tiny_example/ (a 5-document index and a hand-picked query log):
query-parser-autocomplete parse samples/tiny_example/index \
'pyhton tutorial -javascrpt site:example.com'
query-parser-autocomplete suggest samples/tiny_example/index "python tuto" \
--query-log samples/tiny_example/query_log.jsonl
It can also be invoked as a module: python -m query_parser_autocomplete parse ....
Data formats
Input — index vocabulary: the uncompressed manifest.json +
postings.jsonl produced by
inverted-index-builder
(only term and document_frequency are read from each posting line). A
compressed index-compression-codec index is rejected explicitly.
Input — query log (query_log.py), one JSON object per line:
{"query": "python tutorial", "frequency": 120}
Real production traffic would populate this once beacon-search-console
exists; until then, generate-query-log materializes a documented
synthetic one from the index's own vocabulary.
Output — parsed query, the contract bm25-ranking-engine consumes
(ParsedQuery.to_json_dict):
{
"required_terms": ["python", "tutorial"],
"phrases": [["exact", "phrase"]],
"excluded_terms": ["deprecated"],
"filters": [{"field": "site", "value": "example.com"}],
"corrections": [{"original": "pyhton", "corrected": "python"}]
}
Output — suggestions, the contract beacon-search-console consumes
(Suggestion.to_json_dict), printed by the CLI as
rank. text (score=...):
{"text": "python tutorial for beginners", "score": 45}
Programmatic usage
from pathlib import Path
from query_parser_autocomplete import pipeline
# Parsing + spellcheck
corrector = pipeline.build_spell_checker(Path("./index"))
parsed = pipeline.parse_and_correct_query(
'"exact phrase" pyhton -deprecated site:example.com', corrector
)
print(parsed.required_terms, parsed.corrections)
# Autocomplete
autocomplete_index = pipeline.build_autocomplete_index(
Path("./index"), Path("./query_log.jsonl")
)
suggestions = pipeline.suggest("python tuto", autocomplete_index, limit=5)
for suggestion in suggestions:
print(suggestion.text, suggestion.score)
Development
pytest
ruff check .
ruff format --check .
mypy --strict src/
Tests cover the domain models (JSON round-trips, validation), normalization
(NFC/casefold parity with inverted-index-builder's exact algorithm,
including composed vs. decomposed Unicode input), bounded edit distance
(hand-computable cases, boundary conditions on the distance cap), the
independent index-vocabulary reader (round-trips and malformed-input
errors against small on-disk fixtures), the query parser grammar
(ambiguous, misspelled, and combined-operator queries, plus every
malformed-input degradation path — unterminated quotes, a dangling
field:, - followed by whitespace), spellcheck (deterministic tie-break
by frequency then alphabetically, length-bucket pruning), the trie and
autocomplete index (ranking order, deduplication across the two suggestion
sources, and a timed benchmark against a 20,000-term/5,000-query synthetic
fixture asserting sub-100ms latency), the synthetic query log generator
(determinism under a fixed seed), and the pipeline/CLI end to end using
in-memory TermCorrector/PrefixSuggester test doubles where the intent is
testing this project's own orchestration, not spellcheck or autocomplete
logic already covered elsewhere.
Latency
Autocomplete must be sub-100ms for interactive typing to feel responsive.
AutocompleteIndex.suggest is not a scan: descending a prefix through the
trie is O(len(prefix)) (one dict lookup per character), and collecting
matches only walks the subtree rooted at that prefix, never the full set of
indexed queries or terms — a specific, narrow prefix has an inherently small
subtree almost by construction. tests/test_autocomplete.py measures this
directly against a synthetic fixture of 20,000 vocabulary terms and 5,000
query-log entries (larger than any real corpus this ecosystem currently
indexes) and asserts the average call stays well under 100ms; on the
development machine used to build this project it measures well under 1ms
per call (~0.16ms average over 100 calls). Building the two tries from
scratch — the one operation that is linear in corpus size, ~27ms for that
same fixture — happens once per process, in build_autocomplete_index,
never per keystroke.
Troubleshooting
ValueError: ... es un índice comprimido de index-compression-codec: this project only reads the uncompressedinverted-index-builderformat — decompress withindex-compression-codecfirst.parsereturns a filter you didn't intend, e.g. a bare word containing a colon like9:00becomes{"field": "9", "value": "00"}: v1's filter grammar treats anyalnum_or_underscore:valuebare word as a filter, with no restriction to a known operator set (site,filetype, ...) — wrap it in quotes ("9:00") to force it to be parsed as a literal phrase instead.- A misspelled term inside a quoted phrase or after
-is never corrected: this is intentional, not a bug — see How it works, step 4. suggestfor an empty prefix returns the most frequent whole queries: also intentional — a reasonable "trending searches" default before the user has typed anything, rather than an empty result.- Spellcheck returns
None(no correction) for an obviously misspelled term: the closest vocabulary term is farther thanmax_distance(2 by default) edits away — either raisemax_distancewhen constructingSpellChecker, or the term genuinely isn't in the index's vocabulary at all (e.g. it was never crawled).
License
MIT — see LICENSE.