← All courses ← Course home

Lesson 03 of 05

Use BM25 for exact words

BM25 rewards chunks that contain the query words, especially rare words. Apple does not ship a BM25 type. You write a small scorer.

Agent brief (llms.md)

Word search still wins for names, invoice numbers, and error codes. Dense search misses those when the embedder smooths them away. BM25 is the lexical lane.

There is no BM25Index in Foundation Models. Implement a compact version: tokenize to lowercase words, store document frequency, and score on query.

QueryUser words
ScoreYour BM25
Top kLexical hits
struct BM25Index {
    var chunks: [NoteChunk]
    var df: [String: Int] = [:]
    var lengths: [Int] = []
    var avgLength: Double = 1
    let k1 = 1.5
    let b = 0.75

    static func tokenize(_ text: String) -> [String] {
        text.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init)
    }

    mutating func build(_ chunks: [NoteChunk]) {
        self.chunks = chunks
        df = [:]
        lengths = chunks.map { Self.tokenize($0.text).count }
        avgLength = Double(lengths.reduce(0, +)) / Double(max(lengths.count, 1))
        for chunk in chunks {
            for word in Set(Self.tokenize(chunk.text)) {
                df[word, default: 0] += 1
            }
        }
    }

    func score(query: String, limit: Int = 8) -> [(NoteChunk, Double)] {
        let terms = Self.tokenize(query)
        let n = Double(chunks.count)
        return chunks.enumerated().map { i, chunk in
            let tfMap = Dictionary(Self.tokenize(chunk.text).map { ($0, 1) }, uniquingKeysWith: +)
            let len = Double(lengths[i])
            let s = terms.reduce(0.0) { acc, term in
                let tf = Double(tfMap[term] ?? 0)
                let dfi = Double(df[term] ?? 0)
                let idf = log((n - dfi + 0.5) / (dfi + 0.5) + 1)
                let denom = tf + k1 * (1 - b + b * len / avgLength)
                return acc + idf * (tf * (k1 + 1)) / max(denom, 0.001)
            }
            return (chunk, s)
        }
        .filter { $0.1 > 0 }
        .sorted { $0.1 > $1.1 }
        .prefix(limit)
        .map { $0 }
    }
}

Query-time scoring stays in Swift on the phone. Building document frequency for a large corpus is easier on a Mac. Write the stats once in Python, then load them in the app. There is still no Apple BM25Index type.

import json
from collections import Counter
from pathlib import Path

def tokenize(text):
    buf = []
    word = []
    for ch in text.lower():
        if ch.isalnum():
            word.append(ch)
        elif word:
            buf.append("".join(word))
            word = []
    if word:
        buf.append("".join(word))
    return buf

chunks = [json.loads(line) for line in Path("artifacts/chunks.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
df = Counter()
lengths = []
for chunk in chunks:
    tokens = tokenize(chunk["text"])
    lengths.append(len(tokens))
    df.update(set(tokens))
avg_len = sum(lengths) / max(len(lengths), 1)
index = {
    "k1": 1.5,
    "b": 0.75,
    "avgLength": avg_len,
    "df": dict(df),
    "lengths": lengths,
    "n": len(chunks),
}
Path("artifacts/bm25-index.json").write_text(json.dumps(index), encoding="utf-8")
print("docs", index["n"], "avgLength", round(avg_len, 1))

This is enough for a few thousand chunks. If you later need Spotlight's lexical engine, donate the same text and let Path A run. Do not rename that engine BM25.

Unit test. A chunk that contains a unique invoice id must rank first for that id. If it does not, fix tokenize before you add dense search.

Next, add meaning for questions that do not share words.

Key concepts

  • There is no Apple BM25Index type. You write a compact scorer.
  • BM25 wins for names, invoice numbers, and error codes.
  • Build document frequency on a Mac if you want. Query-time scoring stays Swift on the phone.
  • Spotlight's lexical engine is not BM25. Do not rename it.

Takeaways

  • Unit test that a unique invoice id ranks first.
  • Fix tokenize before you add dense search.
  • Do not claim Apple shipped BM25.