# 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.

![Alt Text](/docs/images/search_autocomplete.png "search autocomplete suggestion")

## 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.
