snapshot of https://github.com/blevesearch/bleve.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
This commit is contained in:
358
docs/search_autocomplete.md
Normal file
358
docs/search_autocomplete.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Edge N-gram Autocomplete in Bleve
|
||||
|
||||
Search autocomplete is a feature which we see in search boxes when suggestions appear while we type.
|
||||
So when we type `jav`, we see suggestions like: `java` `javascript` `javascript programming` etc.
|
||||
This is helpful because it saves users time in finding what they are looking for.
|
||||
|
||||

|
||||
|
||||
## 2. How Does It Work?
|
||||
|
||||
Autocomplete generally works in three steps:
|
||||
|
||||
1. **Index Time**: Breaking text into searchable pieces (tokens)
|
||||
2. **Query Time**: Matching user input against indexed tokens
|
||||
3. **Results**: Returning relevant suggestions quickly
|
||||
|
||||
But before we jump into the flow, let's understand different methods to achieve this and why edge n-grams are the most efficient approach.
|
||||
|
||||
## 3. Different Tokenization Methods
|
||||
|
||||
There are several tokenization approaches, each with its own strengths and weaknesses:
|
||||
|
||||
### 3.1 Single Token Tokenizer
|
||||
|
||||
```go
|
||||
// analysis/tokenizer/single/single.go
|
||||
func (t *SingleTokenTokenizer) Tokenize(input []byte) analysis.TokenStream {
|
||||
return analysis.TokenStream{
|
||||
&analysis.Token{
|
||||
Term: input, // Here entire input as one token
|
||||
Position: 1,
|
||||
Start: 0,
|
||||
End: len(input),
|
||||
Type: analysis.AlphaNumeric,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**: Treats the entire input as a single token.
|
||||
|
||||
**Example**: "JavaScript Programming" → [`"JavaScript Programming"`]
|
||||
|
||||
**Pros**:
|
||||
|
||||
- Simple and fast
|
||||
- Perfect for exact phrase matching
|
||||
- Minimal index size
|
||||
|
||||
**Cons**:
|
||||
|
||||
- No autocomplete support (can't match partial text)
|
||||
- Not flexible for search
|
||||
|
||||
**Use case**: Keyword fields, IDs, exact phrase matching
|
||||
|
||||
### 3.2 Whitespace Token Tokenizer
|
||||
|
||||
```go
|
||||
// analysis/tokenizer/whitespace/whitespace.go
|
||||
func TokenizerConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.Tokenizer, error) {
|
||||
return character.NewCharacterTokenizer(notSpace), nil
|
||||
}
|
||||
|
||||
func notSpace(r rune) bool {
|
||||
return !unicode.IsSpace(r) // Split on whitespace
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**: Splits text on whitespace characters.
|
||||
|
||||
**Example**: "JavaScript Programming" → [`"JavaScript"`, `"Programming"`]
|
||||
|
||||
**Pros**:
|
||||
|
||||
- Simple word-based tokenization
|
||||
- Works well for basic prefix search
|
||||
|
||||
**Cons**:
|
||||
|
||||
- Only matches from word beginnings
|
||||
- No support for partial word matching
|
||||
- Limited autocomplete capabilities
|
||||
|
||||
**Use case**: Basic search, word-level indexing
|
||||
|
||||
### 3.3 N-gram Method
|
||||
|
||||
```go
|
||||
// analysis/token/ngram/ngram.go
|
||||
func (s *NgramFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
|
||||
rv := make(analysis.TokenStream, 0, len(input))
|
||||
|
||||
for _, token := range input {
|
||||
runeCount := utf8.RuneCount(token.Term)
|
||||
runes := bytes.Runes(token.Term)
|
||||
|
||||
// ..generate all possible n-grams
|
||||
}
|
||||
return rv
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**: Creates ALL possible substrings of specified lengths.
|
||||
|
||||
**Example**: "java" with 2-3 grams → [`"ja"`, `"av"`, `"va"`, `"jav"`, `"ava"`]
|
||||
|
||||
**Pros**:
|
||||
|
||||
- Supports partial matching anywhere in the text
|
||||
- Coverage of all possible substrings
|
||||
|
||||
**Cons**:
|
||||
|
||||
- **MASSIVE index size** (exponential growth)
|
||||
- Brings more noise as irrelevant matches (e.g., "av" matching "java")
|
||||
- Poor performance for autocomplete
|
||||
- High memory usage
|
||||
|
||||
**Use case**: Full-text substring search (not ideal for autocomplete)
|
||||
|
||||
### 3.4 Edge N-gram Method (preferred for autocomplete)
|
||||
|
||||
```go
|
||||
// analysis/token/edgengram/edgengram.go
|
||||
func (s *EdgeNgramFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
|
||||
rv := make(analysis.TokenStream, 0, len(input))
|
||||
|
||||
for _, token := range input {
|
||||
runeCount := utf8.RuneCount(token.Term)
|
||||
runes := bytes.Runes(token.Term)
|
||||
// ..builds tokens based on either end, specified in the input
|
||||
}
|
||||
return rv
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**: Creates substrings only from the beginning (or end) of each word.
|
||||
|
||||
**Example**: "javascript" with front edge n-grams (1-5) → [`"j"`, `"ja"`, `"jav"`, `"java"`, `"javas"`]
|
||||
|
||||
**Pros**:
|
||||
|
||||
- Perfect for autocomplete (matches prefixes naturally)
|
||||
- Efficient index size (linear growth vs exponential)
|
||||
- Fast queries (direct term matching, no complex processing)
|
||||
- Intuitive results (matches what users expect)
|
||||
- Highly scalable for large datasets
|
||||
|
||||
**Cons**:
|
||||
|
||||
- Only supports prefix matching (but that's preferred for autocomplete!)
|
||||
- Slightly larger index than basic tokenization
|
||||
|
||||
**Use case**: Search autocomplete, prefix-based search
|
||||
|
||||
## 4. Why Edge N-grams Are Most Efficient for Autocomplete
|
||||
|
||||
Let's see what happens when a user types "java" with edge_ngram tokenizer:
|
||||
|
||||
```text
|
||||
Index contains: ["j", "ja", "jav", "java", "javas", "javasc", "javascr", ...]
|
||||
User types: "java"
|
||||
Query: ExactTermQuery("java")
|
||||
Process: Direct hash table lookup for term "java"
|
||||
Result: Instant match, then retrieve documents containing this term
|
||||
```
|
||||
|
||||
**Key advantages of Edge N-gram approach:**
|
||||
|
||||
1. **O(1) lookup**: Direct term matching
|
||||
2. **No query-time processing**: Terms are pre-computed at index time
|
||||
3. **Better caching**: Exact term queries cache better than prefix queries
|
||||
4. **Consistent performance**: Query time doesn't increase with index size
|
||||
|
||||
## 5. Step-by-Step Implementation: Building Search Autocomplete
|
||||
|
||||
Now let's see how to implement this in practice using our exact configuration. We'll build a complete autocomplete system step by step.
|
||||
|
||||
### Step 1: Create Custom Edge N-gram Token Filter
|
||||
|
||||
First, we need to create a custom token filter for edge n-grams. Here's how it looks in our configuration:
|
||||
|
||||
```go
|
||||
// Create a new index mapping
|
||||
indexMapping := mapping.NewIndexMapping()
|
||||
|
||||
// 1. Define the edgeGram token filter
|
||||
edgeGramFilter := map[string]interface{}{
|
||||
"type": edgengram.Name,
|
||||
"min": 2.0,
|
||||
"max": 4.0,
|
||||
"back": false,
|
||||
}
|
||||
|
||||
// Register the token filter
|
||||
if err := indexMapping.AddCustomTokenFilter("Engram", edgeGramFilter); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
**What each setting does:**
|
||||
|
||||
- `"type": "edge_ngram"` - Tells Bleve to use the edge n-gram filter
|
||||
- `"min": 2` - Start creating tokens from 2 characters ("ja", "sc", etc.)
|
||||
- `"max": 4` - Stop at 4 characters ("java", "scri", etc.)
|
||||
- `"back": false` - Create tokens from the front (beginning) of words
|
||||
|
||||
### Step 2: Create Custom Analyzer
|
||||
|
||||
Next, we create an analyzer that uses our custom token filter along with other helpful filters:
|
||||
|
||||
```go
|
||||
// 2. Define a custom analyzer that uses it
|
||||
customAnalyzer := map[string]interface{}{
|
||||
"type": custom.Name,
|
||||
"tokenizer": "unicode",
|
||||
"char_filters": []string{
|
||||
zerowidthnonjoiner.Name,
|
||||
},
|
||||
"token_filters": []string{
|
||||
"Engram", // our custom edge_ngram filter
|
||||
"to_lower",
|
||||
"stop_en",
|
||||
},
|
||||
}
|
||||
|
||||
if err := indexMapping.AddCustomAnalyzer("edgeGramAnalyzer", customAnalyzer); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
**The pipeline works like this:**
|
||||
|
||||
1. **Input Text**: "Schaumbergfest Event"
|
||||
|
||||
2. **Tokenizer** (`unicode`): Splits into words
|
||||
|
||||
```text
|
||||
["Schaumbergfest", "Event"]
|
||||
```
|
||||
|
||||
3. **Character Filter** (`zero_width_spaces`): Removes invisible characters
|
||||
|
||||
```text
|
||||
["Schaumbergfest", "Event"] (cleaned)
|
||||
```
|
||||
|
||||
4. **Token Filter 1** (`Engram`): Creates edge n-grams (2-4 chars)
|
||||
|
||||
```text
|
||||
["Sc", "Sch", "Scha", "Ev", "Eve", "Even"]
|
||||
```
|
||||
|
||||
5. **Token Filter 2** (`to_lower`): Makes everything lowercase
|
||||
|
||||
```text
|
||||
["sc", "sch", "scha", "ev", "eve", "even"]
|
||||
```
|
||||
|
||||
6. **Token Filter 3** (`stop_en`): Removes common words (none in this case)
|
||||
|
||||
```text
|
||||
["sc", "sch", "scha", "ev", "eve", "even"] (final tokens)
|
||||
```
|
||||
|
||||
### Step 3: Configure Field Mapping
|
||||
|
||||
Now we tell Bleve which fields to apply our autocomplete analyzer to:
|
||||
|
||||
```go
|
||||
// 3. Assign analyzer to a field mapping
|
||||
fieldMapping := mapping.NewTextFieldMapping()
|
||||
fieldMapping.Analyzer = "edgeGramAnalyzer"
|
||||
|
||||
indexMapping.DefaultMapping.AddFieldMappingsAt("title", fieldMapping)
|
||||
|
||||
indexPath := "example.bleve"
|
||||
index, err := bleve.New(indexPath, indexMapping)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: How It Works in Real Search
|
||||
|
||||
When someone searches for "sc", here's what happens:
|
||||
|
||||
**Index contains these tokens:**
|
||||
|
||||
```text
|
||||
"sc" → [document1: "Schaumbergfest", document2: "Script", ...]
|
||||
"sch" → [document1: "Schaumbergfest", ...]
|
||||
"scha" → [document1: "Schaumbergfest", ...]
|
||||
```
|
||||
|
||||
**User types "sc":**
|
||||
|
||||
1. Query: `name:sc`
|
||||
2. Bleve looks up exact term "sc" in the index
|
||||
3. Finds document with "Schaumbergfest" and "Script"
|
||||
4. Returns suggestion instantly
|
||||
|
||||
```go
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
// 4. Index Documents
|
||||
documents := []Document{
|
||||
{
|
||||
ID: "doc1",
|
||||
Title: "Schaumbergfest",
|
||||
},
|
||||
{
|
||||
ID: "doc2",
|
||||
Title: "Script",
|
||||
},
|
||||
}
|
||||
|
||||
batch := index.NewBatch()
|
||||
for _, doc := range documents {
|
||||
batch.Index(doc.ID, doc)
|
||||
}
|
||||
if err := index.Batch(batch); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 5. Search the created index
|
||||
query := bleve.NewMatchQuery("sc")
|
||||
query.SetField("title")
|
||||
searchRequest := bleve.NewSearchRequest(query)
|
||||
searchRequest.Explain = true
|
||||
searchRequest.Fields = []string{"title"}
|
||||
searchResult, err := index.Search(searchRequest)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(searchResult)
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```bash
|
||||
|
||||
$ go run main.go
|
||||
|
||||
2 matches, showing 1 through 2, took 189.041µs
|
||||
1. doc2 (0.343255)
|
||||
title
|
||||
Script
|
||||
2. doc1 (0.343255)
|
||||
title
|
||||
Schaumbergfest
|
||||
```
|
||||
|
||||
Note: To run code, enclose code starting from Step 1 in func main.
|
||||
Reference in New Issue
Block a user