Full-Text Search / BM25 / Bleve

Search Engineering intermediate Building with AI 12 min read

Overview

Full-text search is the foundational retrieval paradigm that has powered search engines for decades. Unlike semantic search, which operates on meaning, full-text search works on vocabulary overlap—it finds documents that contain the exact words (or stems of words) present in the query. The dominant scoring algorithm is BM25 (Best Match 25), which ranks documents by how frequently and uniquely query terms appear in them.

In AI engineering, full-text search remains essential because semantic search alone has significant blind spots. Embedding models struggle with exact identifiers like product codes (SKU-4829-X), serial numbers, acronyms, and proper nouns that have no inherent semantic content. BM25 excels at these exact-match scenarios because it operates on literal token overlap. This is why production RAG systems almost always combine semantic and full-text search in a hybrid approach.

Bleve is a full-featured, open-source search and indexing library written in Go that implements BM25 scoring along with rich text analysis capabilities. It’s particularly relevant because it’s the search engine embedded in systems like Couchbase and is designed for embedding directly into Go applications—a common pattern in AI infrastructure built with Go backends.

How It Works

The Inverted Index

Full-text search is built on the inverted index—a data structure that maps every unique term to the list of documents containing it. When you index a document, the system tokenizes the text, applies analyzers (lowercasing, stemming, stop word removal), and stores the resulting terms in the index with positional information.

// Conceptual inverted index structure
"deployment" → [doc_1 (pos: 5, 42), doc_7 (pos: 12), doc_15 (pos: 3, 88)]
"kubernetes" → [doc_1 (pos: 8), doc_3 (pos: 1, 22), doc_7 (pos: 15)]
"pipeline"   → [doc_1 (pos: 6), doc_15 (pos: 4)]

At query time, the system looks up each query term in the inverted index, finds the matching documents, and then scores them using BM25.

The BM25 Algorithm

BM25 scores a document against a query by combining three factors for each query term:

BM25 Scoring Formula

score(D, Q) = Σ IDF(qi) × [ f(qi, D) × (k1 + 1) ] / [ f(qi, D) + k1 × (1 - b + b × |D| / avgdl) ]

Where f(qi, D) is term frequency of query term qi in document D, |D| is document length, avgdl is average document length, k1 controls term frequency saturation (default: 1.2), and b controls length normalization (default: 0.75).

Component 1: Inverse Document Frequency (IDF)

IDF measures how rare a term is across the entire corpus. Common words like “the” or “is” appear in nearly every document and receive a very low IDF score. Rare, distinctive terms like “Kubernetes” or “autosharding” receive high IDF scores. This ensures that matching on a distinctive term contributes more to the relevance score than matching on a ubiquitous one.

The formula is: IDF(q) = ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1), where N is the total number of documents and n(q) is the number of documents containing term q.

Component 2: Term Frequency with Saturation

Term frequency (TF) counts how often a query term appears in a document. But raw TF is misleading—a document mentioning “database” 100 times isn’t 100x more relevant than one mentioning it once. BM25 applies a saturation curve controlled by parameter k1: as TF increases, the score contribution grows rapidly at first, then flattens out. With the default k1 = 1.2, the score is already at ~70% of its maximum after just 3 occurrences.

Component 3: Document Length Normalization

Longer documents naturally contain more term occurrences, which would unfairly boost their scores. BM25 normalizes by comparing a document’s length to the average length across the corpus. The parameter b (default: 0.75) controls how aggressively this normalization is applied. At b = 0, there’s no length normalization; at b = 1, the penalty for being longer than average is fully applied.

Text Analysis Pipeline

Before terms enter the index, they pass through an analysis pipeline:

  1. Tokenization: Breaking text into individual tokens (words). Handles punctuation, special characters, and language-specific rules.
  2. Lowercasing: Converting all tokens to lowercase so “Kubernetes” matches “kubernetes”.
  3. Stop word removal: Filtering out high-frequency, low-information words like “the”, “is”, “and”.
  4. Stemming: Reducing words to their root form. “running”, “runs”, “ran” all become “run”. Porter stemming is the most common algorithm for English.

Bleve: Full-Text Search in Go

Bleve is a pure-Go search library that provides BM25 scoring, rich text analysis, and multiple index backends. It’s designed to be embedded directly into Go applications without requiring an external search service.

// Bleve: create index, add document, and search in Go
package main

import "github.com/blevesearch/bleve/v2"

func main() {
    // Create a new index
    mapping := bleve.NewIndexMapping()
    index, _ := bleve.New("myindex.bleve", mapping)

    // Index a document
    doc := map[string]interface{}{
        "title": "CI/CD Troubleshooting Guide",
        "body":  "Common deployment pipeline failures and fixes...",
    }
    index.Index("doc-1", doc)

    // Search
    query := bleve.NewMatchQuery("deployment pipeline")
    req := bleve.NewSearchRequest(query)
    results, _ := index.Search(req)
    // results.Hits contains ranked matches
}

Key Bleve capabilities include:

Practical Applications

Hybrid RAG Retrieval

In production RAG systems, BM25 runs alongside semantic search. The query “error code E-4829 in deployment” benefits from BM25 finding exact matches on “E-4829” while semantic search finds conceptually related troubleshooting documents. Results from both systems are merged using fusion algorithms (RRF or RSF) for final ranking.

Exact Identifier Matching

Support systems, inventory databases, and technical documentation are full of identifiers—product SKUs, error codes, version numbers, API endpoint paths. BM25 handles these perfectly because it operates on literal token matching. Searching for v2.14.3-beta returns exactly the documents containing that string.

Embedded Search in Go Services

Bleve is commonly used in Go-based microservices that need search capabilities without the operational overhead of running Elasticsearch or Solr. A document management service can embed Bleve directly, using the local filesystem for index storage and avoiding network latency for search queries.

When BM25 Falls Short

Key Takeaways

Further Reading