Article · 2026-02-15

Zero-Backend Hybrid Search: Running BM25 + Semantic Search in the Browser

Static blogs face a search paradox: no backend means no Elasticsearch, no database, no full-text API. Most sites accept this tradeoff by integrating third-party services like Algolia—or abandon search altogether.

This site does neither. The entire search engine lives in the browser: BM25 keyword retrieval, semantic expansion, cross-lingual Chinese-English support, all with zero backend dependencies.

Architecture Overview

The system has two halves: build time (Node.js) and runtime (browser).

Build Time (node)                        Runtime (browser)
┌──────────────────┐                    ┌───────────────────────────┐
│ Markdown/Notebook │                    │ P0: Inverted index + meta │ → Instant keyword search
│ Photo albums + AI │  build.sh          │ P1: Keyword vectors (KVEC)│ → Semantic expansion ready
│ ─────────────────→│ ──────────→        │ P2: ONNX model            │ → Full semantic search
│ index-builder     │                    └───────────────────────────┘
│ vector-builder    │                          ↓
│ image-tagger      │                    BM25 + Semantic → Composite Score Fusion
└──────────────────┘

Design principle: progressive loading. Users search with keywords the instant the page loads (P0). Semantic search loads silently in the background—it enhances results but is never required.

Build Time: From Content to Index

Tokenizer: Chinese Unigram + Bigram

English text splits naturally on whitespace. Chinese has no delimiters, and adding a segmentation library like jieba means build and runtime dependencies.

The implementation uses Chinese character unigrams + bigrams.

function tokenize(text) {
    const tokens = text.toLowerCase().match(/[\u4e00-\u9fff]+|[a-z0-9]+/g) || [];
    const result = [];
    for (const token of tokens) {
        if (/[\u4e00-\u9fff]/.test(token)) {
            // Chinese: each character + adjacent pairs
            for (let i = 0; i < token.length; i++) {
                result.push(token[i]);
                if (i < token.length - 1)
                    result.push(token.slice(i, i + 2));
            }
        } else if (token.length >= 2) {
            result.push(token);
        }
    }
    return result.filter(t => !STOPWORDS.has(t)
        && (t.length >= 2 || /[\u4e00-\u9fff]/.test(t)));
}

Example: "搜索引擎" → ["搜", "搜索", "索", "索引", "引", "引擎", "擎"]

This approach yields:

The tradeoff is lower precision ("索引" and "引擎" both match on "引"), but BM25's IDF weighting suppresses high-frequency generic tokens.

Inverted Index: Compact v2 Format

The index builder scans all Markdown posts, Jupyter notebooks, and photo albums, producing three files:

File Content Size
search-inverted.json Compact inverted index ~2.1 MB
search-metadata.json Article metadata (title, date, excerpt) ~117 KB
search-vocab.json Vocabulary statistics ~2.4 MB

The inverted index uses a compact v2 format, replacing URL strings with numeric IDs:

{
  "v": 2,
  "docs": ["/blog/posts/2026/...", "/gallery/20210711-Chengdu Panda zoo/", ...],
  "avgDL": 250.5,
  "N": 282,
  "dl": [245, 268, ...],
  "idx": {
    "search": [[0, 5], [3, 2], [12, 1]],
    "cathedral": [[42, 3], [43, 2]]
  }
}

[[docNum, tf], ...] replaces [{id: "url", tf: 5}, ...], cutting JSON size by roughly 50%.

Document Chunking

Long articles are split into ~500-character chunks with 50-character overlap, preferring sentence endings and line breaks. The final index aggregates at the article level—all chunks from one article merge their term frequencies—so BM25 scores reflect whole-article relevance.

Photo Albums in the Index

Each photo album receives AI-generated bilingual tags, then enters the same inverted index as articles:

// Index text = location + description + English tags + Chinese tags + year
const text = [album.location, description, tagsEn, tagsZh, year].join(' ');

// Tags field (for title/tag weight boost)
const tags = `photo gallery ${album.location} ${tagsEn} ${tagsZh}`;

Searching "熊猫" (panda) directly hits the Chengdu Panda Zoo album; "museum" finds museum photo galleries.

Build Time: Keyword Vectors

Why Pre-Compute Vectors?

Real-time embedding of hundreds of documents in a browser is too slow. Instead: embed the vocabulary, not the documents.

At build time, filter the ~70,000 index terms down to 8,000 most valuable ones and precompute their embeddings. At search time, embed the query once, then dot-product against 8,000 precomputed vectors—pure CPU arithmetic, ~50ms.

Vocabulary Filtering Strategy

70,000 terms cannot all be vectorized. The filtering strategy:

score = 0
if (appears in title/tags) score += 100    // curated terms, highest priority
score += min(df, 50) × 2                   // wider coverage = more matching value
score += min(maxTf, 20)                    // high TF in some docs = meaningful
if (chinese && length >= 2) score += 10    // Chinese two-char words are meaningful
if (english && length >= 4) score += 5     // longer English words more distinctive

Top 8,000 by score enter the index.

Chinese bigrams are filtered by default (most are meaningless pairs like "景的"), but bigrams appearing in titles or tags are preserved—curated vocabulary like "教堂" (cathedral) and "熊猫" (panda) from photo tags.

multilingual-e5-small: Cross-Lingual Semantics

The system originally used BGE-small-zh-v1.5 (512-dim, Chinese-only). Within Chinese it worked well; cross-lingually it failed entirely:

BGE-small-zh:
  cosine("教堂", "cathedral") = 0.33  ← far below threshold
  cosine("利物浦", "liverpool") = 0.28 ← nearly orthogonal

Switching to multilingual-e5-small (384-dim, 100+ languages):

multilingual-e5-small:
  cosine("埃及", "egypt")       = 0.917  ✓
  cosine("展览", "exhibition")  = 0.897  ✓
  cosine("雕像", "sculpture")   = 0.879  ✓
  cosine("博物", "museum")      = 0.838  ✓
  cosine("熊猫", "panda")       = 0.830  ✓

An important e5 convention: queries need a "query: " prefix, while corpus terms don't. Build-time vocabulary embeddings have no prefix; browser search adds it.

Int8 Quantization and KVEC Binary Format

384 dims × 8,000 terms × 4 bytes = 12.3 MB—too large for browsers.

Int8 quantization: e5 outputs are L2-normalized (range [-1, 1]). Directly scale by 127:

quantized = clamp(round(float × 127), -128, 127)

Precision loss < 0.5%; storage compressed 4×: 12.3 MB → 3.1 MB, ~1.8 MB gzipped.

Binary format (KVEC):

[4B magic "KVEC"]
[4B vocab_size uint32]
[4B dims uint32]
[vocab_size × dims bytes: Int8 vectors, row-major]
[remaining bytes: JSON term array, UTF-8]

Embedding Cache

Computing 8,000 embeddings takes several minutes. The vector builder maintains a JSON cache file. Each build only computes new or changed terms, reusing cached results. The cache is pruned after each build to retain only terms in the current vocabulary.

Build Time: AI Image Auto-Tagging

Search Beyond Metadata

Photo metadata typically contains English location names and dates only. Searching "大熊猫" in Chinese won't find "Chengdu Panda zoo"; "教堂" won't find "Liverpool Metropolitan Cathedral".

The solution: use multimodal AI at build time to analyze representative album images and generate bilingual tags.

AI Fallback Chain

// Priority: Gemini CLI → OpenAI API → Claude CLI
if (hasGemini()) tryGemini(image);     // local CLI, fastest
if (hasOpenAI()) tryOpenAI(image);     // cloud API, most reliable
if (hasClaude()) tryClaude(image);     // common in dev environments

The prompt requests pure JSON output:

Analyze this photo. Output ONLY a JSON object:
- "en": 5-10 English keyword tags
- "zh": 5-10 Chinese keyword tags

Example: {"en":["cathedral","gothic architecture"], "zh":["教堂","哥特式建筑"]}

Tag Quality

The AI generates culturally appropriate tags, not mechanical translations:

// Chengdu Panda Zoo
{"en": ["pandas", "bamboo", "zoo", "wildlife", "natural habitat"],
 "zh": ["熊猫", "竹子", "动物园", "野生动物", "自然栖息地"]}

// Shanghai Museum Egypt Exhibition
{"en": ["exhibit", "ancient artifact", "Egyptian history", "museum"],
 "zh": ["展览", "古代文物", "埃及历史", "博物馆"]}

Idempotency and Caching

The script checks whether tags.json already exists in each album directory. If present, it skips processing. This ensures:

Runtime: Browser-Side Search

Progressive Loading

The key to user experience is never forcing a wait:

Phase Loaded Latency Capability
P0 Inverted index + metadata < 100ms Full keyword search
P1 Keyword vectors (1.8MB) 200-500ms Semantic expansion ready
P2 ONNX model (~20MB) 2-20s Full semantic search

P0 is usable immediately. While users type, P1/P2 load in the background. If the model isn't ready when searching, keyword results display first; semantic results merge in dynamically when available.

BM25 Keyword Search

Standard BM25 with k1=1.2, b=0.75:

score(q, d) = Σ IDF(t) × (tf × (k1+1)) / (tf + k1 × (1-b + b × |d|/avgDL))

When exact matches return nothing, the system attempts prefix matching ("water" → "waterfall", "watermelon") at 0.8× score.

Semantic Expansion: Query Augmentation, Not Re-Ranking

Semantic search doesn't re-rank BM25 results—it discovers related terms and runs a second BM25 retrieval round with them.

Flow:

  1. Embed query text with e5 model (with "query: " prefix)
  2. Compute cosine similarity against 8,000 precomputed vocabulary vectors
  3. Take top 8 terms above 0.82 similarity as expansion terms
  4. Run BM25 with expansion terms, but without TF—each term's document contribution is weighted by semantic similarity

Why no TF? Semantic expansion finds related topics, not exact matches. An article mentioning "waterfall" once is equally relevant to the expansion term as one mentioning it ten times.

Composite Score Fusion

Final ranking fuses both paths:

finalScore = 0.6 × normalize(BM25_score) + 0.4 × normalize(semantic_score)

Two bonus factors apply:

Each score set normalizes independently (max → 1.0) to prevent either path from dominating.

Search Results UI

Results render in two sections:

Each result displays its source: blue "keyword" badge for keyword hits, pink "AI" badge for semantic expansion. Both badges appear if the result came from both paths.

After semantic search completes, expansion terms appear (e.g., "Related: cathedral, church, gothic"), showing users what the engine associated with their query.

Service Worker: Model Caching

ONNX model files (~20MB) use a cache-first strategy via Service Worker:

const MODEL_PATTERNS = ['/onnx/', 'multilingual-e5', '.onnx', 'tokenizer', '/public/models/'];

// Intercept: cache-first
if (isModelFile(url)) {
    const cached = await caches.match(request);
    if (cached) return cached;                 // Cache hit
    const response = await fetch(request);
    cache.put(request, response.clone());       // Cache on first load
    return response;
}

After the first visit, all subsequent loads read locally. Semantic search works offline (provided the model was loaded previously).

Performance

Measured on this site (282 articles + 137 photo albums, 71,332 index terms):

Metric Value
P0 keyword search latency < 10ms
P1 vector loading ~300ms (1.8MB gzip)
P2 model first load 5-15s (network dependent)
P2 model cached load < 2s
Semantic expansion ~50ms (8,000 dot products)
Inverted index size 2.1 MB (gzip ~400KB)
Keyword vectors size 3.1 MB (gzip ~1.8MB)
Result rendering < 5ms

Search Examples

Cross-Lingual Retrieval

Query Keyword Path Semantic Path Result
"熊猫" Chinese tag hit → Chengdu Panda Zoo Expands to "panda", "zoo" Photos + articles
"cathedral" English location hit → Liverpool Cathedral Photo albums
"教堂" No direct hit Expands to "cathedral", "church" Finds cathedral photos
"museum" Hits multiple museum albums Expands to "exhibit", "artifact" Photos + articles

Single-Character Search

Single-character Chinese queries like "树" (tree) and "花" (flower) work—the tokenizer preserves meaningful Chinese characters while filtering English single letters and stopwords.

Build Pipeline

The complete build process:

1.  HEIC → JPG conversion (photo format normalization)
2.  Photo compression
3.  Album description generation (text AI)
4.  Image tag generation (multimodal AI, new)
5.  Office/LaTeX document conversion
6.  posts.json (blog article index)
7.  gallery.json (photo album index, with bilingual tags)
8.  videos.json (video index)
9.  Search inverted index build
10. Keyword vector build
11. Static HTML generation

Image tagging and vector building both cache incrementally—only new content is processed.

Design vs. Implementation

This system was built from a detailed architecture design document. Comparing the original design against the final implementation reveals both strict adherence and deliberate deviations.

Core Design Preserved

The original design specified: BM25 as the foundation, semantic expansion as a graceful enhancement layer, progressive loading (P0/P1/P2), pre-computed vocabulary vectors rather than real-time document embeddings, word-level semantic routing, dual-path score fusion with visible expansion terms, Service Worker caching, and photo albums in the unified index.

All of these landed exactly as planned. The implementation closely followed the architectural blueprint.

Intentional Deviations

1. multilingual-e5-small instead of BGE-small-zh-v1.5

BGE (512-dim, Chinese-only) was specified in the original design. Both are small and fast, but BGE's cross-lingual similarity was unusable—"教堂" vs "cathedral" scored only 0.33. e5 reaches 0.83+ on the same pair. For a bilingual blog, cross-lingual capability is non-negotiable. The tradeoff: e5 requires a "query: " prefix convention that must stay consistent between build and runtime.

2. LLM APIs instead of CLIP for image tagging

The original design used CLIP with predefined candidate tags and cosine matching. The implementation uses Gemini/OpenAI/Claude multimodal APIs to generate free-text bilingual tags. AI generates culturally appropriate tags (e.g., "自然栖息地" for panda habitat photos), which CLIP couldn't produce from a static pool. The API dependency is build-time only, with caching and idempotency.

3. Unigram + bigram instead of jieba

The original design used jieba/nodejieba for offline segmentation with known consistency risks against FlexSearch's CJK mode at runtime. The implementation uses character-level unigrams + bigrams in both build and runtime, completely eliminating segmentation consistency problems. Precision is slightly lower, but BM25 IDF naturally suppresses noise.

4. Unified score-based vocabulary filtering instead of DF thresholds

The original design used layered filtering (title terms unconditional, DF=1 and TF≤2 filtered, DF>80% filtered). The implementation uses a unified scoring function (title +100, DF/TF weighted, length bonus) and takes the top 8,000. More flexible and easier to tune.

5. Similarity threshold 0.82 instead of 0.55

The original design suggested ≥0.55 with an 8-term expansion cap. The implementation uses 0.82. e5's similarity distribution runs higher than BGE's—"熊猫" vs "panda" already scores 0.83. The higher threshold maintains precision; edge cases with fewer expansion terms are worth monitoring.

6. No TF in semantic path

Not explicitly addressed in the original design. The implementation weights semantic expansion results by similarity score only, ignoring term frequency. Semantic expansion finds related topics, not exact matches—mentioning "waterfall" once is equally relevant to the expansion term as mentioning it ten times.

Remaining Opportunities

1. SoA (Struct-of-Arrays) memory layout — The original design emphasized SoA for cache-line optimization. The current KVEC format uses row-major (AoS) layout: each term's 384 bytes are stored contiguously. At 8,000 terms (~3MB), the entire dataset fits in L3 cache, so the impact is negligible. Beyond 40,000 terms, SoA would provide measurable benefits.

2. Interactive expansion term removal — The original design specified that users could click to remove individual expansion terms, triggering a re-search. The current implementation displays expansion terms as static badges. This is a straightforward UX addition: add click handlers that remove terms from the expansion list and re-run fusion scoring without re-computing embeddings.

Conclusion

Purely static sites can deliver search experiences rivaling dynamic services—if the architecture invests at build time rather than asking the browser to do the heavy lifting.

The core decisions that made this work:

The maintenance cost is near zero—no server, no database, no search service bills. Every git push updates the entire search engine.

© 2026 Yuxu Ge ·