Flagship
Search Console
github.com/juanmmm21/beacon-search-console
A full search engine console (FastAPI + React) assembling crawling, indexing, ranking and reranking into a working product over a real corpus.
- Lines
- 2,888
- Files
- 71
- Test files
- 12
- Commits
- 17
- Started
- 9 Jul 2026
- Last commit
- 11 Jul 2026
- Python 43.8%
- TypeScript 42.1%
- CSS 10.7%
- Shell 2.9%
● Where it sits
The flagship of Beacon: it assembles every layer.
L1 Ingestion
L2 Indexing
L3 Ranking & relevance
L4 Query serving
↳ Flagship
README · 9 min read
Commits per day
Project 10/10 of the beacon-search-engine ecosystem — the flagship application.
Repository: github.com/juanmmm21/beacon-search-console
A complete, working search engine console — FastAPI backend + React/TypeScript
frontend — that assembles the nine infrastructure pieces of the ecosystem into
one product and runs them over a real corpus: 181 pages crawled live from
docs.python.org/3 (tutorial + library reference), indexed, compressed,
partitioned into shards, and served with BM25 + PageRank + learning-to-rank
reranking, live autocomplete, spell correction, and highlighted snippets. No
Elasticsearch, no Lucene, no search SaaS anywhere in the stack: every layer
underneath this app is one of the ecosystem's own from-scratch projects.
What problem it solves
Each of the nine previous projects proves one piece of search infrastructure in isolation: a polite crawler, a boilerplate-stripping extractor, a positional inverted index, a postings compression codec, a BM25 scorer, a PageRank implementation, a gradient-boosted reranker, a query parser with autocomplete, and a sharded fan-out/merge layer. None of them, alone, is a search engine. This project is the proof that the ecosystem composes: it wires all nine together — through the exact on-disk contracts and package APIs each of them documents — into a product a person can actually use, over content crawled from the real web, with the failure modes of a real system (a shard process dying mid-session) handled visibly instead of crashing.
Role in beacon-search-engine
OFFLINE (scripts/bootstrap_corpus.sh)
┌──────────────────────────────────────────────────────────────────────────────┐
│ web-crawler-scheduler ──▶ filter_corpus_domain.py ──▶ html-content-extractor │
│ │ link_graph.jsonl │ documents.jsonl (main_text) │
│ │ ▼ │
│ │ inverted-index-builder │
│ │ │ index/ (postings + stats) │
│ │ ▼ │
│ │ index-compression-codec │
│ │ │ compressed index │
│ ▼ ▼ │
│ pagerank-link-analysis distributed-index-sharding `partition` │
│ │ pagerank_scores │ shard-0/ .. shard-N/ │
│ └──────────┐ │ learning-to-rank-reranker │
│ │ │ │ trained model │
└───────────────┼──────────────┼────────────┼──────────────────────────────────┘
▼ ▼ ▼
ONLINE (this project)
┌──────────────────────────────────────────────────────────────────────────────┐
│ FastAPI backend │
│ query ─▶ query-parser-autocomplete (parse + spellcheck) │
│ ─▶ distributed-index-sharding (fan-out to N shard │
│ processes, each scoring with bm25-ranking-engine, │
│ k-way merge, tolerance to shard failure) │
│ ─▶ learning-to-rank-reranker (BM25 + PageRank + match │
│ features, grouped per shard — see Architecture) │
│ ─▶ snippets from html-content-extractor's documents.jsonl │
│ │
│ React + TypeScript frontend │
│ debounced search · live autocomplete · highlighted snippets · │
│ index stats panel · explicit shard-degradation banner │
└──────────────────────────────────────────────────────────────────────────────┘
This is where the whole ecosystem converges — the app every other README
refers to as "converges in beacon-search-console".
Goal and skills demonstrated
- Orchestration, not reimplementation: this app contains no tokenizer, no
scorer, no ranking formula of its own. Parsing and autocomplete come from
query-parser-autocomplete, distributed scoring fromdistributed-index-sharding(which delegates BM25 tobm25-ranking-engineinside each shard process), and the final ordering fromlearning-to-rank-rerankercombining BM25 with the authority scores ofpagerank-link-analysis. The backend's own code is glue: configuration, lifecycle, one integration bridge (below), and snippet presentation. - The one integration no other repo could own:
distributed-index-shardingmerges per-shard BM25 results but knows nothing about reranking;learning-to-rank-rerankerreranks against a single index directory but knows nothing about shards. Bridging them — grouping merged candidates by the shard that owns eachdoc_id, reranking per shard against that shard's local index, then re-merging with the ecosystem's standard deterministic tie-break — is exactly the kind of composition problem a flagship app exists to solve (backend/src/beacon_search_console/reranking.py). - Real corpus, reproducible bootstrap:
scripts/bootstrap_corpus.shruns the six offline tools end to end againstdocs.python.org(allowed by itsrobots.txt, crawled with the polite rate-limitingweb-crawler-schedulerenforces) — no canned fixtures in production, no fake data in the demo.web-crawler-schedulerhas no domain-restriction flag of its own (it's a reusable crawling primitive, not scoped to any one corpus), so a crawl at--max-depth 2legitimately follows outbound links offdocs.python.org(permalinks intogithub.com/python/cpython, mostly);scripts/filter_corpus_domain.pyis this project's own intermediate step — never a change to the crawler itself — that keeps only the seed domain's pages before extraction, so the indexed corpus is exactly the "real corner of the web" this app set out to serve, not everything the crawl happened to reach. - Failure handled as a feature: killing a shard process mid-session
degrades the response (
degraded: true, per-shard statuses, partial results from the healthy shards) instead of returning a 500 — surfaced in the UI as an explicit banner naming which shard is down and why. - Typed on both sides of the wire:
mypy --stricton the backend, TypeScriptstrict(plusnoUncheckedIndexedAccess) on the frontend, and a hand-mirrored contract (frontend/src/types/api.ts↔backend/src/beacon_search_console/models.py) for the versioned/api/v1API.
How it works
A search request flows through the backend like this:
- Parse + spellcheck — the raw string is parsed by
query-parser-autocomplete(parse_and_correct_query): exact phrases,-exclusions,site:filters, and typo correction against the global index vocabulary (built once at startup from the unpartitioned index — shard-local vocabularies would miss corrections for terms living on other shards). - Fan-out — the resulting
ParsedQueryJSON is handed toDistributedSearchPipeline.search_parsed_query, which asks every shard process concurrently over HTTP; each shard scores with its ownbm25-ranking-engineinstance and applies exclusions/site:filters locally. The coordinator k-way-merges the healthy shards' results and reports the failed ones — it never raises because a shard died. - Rerank — the merged candidates (over-fetched:
limit × 3by default, so the reranker has real freedom to reorder) go through the sharding↔reranking bridge described above, producing the final order from BM25, PageRank, and index-derived match features. - Snippets — the ranked
doc_ids are resolved back to real text. The indexing chain deliberately dropsmain_text(an index stores postings, not prose), so the backend loadshtml-content-extractor'sdocuments.jsonlat startup and correlates bydoc_id= line number — the exact contractinverted-index-builderdocuments. A window around the first match is returned as plain text plus character-offset highlight ranges; the frontend wraps those ranges in<mark>itself (neverdangerouslySetInnerHTMLwith server-built HTML).
Autocomplete (/api/v1/autocomplete) delegates to
query-parser-autocomplete's two-trie suggester, built once at startup from
the global vocabulary (plus an optional query log). Index stats
(/api/v1/index/stats) read the builder's stats.json, the cluster
manifest, and the most recent fetched_at in the extractor's output.
Architecture
beacon-search-console/
├── scripts/
│ ├── bootstrap_corpus.sh # offline pipeline: crawl → filter domain →
│ │ # extract → index → compress → pagerank →
│ │ # partition → train LTR
│ ├── filter_corpus_domain.py # keeps only the seed domain's pages
│ │ # (web-crawler-scheduler has no domain
│ │ # restriction of its own, see Goal above)
│ └── tests/ # unittest, stdlib only, no extra dependency
├── data/ # bootstrap output (gitignored, reproducible)
├── samples/tiny_corpus/ # 5-doc deterministic fixture for backend tests
├── backend/
│ ├── pyproject.toml # installs the 3 real package dependencies
│ └── src/beacon_search_console/
│ ├── config.py # env-configurable paths/params (BEACON_*)
│ ├── models.py # the API's own response contract
│ ├── dependencies.py # AppState: everything opened once per process
│ ├── app.py # FastAPI app + lifespan + /api/v1 routers
│ ├── reranking.py # the sharding ↔ reranking bridge
│ ├── snippets.py # doc_id → real text, highlight ranges
│ ├── index_stats.py # minimal reader of the builder's stats.json
│ └── routes/ # search.py, autocomplete.py, stats.py
└── frontend/
└── src/
├── api/client.ts # typed fetch client for /api/v1
├── types/api.ts # TS mirror of the backend contract
├── components/ # SearchBox (with autocomplete dropdown),
│ # SearchResults, SnippetText, StatsPanel,
│ # ShardStatusBanner — each with a Vitest +
│ # Testing Library test alongside it
├── hooks/useDebouncedValue.ts # + its own debounce unit test
└── App.tsx # debounced search, ?q= URL sync
Two deliberate architecture decisions:
- Offline/online separation. The six tools that build artifacts (crawler,
extractor, index builder, codec, PageRank, partitioner + LTR training) are
installed into a disposable
.bootstrap-venv/by the bootstrap script and never imported by the backend. The backend's own venv installs only the three packages the online path genuinely needs to call in-process:distributed-index-sharding,query-parser-autocomplete, andlearning-to-rank-reranker(direct git-URL dependencies — the documented ecosystem exception for outputs that are in-memory only, first established bybm25-ranking-engine). Everything else is consumed through the on-disk formats each project documents. - Everything expensive opens once. Shard subprocesses, the LightGBM
model, the snippet table, the spellchecker, and the autocomplete tries are
all built in the FastAPI lifespan and closed explicitly on shutdown —
DistributedSearchPipelineowns the shard processes' lifecycle, so stopping the API stops the whole local cluster with it.
Requirements
- Python
>=3.11 - Node.js
>=20(frontend) - macOS:
brew install libomp(LightGBM's OpenMP runtime, needed bylearning-to-rank-reranker) - The nine sibling repositories cloned next to this one (the bootstrap script installs the offline tools from the sibling folders; the backend installs its three package dependencies from GitHub)
Bringing the whole engine up from scratch
git clone https://github.com/juanmmm21/beacon-search-console.git
cd beacon-search-console
# 1. Build the corpus and every artifact (crawls docs.python.org politely;
# ~180 pages, a few minutes). Re-run anytime to refresh the corpus.
bash scripts/bootstrap_corpus.sh
# 2. Start the API (spawns one real shard process per shard and health-checks
# them before serving).
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
python -m beacon_search_console --port 8000
# 3. Start the UI (proxies /api to :8000).
cd ../frontend
npm install
npm run dev # → http://localhost:5173
MAX_PAGES, MAX_DEPTH and NUM_SHARDS tune the bootstrap
(MAX_PAGES=400 NUM_SHARDS=4 bash scripts/bootstrap_corpus.sh). The backend
reads its paths and parameters from BEACON_* environment variables
(config.py documents each one); by default everything points at data/.
Quick start if the corpus is already bootstrapped
If data/ (the bootstrap output), backend/.venv and frontend/node_modules
already exist from a previous run, there is no need to repeat step 1 above —
the corpus doesn't change until scripts/bootstrap_corpus.sh is run again, so
just start the two long-running processes directly:
# Terminal 1 — API (spawns the real shard subprocesses on startup)
cd backend
source .venv/bin/activate
python -m beacon_search_console --port 8000
# Terminal 2 — UI
cd frontend
npm run dev # → http://localhost:5173
Open http://localhost:5173, type a query (e.g. python tutorial), and the
results, snippets and index-stats panel are all live against the real corpus.
Manually forcing shard degradation — the same check the automated tests
and the manual smoke test both cover, but doable by hand against the real,
running cluster: kill one of the shard subprocesses DistributedSearchPipeline
started and confirm the next search degrades instead of failing.
# 1. Find the shard processes and their PIDs — one per configured shard
# (default 3), each running distributed_index_sharding's own serve-shard.
ps aux | grep serve-shard | grep -v grep
# Example output (PID in the second column, --shard-id near the end):
# golfeno 18101 ... serve-shard .../data/shards/shard-0 --shard-id 0 ...
# golfeno 18102 ... serve-shard .../data/shards/shard-1 --shard-id 1 ...
# golfeno 18103 ... serve-shard .../data/shards/shard-2 --shard-id 2 ...
# 2. Kill one shard by its PID (shard 1 in this example)
kill -9 18102
# 3. Search again — either in the UI, or directly against the API:
curl -s "http://127.0.0.1:8000/api/v1/search?q=python%20tutorial&limit=3" | python3 -m json.tool
The response comes back with "degraded": true, a message naming how many
shards failed to respond, and a shard_statuses entry with "status": "error"
for the killed shard (a connection-refused error, not a timeout, since the
process is simply gone) — the other shards' results still populate results.
In the UI, the same information renders as the amber "Índice degradado" banner
(ShardStatusBanner) listing every shard's status, with the surviving
results still shown underneath it. Restarting the API respawns a fresh,
healthy cluster; there's nothing to clean up on the killed shard itself.
API
The contract is versioned under /api/v1 from the first commit. All
endpoints are GET.
| Endpoint | Parameters | Returns |
|---|---|---|
/api/v1/search | q (query text), limit (1–50, default 10) | Ranked results with url, title, score, and a snippet (text + highlights as {start, end} character ranges); applied spelling corrections; per-shard shard_statuses; degraded flag and a human-readable message when the query was empty, matched nothing, or ran against a partially-down cluster |
/api/v1/autocomplete | q (prefix), limit (1–20, default 8) | Ranked suggestions (text, score); an empty prefix returns the most frequent queries, query-parser-autocomplete's documented "trending" default |
/api/v1/index/stats | — | total_documents, vocabulary_size, total_postings, average_document_length, num_shards, last_crawled_at (most recent fetched_at in the corpus) |
A shard failing mid-query never produces a 500: the response arrives with
degraded: true, the failing shard's status/error_message in
shard_statuses, and results from the surviving shards.
Frontend
- Debounced search (300 ms) with in-flight request cancellation — the latest keystroke always wins, one request per typing pause, never one per key.
- Live autocomplete (150 ms debounce) with full keyboard navigation
(arrows / Enter / Escape), backed by
/api/v1/autocomplete. - Snippet highlighting built from character ranges: the API sends plain
text plus offsets, and the UI constructs
<mark>React nodes from them — no server-provided HTML is ever injected. - Explicit degradation UI: if any shard is down, a banner names each shard's status and clarifies that results come from the healthy ones only.
- Honest operator hints: the search box itself states the v1 engine
limitations (quoted phrases score as their individual terms — BM25 carries
no positional adjacency through the sharded path — and only the
site:filter has real semantics), inherited frombm25-ranking-engine/distributed-index-shardingand documented in their READMEs rather than discovered by surprise. - Shareable searches: the query lives in the URL (
?q=), updated withreplaceStateso typing doesn't pollute history. - UI text is in Spanish, code identifiers in English — the ecosystem's standing convention.
Development
# Backend (from backend/)
pytest # 24 tests, including real 2-shard cluster startup
ruff check src/ tests/
mypy --strict src/
# Frontend (from frontend/)
npm run test # Vitest + Testing Library, 30 tests
npm run typecheck
npm run build
# scripts/filter_corpus_domain.py (from the repo root) — stdlib-only, no
# extra dependency, so plain unittest rather than pytest
python3 -m unittest discover -s scripts/tests
Backend tests run against samples/tiny_corpus/ — a 5-document,
hand-written, fully deterministic fixture partitioned into 2 shards, with a
pre-trained tiny LTR model — so they exercise the real
DistributedSearchPipeline (actual shard subprocesses on test-only ports)
without network access or the bootstrapped corpus. Shard-failure degradation
is covered with an in-memory pipeline double, mirroring how
distributed-index-sharding tests its own coordinator; the
kill-a-real-process path is covered by that project's end-to-end suite and by
manual smoke testing here.
Frontend tests cover SnippetText's range sanitation (out-of-bounds,
negative, overlapping and unsorted highlight ranges — never trusting the
backend's offsets blindly), the debounce hook in isolation
(useDebouncedValue, including the "several rapid changes collapse into one"
case the search box exists to guarantee), SearchBox's live autocomplete
(debounce, keyboard navigation, a rejected request closing the dropdown
instead of throwing) against a mocked API client, ShardStatusBanner's
degradation rendering, and App-level integration (one search request per
typing pause, the degraded banner rendering alongside the surviving shards'
results, a connection error surfacing instead of crashing).
Troubleshooting
FileNotFoundError: ... cluster_manifest.json(orstats.json,documents.jsonl) at startup: the corpus was never bootstrapped — runbash scripts/bootstrap_corpus.shfirst, or point theBEACON_*variables at an existing data directory.OSError: ... libomp.dylibon macOS: LightGBM needs the OpenMP runtime —brew install libomp.- The API starts but every search comes back degraded with
erroron all shards: another process is occupying the shard port range. The shards bind consecutive ports fromBEACON_SHARD_BASE_PORT(default 9300) — free those ports or move the range. - Results whose snippet doesn't contain the query term: expected for documents matched via a spelling-corrected or phrase-flattened term that appears in the document under a different token boundary; the snippet falls back to the start of the document rather than fabricating a match.
- A quoted phrase returns the same results as its words separately / a
filetype:filter does nothing: v1 engine limitation inherited from the ranking stack (no positional adjacency inbm25-ranking-engine's result; onlysite:has filter semantics) — documented indistributed-index-sharding's Troubleshooting and stated in this UI. - A result points outside
docs.python.org: shouldn't happen —filter_corpus_domain.pyruns right after the crawl and keeps only pages whoseurlhost is exactlydocs.python.org, so externally-linked pages the crawl reaches at--max-depth 2(mostlygithub.com/python/cpythonpermalinks) never make it intoextracted/documents.jsonl. If one still shows up,data/crawl/pages.jsonlwas likely edited or reused across a bootstrap run without going back through the filter step — re-runbash scripts/bootstrap_corpus.shfrom a cleandata/directory. mypyfails underbackend/tests/: expected — onlysrc/is type-checked in--strictmode, the same convention as every other project in the ecosystem.
License
MIT — see LICENSE.