Index Enrichment Pipeline

Search Engineering intermediate Building with AI ~20 min read

Overview

An index enrichment pipeline is an asynchronous processing layer that takes raw ingested documents and augments them with derived data—embeddings, summaries, text chunks, named entities, and classification labels—before or after they land in the search index. The key insight is that enrichment is decoupled from ingestion: a document becomes searchable immediately upon insertion, while enrichment tasks run in the background and update the document’s index entry once complete.

This architecture matters because enrichment operations are expensive. Computing embeddings requires GPU inference, generating summaries calls an LLM, and chunking requires parsing document structure. Running these synchronously during ingestion would add seconds of latency per document and create a bottleneck that limits throughput. By moving enrichment to an async pipeline, the system can ingest documents at full speed and process enrichments at whatever rate the available compute allows.

In a typical production search engine, the enrichment pipeline is structured as a series of stages connected by work queues. Each stage reads documents from its input queue, applies a transformation, writes the result back to the document store, and pushes the document ID to the next stage’s queue. The pipeline supports backfill (re-processing existing documents when a new enrichment is added), pause/resume (stopping a stage without losing progress), and priority ordering (enriching recently-queried documents first).

Core Concepts

Async Enrichment vs. Sync Indexing

Documents are indexed immediately in their raw form, making them searchable within milliseconds of ingestion. Enrichment runs asynchronously and updates the document’s index entry in place. This means search quality improves over time as enrichments complete: a document initially found only by keyword becomes discoverable by vector similarity once its embedding is computed.

Stage-Based Pipeline

Each enrichment type (embedding, chunking, summarization, entity extraction) is a separate stage. Stages are independent and can run in parallel on different workers. A document flows through stages in dependency order: chunking must happen before per-chunk embeddings, but entity extraction can run in parallel with both. The pipeline DAG encodes these dependencies.

Backfill & Re-enrichment

When a new enrichment stage is added (e.g., switching from one embedding model to another), all existing documents need re-processing. A backfill scan walks the document store, identifies documents missing the new enrichment, and enqueues them. Backfill runs at lower priority than new-document enrichment to avoid starving real-time ingestion.

Idempotent Processing

Each enrichment stage must be idempotent: processing the same document twice produces the same result and doesn’t corrupt state. This is essential because queue delivery is at-least-once—worker crashes, timeouts, and retries mean a document may be processed more than once. Idempotency ensures correctness without exactly-once delivery.

Pause & Resume

Individual pipeline stages can be paused (stop dequeuing work) and resumed (continue from where they left off). This is critical for operational control: if an embedding model starts producing garbage, operators can pause the embedding stage, fix the model, and resume without losing queue position or re-processing already-enriched documents.

Pipeline State Tracking

Each document carries an enrichment status map: a set of key-value pairs where the key is the stage name and the value is a status record (pending, in-progress, completed, failed). This map is stored alongside the document and is used to determine which stages still need to process the document. Failed enrichments include an error message and retry count, enabling dead-letter handling after a configurable number of retries.

Priority Ordering

Not all documents need enrichment equally urgently. Documents that users are actively querying benefit most from having embeddings and summaries. The pipeline supports priority levels: documents referenced in recent queries are promoted to the front of the enrichment queue, while bulk-ingested historical documents process at background priority.

How It Works

Pipeline Architecture

The enrichment pipeline consists of a coordinator, work queues, and stage workers. The coordinator manages pipeline state (which stages exist, their dependencies, pause/resume status). Work queues connect stages and provide at-least-once delivery with visibility timeouts. Stage workers pull documents from their input queue, apply the enrichment, write results back, and acknowledge the message:

// Go: Enrichment pipeline coordinator
package enrichment

import (
    "context"
    "sync"
    "time"
)

// Stage defines an enrichment processing stage.
type Stage struct {
    Name         string
    DependsOn    []string         // stages that must complete first
    Process      func(ctx context.Context, doc *Document) (*EnrichmentResult, error)
    Concurrency  int              // number of parallel workers
    RetryLimit   int
    Paused       bool
}

// Pipeline manages the enrichment workflow.
type Pipeline struct {
    mu       sync.RWMutex
    stages   map[string]*Stage
    queue    WorkQueue
    store    DocumentStore
    order    []string           // topological order of stages
}

// Enqueue adds a document to the pipeline for enrichment.
func (p *Pipeline) Enqueue(ctx context.Context, docID string, priority int) error {
    // Find the first stage(s) with no dependencies
    roots := p.rootStages()
    for _, stage := range roots {
        err := p.queue.Push(ctx, stage, QueueItem{
            DocID:    docID,
            Priority: priority,
            EnqueuedAt: time.Now(),
        })
        if err != nil {
            return err
        }
    }
    return nil
}

// rootStages returns stages with no dependencies.
func (p *Pipeline) rootStages() []string {
    var roots []string
    for _, name := range p.order {
        if len(p.stages[name].DependsOn) == 0 {
            roots = append(roots, name)
        }
    }
    return roots
}

// advance moves a document to the next stage(s) after completion.
func (p *Pipeline) advance(ctx context.Context, docID string, completedStage string, priority int) error {
    for _, name := range p.order {
        stage := p.stages[name]
        if !containsStr(stage.DependsOn, completedStage) {
            continue
        }
        // Check if all dependencies are satisfied
        doc, _ := p.store.Get(ctx, docID)
        allDone := true
        for _, dep := range stage.DependsOn {
            if doc.EnrichmentStatus[dep] != StatusCompleted {
                allDone = false
                break
            }
        }
        if allDone {
            p.queue.Push(ctx, name, QueueItem{
                DocID:    docID,
                Priority: priority,
            })
        }
    }
    return nil
}

Stage Worker Loop

Each stage runs a configurable number of worker goroutines. Workers dequeue items, check that the stage is not paused, load the document, call the stage’s processing function, persist results, and advance the document to downstream stages:

// Go: Stage worker implementation
package enrichment

import (
    "context"
    "log"
    "time"
)

// RunWorker processes items for a single stage.
func (p *Pipeline) RunWorker(ctx context.Context, stageName string) {
    stage := p.stages[stageName]

    for {
        select {
        case <-ctx.Done():
            return
        default:
        }

        // Check if stage is paused
        p.mu.RLock()
        paused := stage.Paused
        p.mu.RUnlock()
        if paused {
            time.Sleep(time.Second)
            continue
        }

        // Dequeue next item (blocks until available or timeout)
        item, err := p.queue.Pop(ctx, stageName, 30*time.Second)
        if err != nil {
            continue // timeout or context cancelled
        }

        // Load document
        doc, err := p.store.Get(ctx, item.DocID)
        if err != nil {
            log.Printf("stage %s: failed to load doc %s: %v", stageName, item.DocID, err)
            p.queue.Nack(ctx, stageName, item) // return to queue
            continue
        }

        // Skip if already enriched (idempotency check)
        if doc.EnrichmentStatus[stageName] == StatusCompleted {
            p.queue.Ack(ctx, stageName, item)
            continue
        }

        // Process
        doc.EnrichmentStatus[stageName] = StatusInProgress
        p.store.UpdateStatus(ctx, item.DocID, stageName, StatusInProgress)

        result, err := stage.Process(ctx, doc)
        if err != nil {
            retries := doc.EnrichmentRetries[stageName] + 1
            if retries >= stage.RetryLimit {
                doc.EnrichmentStatus[stageName] = StatusFailed
                p.store.UpdateStatus(ctx, item.DocID, stageName, StatusFailed)
                log.Printf("stage %s: doc %s permanently failed after %d retries", stageName, item.DocID, retries)
            } else {
                doc.EnrichmentRetries[stageName] = retries
                p.queue.Nack(ctx, stageName, item) // re-enqueue for retry
            }
            p.queue.Ack(ctx, stageName, item)
            continue
        }

        // Persist enrichment result
        p.store.SaveEnrichment(ctx, item.DocID, stageName, result)
        doc.EnrichmentStatus[stageName] = StatusCompleted
        p.store.UpdateStatus(ctx, item.DocID, stageName, StatusCompleted)
        p.queue.Ack(ctx, stageName, item)

        // Advance to downstream stages
        p.advance(ctx, item.DocID, stageName, item.Priority)
    }
}

Backfill Scanner

When a new stage is added or an existing stage’s model is updated, the backfill scanner identifies documents that need (re-)enrichment. It iterates the document store in batches, checks each document’s enrichment status map, and enqueues those missing the target stage at low priority:

// Go: Backfill scanner for re-enrichment
package enrichment

import "context"

// Backfill enqueues all documents that are missing a specific enrichment.
func (p *Pipeline) Backfill(ctx context.Context, stageName string, batchSize int) (int, error) {
    var cursor string
    enqueued := 0

    for {
        docs, nextCursor, err := p.store.ListBatch(ctx, cursor, batchSize)
        if err != nil {
            return enqueued, err
        }

        for _, doc := range docs {
            if doc.EnrichmentStatus[stageName] == StatusCompleted {
                continue // already enriched
            }

            // Check dependencies are met
            stage := p.stages[stageName]
            depsOK := true
            for _, dep := range stage.DependsOn {
                if doc.EnrichmentStatus[dep] != StatusCompleted {
                    depsOK = false
                    break
                }
            }
            if !depsOK {
                continue // dependencies not yet satisfied
            }

            p.queue.Push(ctx, stageName, QueueItem{
                DocID:    doc.ID,
                Priority: PriorityBackfill, // lowest priority
            })
            enqueued++
        }

        if nextCursor == "" {
            break
        }
        cursor = nextCursor
    }
    return enqueued, nil
}

Practical Applications

Embedding Generation

A production search engine using this pattern generates vector embeddings for every document via the enrichment pipeline. The embedding stage sends document text to a local inference server, receives the vector, and writes it to the document’s vector field. Because embedding is async, bulk imports of millions of documents complete quickly while embeddings are computed over the following hours. Documents are searchable by keyword immediately; vector search becomes available per-document as embeddings finish.

Document Chunking for RAG

For retrieval-augmented generation, documents must be split into semantically coherent chunks. The chunking stage parses document structure (headings, paragraphs, code blocks), applies overlap-aware splitting with configurable chunk size, and stores chunks as child documents linked to the parent. Downstream stages then compute per-chunk embeddings, enabling fine-grained retrieval that returns the most relevant passage rather than the entire document.

Named Entity Recognition

An NER enrichment stage extracts person names, organizations, dates, and domain-specific entities from document text. These entities are stored as structured metadata on the document, enabling faceted search (“all documents mentioning Company X”) and graph edge creation (linking documents to entity nodes in the knowledge graph).

Auto-summarization for Snippet Generation

Search result snippets are more useful when they are intelligently summarized rather than just showing the first N characters. The summarization stage calls an LLM to produce a 2–3 sentence summary of each document, stored as metadata. The search UI displays these summaries in results, giving users a better signal for which results are relevant without requiring them to click through to each document.

Implementation Patterns

Visibility Timeout for At-Least-Once Delivery

Work queue items become invisible for a configured duration after being dequeued. If the worker doesn’t acknowledge completion within the timeout, the item reappears in the queue for another worker to pick up. This ensures no document is lost if a worker crashes mid-processing. The timeout must be longer than the slowest expected processing time (embedding a large document can take 30+ seconds).

Dead Letter Queue for Poison Documents

Some documents consistently fail enrichment—malformed content, unsupported encodings, or content that triggers model errors. After exceeding the retry limit, these documents move to a dead letter queue (DLQ) for manual inspection. The DLQ preserves the error message from each attempt, enabling operators to diagnose and fix the issue, then re-enqueue the document:

// Zig: Dead letter queue entry
const DLQEntry = struct {
    doc_id: []const u8,
    stage: []const u8,
    attempts: u32,
    last_error: []const u8,
    first_failed_at: i64,
    last_failed_at: i64,

    pub fn format(self: *const DLQEntry, writer: anytype) !void {
        try writer.print(
            "DLQ: doc={s} stage={s} attempts={d} error={s}\n",
            .{ self.doc_id, self.stage, self.attempts, self.last_error },
        );
    }
};

const DeadLetterQueue = struct {
    entries: std.ArrayList(DLQEntry),
    allocator: std.mem.Allocator,

    pub fn add(self: *DeadLetterQueue, entry: DLQEntry) !void {
        try self.entries.append(entry);
    }

    /// Requeue all entries for a given stage back into the pipeline.
    pub fn requeue(self: *DeadLetterQueue, stage: []const u8, queue: *WorkQueue) !u32 {
        var requeued: u32 = 0;
        var i: usize = 0;
        while (i < self.entries.items.len) {
            if (std.mem.eql(u8, self.entries.items[i].stage, stage)) {
                try queue.push(self.entries.items[i].doc_id, PriorityBackfill);
                _ = self.entries.orderedRemove(i);
                requeued += 1;
            } else {
                i += 1;
            }
        }
        return requeued;
    }
};

Progress Tracking with Watermarks

For backfill operations, a high-watermark tracks how far through the document store the scanner has progressed. If the backfill is paused and resumed, it continues from the watermark rather than restarting. The watermark is a cursor (typically the last document ID processed) stored persistently so it survives process restarts.

Rate Limiting for External Services

Enrichment stages that call external services (LLM APIs for summarization, embedding endpoints) must respect rate limits. A token bucket rate limiter per stage ensures the pipeline doesn’t overwhelm downstream services. When the bucket is empty, workers block until tokens replenish, naturally throttling throughput without losing work.

Common Pitfalls

Enrichment Ordering Violations

If stage B depends on stage A (e.g., per-chunk embeddings depend on chunking), enqueuing a document for stage B before stage A completes produces incomplete or incorrect results. Always check the dependency DAG before advancing a document. The pipeline coordinator should enforce this: only enqueue downstream stages when all upstream dependencies show StatusCompleted.

Stale Enrichments After Document Updates

When a document’s content changes, its existing enrichments (embeddings, summaries) become stale. The pipeline must detect content updates and invalidate affected enrichments, resetting their status to pending and re-enqueuing them. Failing to do this means vector search returns results based on old content, creating a consistency gap between keyword search (which uses current content) and vector search (which uses stale embeddings).

Queue Buildup During Bulk Ingestion

Bulk-ingesting millions of documents floods the enrichment queues. If the queue implementation doesn’t handle backpressure, memory usage grows unbounded. Use persistent queues (backed by disk or a message broker) rather than in-memory queues, and apply admission control to limit how many documents are in-flight at once.

Non-Idempotent Enrichment Stages

If an enrichment stage appends to a list rather than replacing it, processing a document twice creates duplicate entries. Every stage must be designed so that running it N times on the same input produces the same final state as running it once. Use upsert semantics (write-or-replace) rather than append semantics.

Ignoring Backfill Priority

Running backfill at the same priority as real-time enrichment causes newly ingested documents to wait behind millions of historical documents. Always give backfill work a lower priority so that fresh documents are enriched first. Users care about recently ingested documents being searchable by vector; historical completeness is a background concern.

Further Reading