Deploying a custom, domain-scoped Wikipedia clone—such as "Wikipedia, but only for Biomedical Engineering" or "Wikipedia, but only for Distributed Systems"—presents a unique software engineering challenge. Wikimedia dump files exceed 20 GB compressed (over 100 GB raw XML), containing millions of unstructured pages, deep nested categories, and complex Wikitext syntax.
Building a production-ready, subsetted local Wikipedia requires solving five key architectural problems:
- Low-memory streaming XML parsing without out-of-memory (OOM) crashes.
- Deterministic Directed Acyclic Graph (DAG) category traversal to extract target domain subgraphs without unbounded graph fan-out.
- Wikitext AST parsing and semantic chunking.
- Hybrid retrieval combining exact-match full-text search (BM25) with dense vector embeddings (
pgvectoror HNSW indexes). - Low-latency rendering and edge deployment.
This guide outlines the complete end-to-end architecture, implementation code, and operational patterns required to extract, index, and serve a domain-isolated Wikipedia platform.
Architecture Blueprint & System Topology
The pipeline converts raw Wikimedia multistream archives into an indexed, domain-specific query engine via three primary stages: Ingestion & Subgraph Pruning, Vectorization & Indexing, and the Hybrid Query Layer.
┌─────────────────────────────────────────────────────────────────────────┐
│ INGESTION & PRUNING STAGE │
│ │
│ [enwiki-multistream.xml.bz2] ──► [Streaming XML Parser] │
│ │ │
│ [categorylinks.sql.gz] ──► [DAG Category Traversal] │
│ │ │
│ ▼ │
│ [Target Domain Articles] │
└───────────────────────────────────────────┬─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ VECTORIZATION & INDEXING │
│ │
│ [Target Articles] ──► [Wikitext AST Parser] ──► [Text Chunking] │
│ │ │
│ ▼ │
│ [Embedding Model] │
└───────────────────────────────────────────┬────────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STORAGE LAYER │
│ │
│ PostgreSQL Engine (pgvector + tsvector) / SQLite FTS5 │
└───────────────────────────────────────────┬─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ HYBRID QUERY LAYER │
│ │
│ Client Query ──► RRF Rank Fusion (BM25 + Cosine) ──► JSON / HTML │
└─────────────────────────────────────────────────────────────────────────┘
Architectural Comparison of Storage & Retrieval Engines
Selecting the correct storage tier depends on target latency, storage constraints, and whether the system runs embedded on mobile devices or hosted on centralized cloud infrastructure.
| Metric / Parameter | Raw Kiwix / ZIM Format | SQLite + FTS5 + sqlite-vec |
PostgreSQL + pgvector + tsvector |
OpenSearch + Milvus Cluster |
|---|---|---|---|---|
| Primary Use Case | Offline static reading | Embedded / Local Desktop App | Modern Monolith / Scalable SaaS | Multi-million doc enterprise |
| P95 Latency (Hybrid) | N/A (Keyword only: ~15ms) | ~25ms - 45ms | ~8ms - 18ms | ~5ms - 12ms |
| Vector Indexing Type | None | Flat / Brute force (small sets) | HNSW / IVFFlat | HNSW / DiskANN |
| Memory Footprint | Extremely Low (~50MB) | Low (~128MB - 512MB) | Moderate (~2GB - 8GB) | High (~16GB+) |
| Write/Sync Velocity | Read-Only static archive | Bulk Insert (~10k ops/sec) | Parallel Copy (~50k ops/sec) | Distributed Shards |
| Operational Overhead | Negligible | Low (Single file database) | Moderate (Standard Postgres operational management) | High (Multi-node cluster management) |
Phase 1: High-Performance Streaming XML & Category Filter Pipeline
Parsing Wikimedia dumps in memory will trigger immediate OOM errors. We must process the stream using iterparse semantics to clear XML nodes immediately after evaluation.
The following production Python pipeline reads compressed .bz2 streams, traverses a target set of allowed categories (pre-populated via a Category BFS graph), strips non-domain content, and structures clean records.
import bz2
import json
import re
import sys
from typing import Generator, Dict, Any, Set
from xml.etree import ElementTree as ET
# Pre-compiled regex patterns for Wiki markup extraction
REDIRECT_PATTERN = re.compile(r"#REDIRECT\s*\[\[(.*?)\]\]", re.IGNORECASE)
CATEGORY_PATTERN = re.compile(r"\[\[Category:(.*?)\]\]", re.IGNORECASE)
class WikiDumpStreamProcessor:
def __init__(self, target_categories: Set[str]):
# Normalize category names to lower-case string set
self.target_categories = {c.lower().strip() for c in target_categories}
self.processed_count = 0
self.matched_count = 0
def _strip_tag(self, tag: str) -> str:
"""Removes XML namespaces from tags."""
return tag.split("}")[-1] if "}" in tag else tag
def parse_stream(self, file_path: str) -> Generator[Dict[str, Any], None, None]:
"""Streams a .bz2 Wikipedia dump and yields matched domain articles."""
with bz2.open(file_path, "rt", encoding="utf-8") as handle:
context = ET.iterparse(handle, events=("start", "end"))
_, root = next(context) # Get root element to allow periodic clearing
current_article: Dict[str, Any] = {}
current_tag = None
for event, elem in context:
tag_name = self._strip_tag(elem.tag)
if event == "start":
current_tag = tag_name
if tag_name == "page":
current_article = {"title": "", "id": "", "text": "", "categories": []}
elif event == "end":
if tag_name == "title" and current_article:
current_article["title"] = elem.text or ""
elif tag_name == "id" and "id" not in current_article and current_article:
current_article["id"] = elem.text or ""
elif tag_name == "text" and current_article:
text_content = elem.text or ""
current_article["text"] = text_content
# Extract categories embedded in Wikitext
found_cats = CATEGORY_PATTERN.findall(text_content)
current_article["categories"] = [c.split("|")[0].strip().lower() for c in found_cats]
elif tag_name == "page":
self.processed_count += 1
# Evaluate Domain Inclusion Criteria
if self._is_domain_match(current_article):
self.matched_count += 1
yield {
"id": current_article["id"],
"title": current_article["title"],
"categories": current_article["categories"],
"raw_text": current_article["text"]
}
# Crucial memory cleanup step
elem.clear()
root.clear()
def _is_domain_match(self, article: Dict[str, Any]) -> bool:
"""Determines if an article belongs to target categories and is valid."""
# Exclude redirects and media pages
if REDIRECT_PATTERN.match(article.get("text", "")):
return False
if article.get("title", "").startswith(("File:", "Template:", "Help:", "Category:")):
return False
# Intersect article categories with target set
article_cats = set(article.get("categories", []))
return bool(article_cats.intersection(self.target_categories))
if __name__ == "__main__":
# Example: Domain focused purely on Distributed Computing and Databases
target_domain_categories = {
"distributed computing",
"distributed database systems",
"fault-tolerant computer systems",
"consensus algorithms",
"database management systems"
}
processor = WikiDumpStreamProcessor(target_categories=target_domain_categories)
dump_path = "enwiki-latest-pages-articles-multistream.xml.bz2"
try:
for article in processor.parse_stream(dump_path):
# Stream directly into database or JSONL queue
sys.stdout.write(json.dumps({"id": article["id"], "title": article["title"]}) + "\n")
except FileNotFoundError:
sys.stderr.write(f"Dump file not found at path: {dump_path}\n")
Phase 2: PostgreSQL Schema & Reciprocal Rank Fusion Search Layer
Once stripped and vectorised, articles require search capabilities. Combining classical keyword search (BM25 via tsvector) with dense semantic vector search (pgvector) produces superior retrieval precision compared to either approach individually.
PostgreSQL Schema with Vector and Full-Text Indexing
-- Enable vector extension
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Primary Domain Article Table
CREATE TABLE domain_articles (
id BIGINT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
clean_content TEXT NOT NULL,
categories TEXT[] NOT NULL,
fts_tokens TSVECTOR GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', clean_content), 'B')
) STORED,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Separate chunk table for vector embeddings to handle long Wikitext pages
CREATE TABLE article_chunks (
id BIGSERIAL PRIMARY KEY,
article_id BIGINT REFERENCES domain_articles(id) ON DELETE CASCADE,
chunk_index INT NOT NULL,
chunk_text TEXT NOT NULL,
embedding VECTOR(1536) NOT NULL -- Match OpenAI text-embedding-3-small or local Ollama dimensions
);
-- High-performance HNSW index for vector similarity
CREATE INDEX idx_article_chunks_embedding_hnsw
ON article_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- GIN index for fast full-text query processing
CREATE INDEX idx_domain_articles_fts ON domain_articles USING GIN (fts_tokens);
TypeScript Hybrid Search Service Engine
The following implementation executes concurrent full-text and vector queries, merging results using Reciprocal Rank Fusion (RRF) ($k=60$).
import { Pool } from 'pg';
interface SearchResult {
articleId: string;
title: string;
chunkText: string;
score: number;
}
interface RawVectorResult {
article_id: string;
title: string;
chunk_text: string;
cosine_distance: number;
}
interface RawFtsResult {
article_id: string;
title: string;
clean_content: string;
rank: number;
}
export class HybridSearchEngine {
private db: Pool;
constructor(connectionString: string) {
this.db = new Pool({ connectionString, max: 20 });
}
/**
* Performs Hybrid Reciprocal Rank Fusion (RRF) search.
*/
public async search(
queryText: string,
queryVector: number[],
limit: number = 10,
k: number = 60
): Promise<SearchResult[]> {
const client = await this.db.connect();
try {
// 1. Concurrent Execution of Vector Search & FTS
const vectorQueryPromise = client.query<RawVectorResult>(
`SELECT c.article_id, a.title, c.chunk_text, (c.embedding <=> $1::vector) as cosine_distance
FROM article_chunks c
JOIN domain_articles a ON a.id = c.article_id
ORDER BY cosine_distance ASC
LIMIT $2`,
[JSON.stringify(queryVector), limit * 2]
);
const ftsQueryPromise = client.query<RawFtsResult>(
`SELECT id as article_id, title, clean_content, ts_rank_cd(fts_tokens, websearch_to_tsquery('english', $1)) as rank
FROM domain_articles
WHERE fts_tokens @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC
LIMIT $2`,
[queryText, limit * 2]
);
const [vectorRes, ftsRes] = await Promise.all([vectorQueryPromise, ftsQueryPromise]);
// 2. Map and Calculate Reciprocal Rank Fusion Scores
const rrfScores: Map<string, { title: string; chunkText: string; score: number }> = new Map();
// Process Vector Ranks
vectorRes.rows.forEach((row, rank) => {
const key = row.article_id;
const rrfRank = 1 / (k + (rank + 1));
rrfScores.set(key, {
title: row.title,
chunkText: row.chunk_text,
score: rrfRank
});
});
// Process FTS Ranks and Merge
ftsRes.rows.forEach((row, rank) => {
const key = row.article_id;
const rrfRank = 1 / (k + (rank + 1));
if (rrfScores.has(key)) {
const existing = rrfScores.get(key)!;
existing.score += rrfRank; // Sum RRF scores for overlapping documents
} else {
rrfScores.set(key, {
title: row.title,
chunkText: row.clean_content.substring(0, 300) + '...',
score: rrfRank
});
}
});
// 3. Sort merged map by final score descending
const sortedResults = Array.from(rrfScores.entries())
.map(([articleId, val]) => ({
articleId,
title: val.title,
chunkText: val.chunkText,
score: val.score
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return sortedResults;
} catch (error) {
console.error('Hybrid Search Execution Failed:', error);
throw new Error('Database search operation failure.');
} finally {
client.release();
}
}
}
Operations, Incremental Updates, and Edge Deployment
Deploying a subsetted local Wikipedia requires a structured strategy for handling updates and edge delivery.
┌─────────────────────────────────────────────────────────────────────────┐
│ INCREMENTAL UPDATE PIPELINE │
│ │
│ [Wikimedia Monthly Dumps / API] │
│ │ │
│ ▼ │
│ [MD5 Hash & Page Revision Comparison Engine] │
│ │ │
│ ┌────────┴────────┐ │
│ ▼ ▼ │
│ [New Pages] [Modified Pages] │
│ │ │ │
│ ▼ ▼ │
│ [Embed Chunk] [Invalidate CDN Cache] ──► [Re-index Pgvector] │
└─────────────────────────────────────────────────────────────────────────┘
- Incremental Update Engine: Instead of re-parsing the entire XML archive every month, track Wikipedia page revision IDs (
rev_id). Execute lightweight delta fetches using the MediaWiki REST API (/page/v1/summary/{title}) to refresh updated pages in target categories. - CDN Cache Invalidation: Store fully rendered Wikipedia pages as static HTML or serialized JSON at the edge (Cloudflare Workers, Fastly). When page revisions update, issue cache purges for the specific article slug.
- Database Sharding: For domain instances exceeding 1,000,000 pages, shard PostgreSQL by
article_idhash ranges, using independent HNSW index instances to keep RAM usage within predictable bounds.
How BrickTry Helps You Build and Scale This
Architecting, parsing, and operating high-throughput, vector-enabled domain search platforms requires balancing database operations, background pipeline concurrency, and frontend performance. BrickTry provides the infrastructure and senior engineering pairing to take this from concept to production.
- Interactive Sandbox (
/lab): Instantly test streaming XML parsers, Wikitext tokenizers, and PostgreSQLpgvectorindexing scripts directly inside our browser sandbox runtime environment. Prototype vector search performance with real-time feedback and zero host setup. - AI-Human Dev Pairing: Accelerate development by combining autonomous AI agents that scaffold streaming ingestion scripts and schema migrations with dedicated Senior BrickTry Platform Engineers. Our architects review system topologies, optimize database indexing query strategies, and eliminate concurrency bottlenecks.
- Interactive Scoping Engine: Translate domain-specific requirements—such as custom ontology rules, fallback options, and embedding parameters—into executable technical roadmaps and automated infrastructure code.
- 100% Source Code & IP Ownership: Gain full ownership of your tailored parsing scripts, backend orchestration APIs, vector database configuration files, and deployment automation. Delivered directly to your private GitHub organization without platform lock-in.
Frequently Asked Questions
How do you prevent graph explosion during category traversal?
MediaWiki categories are a loose graph containing cyclic subgraphs and broad root nodes (e.g., Category:Main topic classifications). Traversal algorithms must execute a bounded Breadth-First Search (BFS) with a maximum depth constraint (typically $N \le 3$), while maintaining an explicit exclusion blocklist for high-fanout container categories such as Living people, Articles with short description, or Webarchive template wayback links.
How should Wikitext be cleaned and chunked for vector embeddings?
Raw Wikitext contains dense formatting noise (templates like {{cite web}}, infoboxes, tables, and HTML comments) that pollutes embedding quality. The pipeline should first parse Wikitext into an Abstract Syntax Tree (AST) using libraries such as mwparserfromhell or wikitext-parser, extract pure plaintext paragraphs, and chunk documents using a sliding window algorithm ($512\text{ tokens}$ per chunk with a $128\text{ token}$ overlap) to maintain semantic continuity across section boundaries.
What is the recommended strategy for updating vector indexes when source articles change?
For high-volume operations, re-indexing every article on update is inefficient. Compare incoming Wikitext content using MD5 content hashes against the existing stored payload. If the hash changes, execute an idempotent transactional update: delete existing chunks matching article_id, re-chunk the updated body text, re-generate embeddings asynchronously via a background task queue (e.g., BullMQ or Celery), and perform a bulk insert into article_chunks.
Build, Test, and Scale This on BrickTry
BrickTry pairs you with autonomous AI scaffolding supervised by dedicated senior full-stack software engineers in an interactive in-browser development sandbox (/lab). Test, build, and deploy production-grade software with 100% source code ownership and zero vendor lock-in.