blaze

repository·main·Indexed 19 days ago

https://github.com/0xnyn/blaze

A lightweight, educational full-text search engine written in Go. It provides core search functionalities including inverted indexing, BM25 ranking, boolean queries (AND, OR, NOT), and proximity search. The engine features a text analysis pipeline with tokenization, Snowball (Porter2) stemming, and stopword filtering, utilizing Skip Lists and Roaring Bitmaps for efficient execution.

Tokens
18K
Snippets
67
Records
78
Agent score
68%

What's inside blaze

  1. Overview of Blaze

    main

    Blaze is a lightweight, hackable full-text search engine written in Go. It is designed for keyword-based search using an inverted index, rather than semantic vector search. It is ideal for developers who want to understand or implement features like BM25 ranking, boolean queries, and proximity search without the complexity of a hyperscale engine.

    Key Capabilities:

    • Search: Term search, phrase search, boolean queries (AND, OR, NOT), and proximity ranking.
    • Ranking: BM25 relevance scoring (industry standard) and proximity-based scoring.
    • Text Processing: Tokenization, Snowball (Porter2) stemming for English, stopword filtering, and case normalization.
    • Data Structures: Uses Skip Lists for $O(\log n)$ operations and Roaring Bitmaps for fast boolean query execution.

    Note on Semantic Search: Blaze is focused on keyword-based search. For vector embeddings and semantic retrieval, use Comet. For production-grade, battle-tested full-text search, consider Bleve.

  2. How to choose between BM25 and Proximity ranking

    main

    Blaze provides two primary ranking algorithms. Choosing the right one depends on your search goals:

    • Use RankBM25 when: You need industry-standard relevance. It weights terms based on frequency and rarity (TF-IDF style) and performs automatic length normalization. It is recommended for most general use cases like broad topic searches or e-commerce product discovery.
    • Use RankProximity when: You need to find terms that appear close together. This is ideal for finding specific phrases, co-occurrences, or when you need position data for snippet generation.

    Best Practice: Use both. Use RankBM25 to cast a wide net for general relevance, and RankProximity to refine results for precise phrase matching.

    // E-commerce: General product search
    bm25Results := idx.RankBM25("wireless bluetooth headphones", 20)
    
    // E-commerce: Exact product name
    proxResults := idx.RankProximity("Sony WH-1000XM4", 20)
    
    // Document search: Research papers
    // BM25 for broad topic search
    papers := idx.RankBM25("neural networks deep learning", 50)
    
    // Document search: Finding specific phrase mentions
    mentions := idx.RankProximity("neural networks", 50)
    
    // Best practice: Use both for different purposes!
    generalResults := idx.RankBM25(query, 100)    // Cast wide net
    preciseResults := idx.RankProximity(query, 20) // Refine results
  3. How the Query Builder executes complex queries

    main

    The QueryBuilder uses an operand stack and an operator stack to evaluate logical expressions. It supports grouping via functional closures to handle nested boolean logic.

    Execution Flow Example: For a query like NewQueryBuilder(idx).Group(func(q) { q.Term("machine").Or().Term("deep") }).And().Term("learning").Execute():

    1. Group Execution: The sub-builder executes machine OR deep. It looks up the bitmaps for both terms, performs a Union operation, and pushes the resulting bitmap onto the parent stack.
    2. Logical Continuation: The builder then processes the And() operator and the learning term.
    3. Final Execution: The Execute() method pops the bitmaps from the stack, performs an Intersection (AND), and returns the final filtered bitmap.
    // Conceptual usage of the QueryBuilder pattern
    query := NewQueryBuilder(idx).
        Group(func(q *SubQueryBuilder) {
            q.Term("machine").Or().Term("deep")
        }).
        And().
        Term("learning").
        Execute()
  4. Understand the Blaze Query Processor Architecture

    main

    The Blaze query processor follows a multi-stage pipeline to transform a user query into ranked results:

    1. Text Analyzer: Tokenizes, stems, and processes the raw input string into individual terms.
    2. Query Builder: Constructs a query tree representing the logical structure (e.g., AND, OR, NOT).
    3. Execution Phases:
      • Bitmap Phase: Performs high-speed boolean operations (AND, OR, NOT) using Roaring Bitmaps to filter candidate documents.
      • Position Phase: (Optional) If the query requires phrase or proximity matching, Blaze uses Skip Lists to verify exact word positions.
      • Ranking Phase: Applies the BM25 scoring algorithm to rank the remaining candidate documents based on term frequency and inverse document frequency (IDF).
  5. How to use the Query Builder effectively

    main

    To get the best results and maintainable code with Blaze, follow these best practices:

    1. Use Groups for Complex Logic: Always use .Group() when mixing And() and Or() to ensure correct precedence.
    2. Leverage Shorthands: Use AllOf, AnyOf, and TermExcluding for simple queries instead of the full builder.
    3. Use BM25 for Users: Use .ExecuteWithBM25(n) for user-facing search features to provide ranked relevance; use .Execute() for raw bitmap operations.
    4. Strategic Phrase Usage: Combine .Phrase() with .Term() (e.g., qb.Phrase("machine learning").And().Term("python")) rather than putting everything in a single phrase, which can be overly restrictive.
  6. How the Text Analysis Pipeline transforms text

    main

    Blaze processes raw text into searchable tokens through a five-stage pipeline:

    1. Tokenization: Splits text on non-alphanumeric characters.
    2. Lowercasing: Normalizes case (e.g., "Quick" $\rightarrow$ "quick").
    3. Stopword Filtering: Removes common words (e.g., "the", "a", "is").
    4. Length Filtering: Removes tokens shorter than a specified threshold (default is 2 chars).
    5. Stemming: Uses Snowball/Porter2 algorithms to reduce words to their root (e.g., "running" $\rightarrow$ "run").

    You can customize this behavior using blaze.AnalyzerConfig.

    // Use default configuration
    tokens := blaze.Analyze("The quick brown fox")
    
    // Custom configuration
    config := blaze.AnalyzerConfig{
        MinTokenLength:  3,      // Only keep tokens >= 3 chars
        EnableStemming:  false,  // Disable stemming
        EnableStopwords: true,   // Keep stopword filtering
    }
    tokens := blaze.AnalyzeWithConfig("The quick brown fox", config)
  7. How the Inverted Index works in Blaze

    main

    Blaze uses an Inverted Index to enable fast term lookups without scanning every document. The index maps tokens (words) to a Posting List, which contains the specific Document IDs and the positions (offsets) where that token appears.

    Key Benefits:

    • Instant term lookups: Avoids full document scans.
    • Phrase search: Enabled by checking token positions.
    • Proximity ranking: Measures distance between terms.
    • Boolean queries: Supports efficient AND, OR, and NOT operations.
    // Example of what an inverted index represents conceptually
    // Doc 1: "the quick brown fox" (Pos: 0, 1, 2, 3)
    // Doc 2: "the lazy dog"       (Pos: 0, 1, 2)
    
    // Inverted Index Map:
    // "quick" -> [Doc1:Pos1]
    // "brown" -> [Doc1:Pos2]
    // "fox"   -> [Doc1:Pos3]
  8. Understand the Blaze Hybrid Storage Model

    main

    Blaze uses a hybrid storage approach to balance speed and precision for every term in the index:

    • Document Level (Roaring Bitmaps): Stores a compressed representation of all document IDs containing a specific term. This is optimized for lightning-fast boolean filtering (e.g., finding documents that contain both 'machine' AND 'learning').
    • Position Level (Skip Lists): Stores detailed position information for every occurrence of a term. This enables advanced features like phrase searching, proximity ranking, and snippet generation.

    This hybrid model allows Blaze to perform massive document filtering in microseconds while maintaining the ability to perform precise position-based lookups when needed.

  9. Quick Start with Blaze

    main

    To get started, create a new inverted index using blaze.NewInvertedIndex(), index your documents using the Index method, and perform searches using methods like RankProximity. Each document is assigned an ID (e.g., 1, 2, 3) which is used to map terms to positions.

    package main
    
    import (
        "fmt"
        "github.com/wizenheimer/blaze"
    )
    
    func main() {
        // Create a new inverted index
        idx := blaze.NewInvertedIndex()
    
        // Index some documents
        idx.Index(1, "The quick brown fox jumps over the lazy dog")
        idx.Index(2, "A quick brown dog runs fast")
        idx.Index(3, "The lazy cat sleeps all day")
    
        // Search for documents containing "quick" and "brown"
        matches := idx.RankProximity("quick brown", 10)
    
        // Print results
        for _, match := range matches {
            fmt.Printf("Document %d (score: %.2f)\n",
                int(match.Offsets[0].DocumentID),
                match.Score)
        }
    }
  10. Persist and load the InvertedIndex

    main

    For large datasets, do not rebuild the index from scratch every time. Use idx.Encode() to serialize the index to a byte slice and idx.Decode(data) to load it back from a file.

    const indexFile = "search_index.bin"
    
    func LoadOrBuildIndex(docs []Document) (*blaze.InvertedIndex, error) {
        // Try to load existing index
        if data, err := os.ReadFile(indexFile); err == nil {
            idx := blaze.NewInvertedIndex()
            if err := idx.Decode(data); err == nil {
                return idx, nil
            }
        }
    
        // Build new index
        idx := blaze.NewInvertedIndex()
        for _, doc := range docs {
            idx.Index(doc.ID, doc.Content)
        }
    
        // Save for next time
        if data, err := idx.Encode(); err == nil {
            os.WriteFile(indexFile, data, 0644)
        }
    
        return idx, nil
    }
  11. Choose appropriate Document IDs

    main

    When indexing documents using idx.Index(id, content), always use stable, unique identifiers such as database primary keys. Avoid using array indices as IDs, because they change if the document order is reordered, which will break search results and updates.

    // Good: Use database primary keys
    idx.Index(dbRecord.ID, dbRecord.Content)
    
    // Bad: Use array indices (changes when reordering)
    for i, doc := range docs {
        idx.Index(i, doc.Content)  // Don't do this
    }