WikifitaGitHub live67e8de5
outro · wikifita-site-architecture/wikifita-site-search-system

Search System Deep-Dive

BM25 search with PageRank boosting, title matching, and graph-proximity scoring: index construction, tokenization, scoring formula, and the BFS distance computation.

Baixar raw

Search System Deep-Dive

The Wikifita Atlas implements a custom search system that combines BM25 text relevance with PageRank authority, title matching, and graph-based proximity. The search index is built lazily from the wiki corpus and cached for the lifetime of the corpus instance.

All search logic lives in lib/wiki.ts, functions buildSearchIndex (lines 739-764) and searchWiki (lines 788-831).


Architecture Overview

flowchart TD
    A[WikiCorpus] -->|lazy build| B[SearchIndex]
    B --> C[documents: SearchDocument[]]
    B --> D[documentFrequency: Map]
    B --> E[averageLength: number]
    F[Query] -->|tokenize| G[queryTokens]
    G --> H{For each document}
    H --> I[BM25 Score]
    H --> J[Title Boost]
    H --> K[PageRank Boost]
    H --> L[Proximity Boost]
    I --> M[Weighted Sum]
    J --> M
    K --> M
    L --> M
    M --> N[Sorted Results]

Search Index Construction

The search index is a lazy singleton, cached via WeakMap<WikiCorpus, SearchIndex>. When searchWiki is first called for a corpus, the index is built. Subsequent calls reuse the cached index until a new corpus replaces the old one (the WeakMap key changes).

Document Assembly

For each page in the corpus, the searchable text is assembled by concatenating multiple fields:

searchableText = title + " " + description + " " + tags.join(" ") + " " + type + " " + category + " " + path + " " + body

This means a search for "memory" will match pages with:

  • "Memory" in the title
  • type: memory in frontmatter
  • category: memory metadata
  • memorias/ in the file path
  • The word "memory" in the body text

Tokenization

The tokenizer (tokenize, lines 722-724) performs four transformations:

1. Unicode NFD normalization
   "Café" -> "Café" (decomposed)

2. Strip diacritics (Unicode combining marks, category \p{M})
   "Café" -> "Cafe"

3. Lowercase (pt-BR locale)
   "Cafe" -> "cafe"

4. Extract tokens matching /[\p{L}\p{N}_-]+/gu
   "cafe-com-leite 2.0" -> ["cafe", "com", "leite", "2.0"]

The accent folding (steps 1-2) is critical for Portuguese content. The wikifita corpus contains extensive Portuguese text with diacritics (memória, diretiva, pesquisa). The tokenizer ensures that searching for "memoria" matches "memória" and vice versa.

The token pattern [\p{L}\p{N}_-]+ captures:

  • Unicode letters (including accented characters before folding)
  • Digits
  • Underscores and hyphens (common in filenames and technical terms)

Index Structure

type SearchIndex = {
  documents: SearchDocument[];           // One per page
  documentFrequency: Map<string, number>; // token -> number of docs containing it
  averageLength: number;                 // Mean document length in tokens
};

type SearchDocument = {
  page: WikiPage;                        // Reference to the source page
  tokens: string[];                      // All tokens in order
  frequencies: Map<string, number>;      // token -> count within this document
  length: number;                        // Total token count (min 1)
};

Document Frequency

After all documents are tokenized, the documentFrequency map is built by counting how many documents contain each unique token:

for each document in index:
    for each unique token in document.frequencies:
        documentFrequency[token] += 1

This map is used in the BM25 IDF calculation. A token that appears in many documents has low IDF (low discriminative power), while a token in few documents has high IDF.

Average Document Length

avgdl=1Ni=1Ndli\text{avgdl} = \frac{1}{N} \sum_{i=1}^{N} \text{dl}_i

Where dli\text{dl}_i is the token count of document ii (minimum 1 to avoid division by zero). The average length is used in BM25's document length normalization.


BM25 Scoring

BM25 (Best Match 25) is a probabilistic retrieval function that scores documents based on term frequency and inverse document frequency. The Wikifita implementation uses non-standard parameters tuned for wiki content.

Parameters

ParameterValueStandard BM25Effect
k11.351.2-2.0Term frequency saturation. Higher values increase the impact of repeated terms.
b0.720.75Document length normalization. Lower values reduce the penalty for long documents.

The b = 0.72 value is slightly below the standard 0.75, meaning long wiki pages are penalized less than they would be in standard BM25. This is deliberate: wiki pages naturally vary in length, and short stub pages should not rank artificially high just because they are short.

The k1 = 1.35 is slightly above the standard 1.2, giving more weight to terms that appear multiple times in a document. This helps surface pages that deeply cover a topic, not just mention it once.

BM25 Formula

For each query term qq and document dd:

BM25(q,d)=tqIDF(t)f(t,d)(k1+1)f(t,d)+k1(1b+bdlavgdl)\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot \left(1 - b + b \cdot \frac{\text{dl}}{\text{avgdl}}\right)}

Where:

  • f(t,d)f(t, d) = frequency of term tt in document dd
  • dl\text{dl} = document length (token count)
  • avgdl\text{avgdl} = average document length across the corpus
  • NN = total number of documents
  • df(t)\text{df}(t) = number of documents containing term tt

IDF Calculation

IDF(t)=ln(1+Ndf(t)+0.5df(t)+0.5)\text{IDF}(t) = \ln\left(1 + \frac{N - \text{df}(t) + 0.5}{\text{df}(t) + 0.5}\right)

This is the Robertson-Sparck Jones IDF variant with a +1 floor, ensuring all IDF values are positive. Rare terms (low df) get high IDF; common terms (high df) get low IDF.

Term appears inIDF (N=100)Interpretation
1 documentln(1+198/1.5)4.88\ln(1 + 198/1.5) \approx 4.88Very distinctive
10 documentsln(1+90.5/10.5)2.25\ln(1 + 90.5/10.5) \approx 2.25Moderately distinctive
50 documentsln(1+50.5/50.5)0.69\ln(1 + 50.5/50.5) \approx 0.69Common
100 documentsln(1+0.5/100.5)0.005\ln(1 + 0.5/100.5) \approx 0.005Near-zero (appears everywhere)

Term Frequency Saturation

The numerator f(t,d)(k1+1)f(t,d) \cdot (k_1 + 1) grows linearly with frequency, but the denominator f(t,d)+k1(1b+bdl/avgdl)f(t,d) + k_1 \cdot (1 - b + b \cdot \text{dl/avgdl}) also grows, causing the score to saturate. With k1=1.35k_1 = 1.35:

Term FrequencyRaw BM25 Contribution (normalized doc)
11×2.351+1.35=1.0\frac{1 \times 2.35}{1 + 1.35} = 1.0 (baseline)
22×2.352+1.35=1.40\frac{2 \times 2.35}{2 + 1.35} = 1.40
55×2.355+1.35=1.85\frac{5 \times 2.35}{5 + 1.35} = 1.85
1010×2.3510+1.35=2.07\frac{10 \times 2.35}{10 + 1.35} = 2.07
5050×2.3550+1.35=2.29\frac{50 \times 2.35}{50 + 1.35} = 2.29

The diminishing returns are clear: going from 1 to 2 occurrences gives a 40% boost, but going from 10 to 50 gives only 11%.


Score Composition

The final search score is a weighted sum of four components:

finalScore=bm25×1.8+titleBoost+pagerankBoost+proximityBoost\text{finalScore} = \text{bm25} \times 1.8 + \text{titleBoost} + \text{pagerankBoost} + \text{proximityBoost}

Component Breakdown

ComponentWeightRangeSource
BM25×1.8\times 1.80 to ~15 (depends on query)Text relevance
Title Boost+4.0, +1.5, or 0DiscreteExact or substring title match
PageRank Boost0 to +1.25ContinuousProportional to normalized PageRank wikifita-site-pagerank
Proximity Boost0 to +1.25Discrete (0, 0.20, 0.55, 0.90, 1.25)BFS graph distance wikifita-site-graph-construction

Title Boost

The title boost is a strong signal for navigational queries (when the user is searching for a specific page by name):

ConditionBoostExample
Normalized title equals normalized query+4.0Query "memory index" matches page titled "Memory Index"
Normalized title contains normalized query+1.5Query "memory" matches page titled "Memory Index"
Neither0Query "pagerank" does not match "Memory Index" title

Both comparisons use the same accent-folding tokenizer as the BM25 index: NFD normalization, diacritic stripping, lowercase.

PageRank Boost

pagerankBoost=pagerank(p)pagerankmax×1.25\text{pagerankBoost} = \frac{\text{pagerank}(p)}{\text{pagerank}_{\max}} \times 1.25

The highest-ranked page in the corpus gets a boost of exactly 1.25. All other pages get a proportionally smaller boost. This ensures authoritative, well-connected pages surface above obscure ones when text relevance is similar.

Proximity Boost

When a focusPath is provided (the user is searching from a specific document page), the system computes the BFS graph distance between the focus page and each candidate result.

See the dedicated section below for the full BFS algorithm.

proximityBoost=max(0, 1.25distance×0.35)\text{proximityBoost} = \max(0,\ 1.25 - \text{distance} \times 0.35)
Graph DistanceProximity BoostInterpretation
0 (same page)1.25Focus page itself
1 (direct link)0.90Directly linked from focus
2 (two hops)0.55Linked from a linked page
3 (three hops)0.20Distant connection
>3 or disconnected0.00No proximity signal

Graph Distance (BFS)

The proximity boost uses Breadth-First Search on an undirected version of the link graph to find the shortest path between two pages.

Graph Construction for BFS

The BFS graph treats the link graph as undirected: if page A links to page B, traversal can go in either direction. This is implemented by combining outlinks and backlinks:

function buildUndirectedAdjacency(corpus: WikiCorpus): Map<string, string[]> {
  const adj = new Map<string, string[]>();
  for (const page of corpus.pages) {
    // Add outlinks (forward direction)
    for (const target of page.outlinks) {
      addEdge(adj, page.path, target);
    }
    // Add backlinks (reverse direction)
    for (const source of page.backlinks) {
      addEdge(adj, page.path, source);
    }
  }
  return adj;
}

BFS Algorithm

function graphDistance(start: string, target: string, adjacency: Map<string, string[]>): number {
  if (start === target) return 0;

  const visited = new Set<string>([start]);
  const queue: [string, number][] = [[start, 0]];
  const maxDepth = 3;

  while (queue.length > 0) {
    const [current, depth] = queue.shift();
    if (depth >= maxDepth) continue;

    for (const neighbor of adjacency.get(current) || []) {
      if (neighbor === target) return depth + 1;
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push([neighbor, depth + 1]);
      }
    }
  }

  return Infinity; // Beyond max depth or disconnected
}

Properties

  • Max depth: 3 hops. The search does not explore beyond 3 hops, preventing expensive traversal on large graphs.
  • Undirected: A page that links to the focus page (backlink) is distance 1, just like a page linked from the focus page (outlink).
  • Shortest path: BFS guarantees the shortest path is found first.
  • Disconnected pages: Pages unreachable within 3 hops return Infinity, resulting in a proximity boost of 0.
  • Self-distance: The focus page itself has distance 0 and receives the maximum boost of 1.25.

Performance

For the Wikifita corpus (50-200 pages, max depth 3), BFS explores at most O(k3)O(k^3) nodes where kk is the average degree. With k4k \approx 4 and N100N \approx 100, this is roughly 43=644^3 = 64 nodes per search -- negligible.

The BFS runs once per candidate result. For a typical search returning 10-20 results, the total BFS cost is 10-20 traversals of at most 64 nodes each.


Query Processing

Tokenization

The query string is tokenized using the same function as document indexing:

1. Unicode NFD normalization
2. Strip diacritics
3. Lowercase (pt-BR locale)
4. Extract tokens: /[\p{L}\p{N}_-]+/gu

This ensures query tokens match document tokens regardless of accent differences.

Multi-Term Queries

For multi-term queries, BM25 sums the per-term scores. Each query term is scored independently against each document, and the contributions are additive:

BM25(Q,d)=tQBM25t(t,d)\text{BM25}(Q, d) = \sum_{t \in Q} \text{BM25}_t(t, d)

The title boost checks if the full normalized query matches the title (not individual terms). So a query "unit distance" gets +4.0 only if the title is exactly "Unit Distance" (after normalization), not if it contains "unit" or "distance" separately.


Result Ranking

Results are sorted by a three-level comparison:

1. score DESC        (primary: composite score)
2. pagerank DESC     (first tiebreaker: page authority)
3. path ASC          (final tiebreaker: alphabetical for determinism)

Result Object

type WikiSearchResult = {
  path: string;           // Page file path
  score: number;          // Final composite score
  bm25: number;           // Raw BM25 score (before weight)
  titleBoost: number;     // 0, 1.5, or 4.0
  pagerankBoost: number;  // 0 to 1.25
  proximityBoost: number; // 0 to 1.25
  explanation: string;    // Human-readable score breakdown
};

The explanation field provides a human-readable breakdown of how the score was computed, useful for debugging and transparency.


Suppression Rules

Certain pages are excluded from search suggestions (but not from the search index itself):

Suppressed FilenameReason
claude (any extension)Internal agent configuration
agents (any extension)Internal agent configuration
index (any extension)Index pages are navigation, not content
*prefill* (any part of filename)Template content
*template* (any part of filename)Template content

Suppressed pages can still appear in direct search results but are excluded from the top-3 suggestions on the home page and sidebar.


Search API

The search API is exposed at:

GET /api/search?q=<query>&limit=<1-50>&focus=<path>

Parameters

ParameterTypeDefaultDescription
qstringrequiredSearch query
limitnumber10Maximum results (1-50)
focusstringoptionalPage path for proximity boosting

Response

{
  "query": "pagerank algorithm",
  "limit": 10,
  "source": {
    "repository": "aleffita/wikifita",
    "branch": "main",
    "commit": "abc123",
    "state": "live"
  },
  "results": [
    {
      "path": "wiki/pagerank.md",
      "score": 12.45,
      "bm25": 5.2,
      "titleBoost": 4.0,
      "pagerankBoost": 0.89,
      "proximityBoost": 0.90,
      "explanation": "bm25=5.20*1.8=9.36 title=4.00 pr=0.89 prox=0.90"
    }
  ]
}

Response Headers

HeaderDescription
Content-Typeapplication/json
ETagDeterministic hash for cache validation
X-Wikifita-Sourcegithub or snapshot
X-Wikifita-CommitCurrent commit SHA
Cache-Controlno-store

Scoring Example

Consider a wiki with 50 pages. A user searches for "memoria persistente" from the page memorias/MEMORY.md.

Query Tokenization

"memoria persistente" -> ["memoria", "persistente"]

Candidate: memorias/MEMORY.md (Memory Index)

ComponentCalculationValue
BM25"memoria" appears 5 times, "persistente" appears 3 times8.5
BM25 weighted8.5 * 1.815.3
Title boost"Memory Index" contains "memoria" after normalization+1.5
PageRank boostHighest-ranked page, normalized rank = 0.72+0.90
Proximity boostDistance 0 (same page)+1.25
Total18.95

Candidate: wiki/log.md (Changelog)

ComponentCalculationValue
BM25"memoria" appears 1 time, "persistente" appears 0 times1.2
BM25 weighted1.2 * 1.82.16
Title boost"Changelog" does not contain "memoria"0
PageRank boostMedium rank, normalized = 0.35+0.44
Proximity boostDistance 1 (linked from MEMORY.md)+0.90
Total3.50

The Memory Index page wins decisively due to high BM25, title match, and zero-distance proximity.


Index Lifecycle

flowchart TD
    A[New WikiCorpus] -->|first searchWiki call| B[buildSearchIndex]
    B --> C[Tokenize all pages]
    C --> D[Compute document frequencies]
    D --> E[Compute average document length]
    E --> F[Cache via WeakMap]
    F --> G[SearchIndex ready]
    G -->|searchWiki called| H[Tokenize query]
    H --> I[Score all documents]
    I --> J[Sort results]
    J --> K[Apply suppression]
    K --> L[Return WikiSearchResult[]]

    M[New corpus instance] -->|WeakMap key changes| N[Old index eligible for GC]
    N --> A

The WeakMap caching means:

  • The index is built once per corpus instance
  • When the corpus is replaced (new commit SHA, new snapshot), a new index is built
  • Old indices are garbage-collected when no references to the old corpus remain

Limitations

  1. No fuzzy matching: The BM25 index requires exact token matches after accent folding. Typos are not tolerated.
  2. No phrase queries: Multi-word queries are treated as independent terms. The phrase "unit distance" is scored as the sum of "unit" and "distance" individually.
  3. No stemming: "pesquisando" and "pesquisa" are different tokens. Portuguese stemming is not applied.
  4. No field boosting at query time: The title, description, and body are concatenated into a single searchable text. Field-level boosting (e.g., title matches count 2x) happens only through the explicit title boost component.
  5. Proximity requires focus: The proximity boost only activates when a focusPath is provided (searching from a document page). Home page and standalone search have no proximity signal.

Related Pages