Component · L3
PageRank
github.com/juanmmm21/pagerank-link-analysis
An iterative PageRank implementation computing page authority from a crawled link graph using sparse matrix operations.
- Lines
- 1,158
- Files
- 30
- Test files
- 10
- Commits
- 11
- Started
- 9 Jul 2026
- Last commit
- 9 Jul 2026
- Python 100%
● Where it sits
Layer 3 of Beacon — ranking & relevance.
L1 Ingestion
L2 Indexing
L3 Ranking & relevance
L4 Query serving
↳ Flagship
README · 7 min read
Commits per day
Project 6/10 of the beacon-search-engine ecosystem — Ranking and Relevance category.
Repository: github.com/juanmmm21/pagerank-link-analysis
The algorithm that put Google on the map, implemented from scratch: iterative
PageRank with a configurable teleportation (damping) factor, computing a
stationary authority distribution over a crawled link graph via sparse matrix
power iteration. No networkx, no graph-processing library: the transition
matrix, the dangling-node mass redistribution, and the convergence criterion
are all implemented here with scipy.sparse and numpy.
What problem it solves
BM25 tells you how well a document's text matches a query. It says nothing about whether the web itself considers that document important. Two pages can contain the exact same words with the exact same frequency, yet one is a central reference other pages link to constantly and the other is an obscure dead end nobody points at — that difference in structural authority is exactly what a purely lexical signal cannot see. PageRank answers this by modeling a "random surfer" who follows outbound links most of the time and occasionally jumps to a random page in the corpus (the teleportation, or damping, factor): the fraction of time the surfer spends on each page, at the process's stationary distribution, is that page's authority. This project computes exactly that distribution, from a real crawled link graph, using sparse linear algebra so it scales to corpora with far more pages than would fit in a dense adjacency matrix.
Role in beacon-search-engine
web-crawler-scheduler
│ link_graph.jsonl (outbound link graph)
│ inverted-index-builder
│ │ documents.jsonl (doc_id ↔ url)
▼ ▼
┌───────────────────────────────────┐
│ pagerank-link-analysis │ (this project)
│ link graph → authority score │
└─────────────────┬───────────────────┘
│ doc_id → pagerank_score
▼
learning-to-rank-reranker
▲
│ doc_id → BM25 score
bm25-ranking-engine
│
▼
(converges in beacon-search-console)
pagerank-link-analysis sits alongside bm25-ranking-engine as one of the
two ranking signals learning-to-rank-reranker combines: where BM25 scores
lexical relevance to a specific query, this project scores structural
authority independent of any query, computed once per corpus (or recomputed
whenever the link graph changes) rather than per search. It depends on the
outbound link graph produced by
web-crawler-scheduler
and on the doc_id space assigned by
inverted-index-builder,
but never on document text — that is what lets it be recalculated on a
fresh crawl without rebuilding the inverted index at all.
Goal and skills demonstrated
- PageRank derived directly from the random-surfer model, not copied from
a graph library: the transition matrix, the
(1 - d)/Nteleportation term, and the dangling-node mass-redistribution term are each implemented and justified on their own, not hidden behind a single opaque.rank()call. - Sparse matrix algebra at every step: the adjacency matrix is built and
iterated as a
scipy.sparse.csr_matrix; each power-iteration step is one sparse matrix-vector product, so the cost per iteration is proportional to the number of edges in the link graph, never to the square of the number of pages — the difference between an algorithm that scales to a real crawl and one that only works in a classroom example. - Explicit, tested dangling-node handling: pages with no resolvable
outbound links (either because they truly have none, or because every link
they had points outside the indexed corpus) would silently leak probability
mass out of the system on every iteration if left unhandled. Their mass is
redistributed uniformly across all documents each iteration, and this is
covered by a dedicated test asserting the rank vector still sums to
1.0. - Deterministic, hand-verified correctness: the two canonical PageRank
textbook examples — a star graph (one hub, several leaves, hub as the
dangling node) and a cycle graph (uniform distribution is an exact fixed
point for any damping factor) — are solved analytically in the test
docstrings and asserted against the algorithm's output with
math.isclose, never==(../CLAUDE.md, section 2.C). - Independent, minimal re-implementation of a sibling repo's URL
normalization, not an import of it: joining
link_graph.jsonl(whoseurlfield is the crawler's post-redirect URL) againstdocuments.jsonl(whoseurlfield is the crawler's pre-redirect URL) requires canonicalizing both sides the same way — implemented here from scratch, entirely independently ofweb-crawler-scheduler's own package, the same polyrepo integration contractbm25-ranking-engineapplied to a binary format.
How it works
- Resolving
doc_ids (document_resolver.py):documents.jsonlfrominverted-index-builderis loaded once into aurl (normalized) → doc_idmap.total_documentsismax(doc_id) + 1— the same conventionbm25-ranking-enginerelies on for a dense, gap-freedoc_idspace. - Normalizing URLs (
url_normalizer.py): lowercases scheme and host, drops default ports, drops fragments, sorts query parameters, and strips trailing slashes — standard URL canonicalization, needed becauselink_graph.jsonl'surlfield is the post-redirect URL whiledocuments.jsonl'surlfield is pre-redirect, and because outbound links as they appear in raw HTML carry trivial formatting variance that would otherwise fail to match an indexed page. - Building the graph (
graph_builder.py):link_graph.jsonlis read lazily, line by line; each entry's URL and each of its (deduplicated) outlinks are resolved to adoc_id. Entries or links that don't resolve — external URLs, or pages crawled but never indexed — are dropped explicitly and counted inGraphBuildStats, never silently. The result is atotal_documents x total_documentssparse adjacency matrix, sized to the fulldoc_idspace so that documents absent from the link graph still get a baseline authority score. - Power iteration (
pagerank.py): the adjacency matrix is row-normalized by out-degree into a transition matrix (dangling rows are left at zero and handled separately); starting from a uniform rank vector, each iteration computesd * (Mᵀ·rank + dangling_mass/N) + (1-d)/Nas sparse matrix-vector arithmetic, and stops when the L1 distance between consecutive rank vectors drops belowtoleranceormax_iterationsis reached — an explicit, bounded convergence criterion, never an unbounded loop. - Orchestration (
pipeline.py):PageRankPipelineties the resolver, the graph builder, and the algorithm together behind a singlecompute()method, andscores_io.pypersists the result as its own on-disk format (manifest.json+pagerank_scores.jsonl+convergence.json), the same manifest-plus-JSONL conventioninverted-index-builderandindex-compression-codecuse for their own index directories.
Architecture
src/pagerank_link_analysis/
├── models.py # DocumentRecord, PageRankParams, PageRankScore,
│ # GraphBuildStats, ConvergenceInfo, PageRankRunResult
├── protocols.py # DocumentIdResolver interface
├── url_normalizer.py # independent URL canonicalization for cross-repo joins
├── document_resolver.py # JsonlDocumentIdResolver: url -> doc_id, backed by documents.jsonl
├── link_graph_reader.py # lazy line-by-line reader of link_graph.jsonl
├── graph_builder.py # link_graph.jsonl + resolver -> sparse adjacency matrix
├── pagerank.py # power iteration: damping, dangling nodes, convergence
├── scores_io.py # on-disk output format (manifest + scores + convergence)
├── pipeline.py # PageRankPipeline: ties everything together
└── __main__.py # CLI (`compute`, `top`)
Requirements and installation
- Python
>=3.11 - Runtime dependencies:
numpyandscipy—scipy.sparseis what makes the power iteration viable on graphs with thousands of nodes instead of a dense matrix that would not fit in memory at real crawl scale (../CLAUDE.md, section 1 andAGENTS.md's Definition of Done).
git clone https://github.com/juanmmm21/pagerank-link-analysis.git
cd pagerank-link-analysis
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Usage (CLI)
# Compute PageRank from a crawler's link graph and an indexed corpus
pagerank-link-analysis compute ./documents.jsonl ./link_graph.jsonl ./output
# Tune the damping factor, convergence tolerance, and iteration cap
pagerank-link-analysis compute ./documents.jsonl ./link_graph.jsonl ./output \
--damping-factor 0.85 --tolerance 1e-8 --max-iterations 200
# Inspect the highest-authority documents of an already-computed result
pagerank-link-analysis top ./output --documents ./documents.jsonl --top-k 10
Try it against the small, hand-built graph checked into samples/tiny_example/
(one hub linked by two leaves, plus one fully isolated page and one external
link that intentionally doesn't resolve):
pagerank-link-analysis compute \
samples/tiny_example/documents.jsonl samples/tiny_example/link_graph.jsonl /tmp/pagerank-example
pagerank-link-analysis top /tmp/pagerank-example --documents samples/tiny_example/documents.jsonl
It can also be invoked as a module: python -m pagerank_link_analysis compute ....
Data formats
Input — documents.jsonl, produced by inverted-index-builder: one JSON
object per line, at minimum doc_id and url (see
that project's README
for the full schema). total_documents is derived as max(doc_id) + 1.
Input — link_graph.jsonl, produced by web-crawler-scheduler: one JSON
object per line, outbound links per page (see
that project's README):
{"url": "http://example.com/", "outlinks": ["http://example.com/a", "http://example.com/b"]}
Output — --output-dir produces a directory with three files:
manifest.json — format version and file names:
{
"format_version": 1,
"scores_file": "pagerank_scores.jsonl",
"convergence_file": "convergence.json"
}
pagerank_scores.jsonl — one line per document, ordered by ascending
doc_id, the direct integration contract with learning-to-rank-reranker:
{"doc_id": 0, "pagerank_score": 0.02380952380952381}
Scores across the whole corpus sum to 1.0 by construction (they are a
probability distribution over the random surfer's position), so every
individual value already falls within [0, 1] without any additional
rescaling.
convergence.json — parameters, convergence outcome, and graph-build
statistics for that run, useful for debugging and for the benchmark below:
{
"damping_factor": 0.85,
"tolerance": 1e-06,
"max_iterations": 100,
"iterations_run": 14,
"converged": true,
"final_delta": 9.36e-07,
"elapsed_seconds": 0.000195,
"total_documents": 2000,
"resolved_edges": 11448,
"dangling_documents": 103,
"unresolved_source_entries": 0,
"unresolved_target_links": 193
}
Programmatic usage
from pathlib import Path
from pagerank_link_analysis.models import PageRankParams
from pagerank_link_analysis.pipeline import PageRankPipeline
pipeline = PageRankPipeline.from_documents_path(Path("./documents.jsonl"))
result = pipeline.compute(
Path("./link_graph.jsonl"), PageRankParams(damping_factor=0.85, tolerance=1e-8)
)
print(result.convergence.converged, result.convergence.iterations_run)
for scored in sorted(result.scores, key=lambda s: s.score, reverse=True)[:5]:
document = pipeline.document(scored.doc_id)
print(scored.doc_id, scored.score, document.title if document else None)
Convergence benchmark
Measured against samples/benchmark/, a reproducible synthetic reference
graph (python samples/generate_benchmark_graph.py, fixed seed 42): 2,000
documents, Poisson-distributed out-degree (mean 6), 5% of pages dangling by
construction, and roughly 10% of pages carrying one link outside the crawled
corpus — deliberately mirroring how a real crawl's link graph loses mass to
unindexed pages.
tolerance | max_iterations | Iterations to converge | Wall time |
|---|---|---|---|
1e-6 (CLI default) | 100 | 14 | ~0.20 ms |
1e-8 | 200 | 19 | ~0.30 ms |
Resolved edges: 11,448. Dangling documents: 103. Unresolved target links
(pointing outside the corpus): 193. The dominant cost is the sparse
matrix-vector product per iteration (O(edges)), not the number of
documents — this is exactly why the adjacency matrix is never densified: a
dense 2000 x 2000 matrix would already be 32 MB of mostly zeros for a graph
with barely 11,448 real edges, and the gap only widens at real crawl scale.
Development
pytest
ruff check .
ruff format --check .
mypy --strict src/
Tests cover the domain models (PageRankParams validation), the URL
normalizer (default ports, fragments, query ordering, trailing slashes), the
document resolver (exact and trivially-varied URL matches, duplicate
detection), the link graph reader, the adjacency matrix builder (edge
deduplication, unresolved source/target counting, dangling detection), the
PageRank algorithm itself (hand-derived star and cycle graphs, mass
conservation with dangling nodes, non-convergence reporting, single-document
and empty-graph edge cases), the on-disk output format (round-trip), the
end-to-end pipeline, and the CLI.
Troubleshooting
- A document I know is linked keeps showing up with only the baseline
teleportation score: check
unresolved_target_linksandunresolved_source_entriesinconvergence.json— if the crawler'slink_graph.jsonlentry for that page uses a URL that doesn't normalize to the same string as itsdocuments.jsonlentry (a redirect chain the crawler didn't fully resolve, or a URL shapeurl_normalizer.pydoesn't canonicalize), the edge is silently dropped by design rather than guessed at. ValueError: no se puede calcular PageRank sobre un corpus de 0 documentos:documents.jsonlis empty or missingdoc_id/urlfields — rebuild it withinverted-index-builderbefore recomputing PageRank.ValueError: URL normalizada duplicada tras normalización: two differentdoc_ids indocuments.jsonlnormalize to the same canonical URL — a data-integrity issue upstream (the crawler is supposed to deduplicate by normalized URL before a page ever reaches indexing), not something this project silently works around.- Convergence takes far more than the iterations shown in the benchmark
above: a smaller
damping_factorconverges faster (more teleportation mass smooths the distribution each iteration) at the cost of authority scores that lean more uniform; a link graph with many disconnected clusters typically needs more iterations than one where every page is reachable from every other. mypyfails undertests/: expected — onlysrc/is type-checked in--strictmode; test fixtures use deliberately looser typing.
License
MIT — see LICENSE.