Search Indexing Strategies
Index Updates and Delta Indexing
Read aloud is unavailable in this browser.
The Synchronization Challenge
Search indexes are not databases of record—they're derived data stores that must stay synchronized with authoritative sources. This synchronization is straightforward when you can simply reindex everything, but that approach breaks down at scale.
Imagine you're running the search infrastructure for a major e-commerce platform with 500 million products. A full reindex takes 6 hours and consumes significant cluster resources. Now consider that 10 million products change every day—prices update, descriptions change, inventory fluctuates. You can't reindex everything daily, but you can't ignore changes either.
Delta indexing solves this problem by identifying and processing only the documents that have changed since the last index update. Done well, delta indexing reduces a 6-hour full reindex to a 15-minute incremental job. Done poorly, it becomes a source of subtle bugs, inconsistent search results, and operational nightmares.
Mastering delta indexing is what separates teams that struggle with search from teams that make it look effortless.
By the end of this page, you will understand how to track document changes, implement efficient delta detection, handle updates, deletes, and partial modifications, and design robust synchronization pipelines. You'll learn the patterns used by companies like Shopify, Airbnb, and Netflix to keep billions of documents synchronized.
The Anatomy of Index Updates
Before designing delta indexing strategies, we must understand what happens when we update a search index. The mechanics differ significantly from traditional database updates.
The Immutability Reality
Most search engines, including Lucene-based systems (Elasticsearch, Solr, OpenSearch), use immutable segments. When you "update" a document:
- The old document version is marked as deleted in its segment
- A new document version is written to a new or current segment
- The old version remains on disk until segment merging occurs
- Searches filter out deleted documents dynamically
This design has profound implications:
- Updates are actually delete + insert operations internally
- Disk space grows with update frequency until merges compact segments
- Update order matters—the last write wins, but ordering is non-trivial in distributed systems
- Partial updates still rewrite the entire document in most implementations
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// 1. Full document update (replaces entire document)PUT /products/_doc/12345{ "product_id": "12345", "name": "Wireless Headphones", "price": 79.99, "inventory": 150, "category": "electronics", "last_updated": "2024-01-15T10:30:00Z"} // 2. Partial update (preferred for single field changes)// Still rewrites document internally, but API is simplerPOST /products/_update/12345{ "doc": { "price": 69.99, "last_updated": "2024-01-15T11:00:00Z" }} // 3. Scripted update (for computed changes)POST /products/_update/12345{ "script": { "source": "ctx._source.inventory -= params.sold", "params": { "sold": 5 } }} // 4. Upsert (update or insert if not exists)POST /products/_update/12345{ "doc": { "price": 69.99, "last_updated": "2024-01-15T11:00:00Z" }, "upsert": { "product_id": "12345", "name": "New Product", "price": 69.99, "inventory": 100, "last_updated": "2024-01-15T11:00:00Z" }} // 5. Bulk update (efficient for multiple documents)POST /_bulk{"update": {"_index": "products", "_id": "12345"}}{"doc": {"price": 69.99}}{"update": {"_index": "products", "_id": "12346"}}{"doc": {"price": 89.99}}{"update": {"_index": "products", "_id": "12347"}}{"doc": {"price": 49.99}}While APIs like _update appear to modify single fields, internally the search engine must fetch the current document, merge changes, and write the complete new version. This means partial updates to large documents are nearly as expensive as full replacements. Design your documents with update patterns in mind.
Change Detection: Finding What's Changed
The foundation of delta indexing is change detection—identifying which documents have been created, modified, or deleted since the last indexing run. There are several approaches, each with distinct trade-offs.
Timestamp-Based Detection
The most common approach: every document has a last_modified timestamp, and delta queries select documents modified after the last checkpoint.
Advantages:
- Simple to implement
- Works with existing data models
- Easy to reason about
Disadvantages:
- Clock skew in distributed systems causes missed or duplicate updates
- Requires reliable timestamps in all data sources
- Doesn't detect deletes (the document is gone, along with its timestamp)
Change Data Capture (CDC)
Capture changes at the database level using transaction logs, triggers, or database features like PostgreSQL's logical replication.
Advantages:
- Captures all changes including deletes
- Preserves change ordering
- Works even when application bypasses normal update paths
Disadvantages:
- Database-specific implementation
- Requires additional infrastructure (Debezium, Maxwell, etc.)
- Log retention and replay complexity
Event Sourcing
Every change is published as an event to a message queue, consumed by the indexing pipeline.
Advantages:
- Decoupled from database internals
- Easy to replay history
- Supports multiple consumers
Disadvantages:
- Requires application changes to publish events
- Event ordering challenges in distributed systems
- Message queue becomes a critical dependency
| Strategy | Detects Creates | Detects Updates | Detects Deletes | Ordering | Complexity |
|---|---|---|---|---|---|
| Timestamp Query | ✓ | ✓ | ✗ | Approximate | Low |
| Full Diff (Hash) | ✓ | ✓ | ✓ | N/A | High |
| CDC (Debezium) | ✓ | ✓ | ✓ | Exact | Medium |
| Event Sourcing | ✓ | ✓ | ✓ | Per-partition | Medium |
| Soft Deletes + Timestamps | ✓ | ✓ | ✓ | Approximate | Low-Medium |
The Delete Problem
Deletes deserve special attention because they're inherently difficult to detect. If a record is deleted from the source database, it's gone—there's nothing to query for "modified since" timestamps.
Common solutions:
-
Soft Deletes: Never delete records; mark them with a
deleted_attimestamp. Delta queries include these, and the indexer removes them from the search index. -
Tombstone Tables: On delete, write the deleted ID to a separate "tombstones" table with a timestamp. Delta queries check both the main table and tombstones.
-
Full ID Reconciliation: Periodically, compare all IDs in the search index against all IDs in the source database. Missing IDs in source = deleted.
-
CDC: Database-level change capture naturally includes delete events.
Most production systems use a combination: CDC or soft deletes for operational deletes, with periodic full reconciliation as a safety net.
Delta Indexing Pipeline Architecture
A robust delta indexing pipeline consists of several coordinated components. Let's examine a production-grade architecture used by major platforms.
Core Components
1. Change Source The authoritative database or event stream where changes originate. This could be:
- A relational database with timestamps
- A CDC stream from Debezium
- A Kafka topic receiving application events
2. Change Collector A service that polls or subscribes to the change source, maintaining a checkpoint of the last processed change. This component must be:
- Idempotent (safe to restart)
- Resumable (picks up from checkpoint after failure)
- Scalable (handles bursts without falling behind)
3. Document Enricher Changes often contain only IDs or partial data. The enricher fetches complete documents from source systems, resolving foreign keys, joining related data, and computing derived fields.
4. Document Transformer Converts source documents into the search index schema, applying normalization, tokenization, and field mappings.
5. Bulk Indexer Batches transformed documents and writes them to the search cluster using bulk APIs.
6. Checkpoint Manager Persists the latest successfully indexed change marker, enabling resumption after failures.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
"""Production Delta Indexing Pipeline This implementation demonstrates patterns used at companiesprocessing millions of document updates per hour.""" from dataclasses import dataclassfrom datetime import datetime, timedeltafrom typing import Generator, Optionalimport json @dataclassclass ChangeEvent: """Represents a single change in the source system.""" event_id: str # Unique identifier for idempotency entity_type: str # e.g., 'product', 'user', 'order' entity_id: str # Primary key of changed entity operation: str # 'INSERT', 'UPDATE', 'DELETE' timestamp: datetime # When the change occurred payload: Optional[dict] # Changed data (may be partial) @dataclassclass IndexCheckpoint: """Tracks progress through the change stream.""" last_event_id: str last_timestamp: datetime events_processed: int last_updated: datetime class DeltaIndexingPipeline: """ Coordinates delta indexing from change detection through bulk indexing with fault tolerance and checkpointing. """ def __init__( self, change_source: 'ChangeSource', document_enricher: 'DocumentEnricher', search_client: 'SearchClient', checkpoint_store: 'CheckpointStore', batch_size: int = 1000, max_batch_wait_seconds: int = 5 ): self.change_source = change_source self.enricher = document_enricher self.search = search_client self.checkpoints = checkpoint_store self.batch_size = batch_size self.max_wait = max_batch_wait_seconds async def run(self) -> None: """ Main loop: consume changes, enrich, transform, index. This loop is designed to run continuously in production, handling backpressure and failures gracefully. """ while True: checkpoint = await self.checkpoints.load() try: async for batch in self._consume_batches(checkpoint): # Separate deletes from upserts deletes = [c for c in batch if c.operation == 'DELETE'] upserts = [c for c in batch if c.operation != 'DELETE'] # Process upserts: enrich, transform, index if upserts: documents = await self._process_upserts(upserts) await self._bulk_index(documents) # Process deletes: remove from index if deletes: await self._bulk_delete( [c.entity_id for c in deletes] ) # Checkpoint after successful batch new_checkpoint = IndexCheckpoint( last_event_id=batch[-1].event_id, last_timestamp=batch[-1].timestamp, events_processed=( checkpoint.events_processed + len(batch) ), last_updated=datetime.utcnow() ) await self.checkpoints.save(new_checkpoint) checkpoint = new_checkpoint except Exception as e: # Log error, wait, retry from checkpoint await self._handle_failure(e, checkpoint) async def _consume_batches( self, checkpoint: IndexCheckpoint ) -> Generator[list[ChangeEvent], None, None]: """ Consume changes in batches, balancing latency and throughput. Uses time-based batching: emit batch when either size limit or time limit is reached, whichever comes first. """ batch = [] batch_start = datetime.utcnow() async for change in self.change_source.stream(checkpoint): batch.append(change) batch_age = (datetime.utcnow() - batch_start).seconds if len(batch) >= self.batch_size or batch_age >= self.max_wait: yield batch batch = [] batch_start = datetime.utcnow() # Emit remaining items if batch: yield batch async def _process_upserts( self, changes: list[ChangeEvent] ) -> list[dict]: """ Enrich and transform changes into index-ready documents. Handles the common case where change events contain only IDs, requiring enrichment from the source database. """ # Deduplicate: if same entity changed multiple times in batch, # only process the latest latest_changes = {} for change in changes: key = f"{change.entity_type}:{change.entity_id}" existing = latest_changes.get(key) if not existing or change.timestamp > existing.timestamp: latest_changes[key] = change # Enrich: fetch full documents from source enriched = await self.enricher.enrich_batch( list(latest_changes.values()) ) # Transform: convert to search schema documents = [] for entity_id, data in enriched.items(): doc = self._transform_to_search_schema(data) documents.append(doc) return documents async def _bulk_index(self, documents: list[dict]) -> None: """ Write documents to search index using bulk API. Implements retry with exponential backoff for transient failures. """ for attempt in range(3): try: result = await self.search.bulk_index(documents) if result.errors: # Handle partial failures failed_ids = [ item['_id'] for item in result.items if 'error' in item ] raise PartialIndexingError(failed_ids) return except TransientError as e: wait = 2 ** attempt await asyncio.sleep(wait) raise IndexingError("Max retries exceeded") async def _bulk_delete(self, entity_ids: list[str]) -> None: """Remove deleted entities from search index.""" await self.search.bulk_delete(entity_ids) def _transform_to_search_schema(self, data: dict) -> dict: """ Map source data to search index schema. This is where you apply field mappings, compute derived fields, and normalize data for search. """ return { '_id': data['id'], 'name': data['name'], 'description': data.get('description', ''), 'price': float(data['price']), 'category': data['category']['name'], 'brand': data.get('brand', {}).get('name'), 'in_stock': data.get('inventory', 0) > 0, 'popularity_score': self._compute_popularity(data), 'indexed_at': datetime.utcnow().isoformat() }Handling Edge Cases and Failure Modes
Delta indexing pipelines must handle numerous edge cases that can cause subtle data inconsistencies if not addressed properly.
Out-of-Order Updates
In distributed systems, updates can arrive out of order. If a product's price changes from $100 → $80 → $90, but updates arrive as $80, $90, $100, the index will show $100 (the last processed) instead of $90 (the current value).
Solutions:
- Include version numbers or timestamps in documents; reject older versions
- Use optimistic concurrency control (
if_seq_noandif_primary_termin Elasticsearch) - Periodically reconcile against the source of truth
Enrichment Failures
When fetching full documents during enrichment, related data might be missing (e.g., a product references a deleted category).
Solutions:
- Use defaults for missing relations
- Skip problematic documents and log for investigation
- Maintain denormalized snapshots for frequently-missing relations
Partial Batch Failures
When indexing 1,000 documents, 995 might succeed and 5 might fail due to mapping conflicts or validation errors.
Solutions:
- Checkpoint at document granularity, not batch granularity
- Dead-letter queue for failed documents
- Separate indexing paths for "clean" vs. "problematic" documents
- •Version Conflicts: Concurrent updates to the same document cause rejection; implement merge-on-conflict logic
- •Schema Drift: Source data structure changes break transformation; add schema validation and alerting
- •Checkpoint Corruption: Lost or corrupted checkpoint causes re-processing or gaps; use durable storage with backups
- •Enrichment Timeouts: Source database slowness delays indexing; implement circuit breakers and degraded modes
- •Message Queue Lag: High change volume exceeds processing capacity; monitor consumer lag and scale horizontally
- •Duplicate Processing: Failures after indexing but before checkpoint cause duplicates; ensure idempotent indexing
Every operation in your delta indexing pipeline must be idempotent—safe to execute multiple times with the same result. When failures occur (and they will), you'll restart from the last checkpoint and reprocess some events. Non-idempotent operations cause data corruption or duplicates.
Optimizing Delta Indexing Performance
Delta indexing performance directly impacts how fresh your search results are. Slow delta processing means stale data. Here are proven optimization techniques:
Batch Size Tuning
The optimal batch size balances latency and throughput:
- Too small (10-100): High per-batch overhead dominates; throughput suffers
- Too large (50,000+): Memory pressure and retry scope become problems; failures are expensive
- Sweet spot (1,000-10,000): Typically optimal for most workloads
Parallel Processing
Delta pipelines often have embarrassingly parallel workloads:
- Change consumption: Can shard by entity type or ID hash
- Enrichment: Multiple workers can enrich different documents concurrently
- Transformation: Stateless, infinitely parallelizable
- Indexing: Bulk requests can be sent concurrently to different shards
Enrichment Optimization
Enrichment (fetching full documents) is often the bottleneck:
- Batch fetching: Fetch 1,000 documents in one query, not 1,000 individual queries
- Caching: Cache frequently-accessed relations (categories, brands)
- Denormalization: Store complete documents in the change event, avoiding enrichment
Network Optimization
Minimize data transfer and connection overhead:
- Compression: Enable HTTP compression for bulk requests
- Connection pooling: Reuse HTTP connections to the search cluster
- Locality: Run indexing jobs in the same region as the search cluster
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
/** * Performance-optimized delta indexing with parallel processing. * * Achieves 50,000+ documents/second on typical hardware. */ interface PerformanceConfig { // Parallelism settings enrichmentConcurrency: number; // Parallel enrichment workers indexingConcurrency: number; // Parallel bulk request streams // Batch settings enrichmentBatchSize: number; // IDs per enrichment query indexingBatchSize: number; // Documents per bulk request // Caching settings relationCacheTTLSeconds: number; // TTL for category/brand cache relationCacheMaxSize: number; // Max cached relations} const PRODUCTION_CONFIG: PerformanceConfig = { enrichmentConcurrency: 8, indexingConcurrency: 4, enrichmentBatchSize: 500, indexingBatchSize: 2000, relationCacheTTLSeconds: 300, relationCacheMaxSize: 100_000,}; class OptimizedDeltaPipeline { private enrichmentSemaphore: Semaphore; private indexingSemaphore: Semaphore; private relationCache: LRUCache<string, Relation>; constructor(private config: PerformanceConfig) { this.enrichmentSemaphore = new Semaphore(config.enrichmentConcurrency); this.indexingSemaphore = new Semaphore(config.indexingConcurrency); this.relationCache = new LRUCache({ maxSize: config.relationCacheMaxSize, ttl: config.relationCacheTTLSeconds * 1000, }); } /** * Process changes with optimal parallelism. * * Uses a pipeline pattern: enrichment and indexing * happen concurrently on different batches. */ async processBatch(changes: ChangeEvent[]): Promise<void> { // Split into enrichment batches const enrichmentBatches = chunk( changes, this.config.enrichmentBatchSize ); // Process enrichment batches in parallel const enrichedDocs = await Promise.all( enrichmentBatches.map(batch => this.enrichmentSemaphore.runExclusive(() => this.enrichBatch(batch) ) ) ); // Flatten and split into indexing batches const allDocs = enrichedDocs.flat(); const indexingBatches = chunk(allDocs, this.config.indexingBatchSize); // Index batches in parallel await Promise.all( indexingBatches.map(batch => this.indexingSemaphore.runExclusive(() => this.indexBatch(batch) ) ) ); } /** * Batch enrichment with caching for relations. */ private async enrichBatch(changes: ChangeEvent[]): Promise<Document[]> { const entityIds = changes.map(c => c.entity_id); // Single query for all entities const entities = await this.db.query(` SELECT * FROM products WHERE id = ANY($1::uuid[]) `, [entityIds]); // Collect unique relation IDs const categoryIds = new Set(entities.map(e => e.category_id)); const brandIds = new Set(entities.map(e => e.brand_id).filter(Boolean)); // Fetch uncached relations const uncachedCategoryIds = [...categoryIds].filter( id => !this.relationCache.has(`category:${id}`) ); const uncachedBrandIds = [...brandIds].filter( id => !this.relationCache.has(`brand:${id}`) ); if (uncachedCategoryIds.length > 0) { const categories = await this.db.query( 'SELECT * FROM categories WHERE id = ANY($1::uuid[])', [uncachedCategoryIds] ); categories.forEach(c => this.relationCache.set(`category:${c.id}`, c) ); } if (uncachedBrandIds.length > 0) { const brands = await this.db.query( 'SELECT * FROM brands WHERE id = ANY($1::uuid[])', [uncachedBrandIds] ); brands.forEach(b => this.relationCache.set(`brand:${b.id}`, b) ); } // Build enriched documents return entities.map(entity => ({ ...entity, category: this.relationCache.get(`category:${entity.category_id}`), brand: entity.brand_id ? this.relationCache.get(`brand:${entity.brand_id}`) : null, })); } /** * Bulk index with compression and connection reuse. */ private async indexBatch(documents: Document[]): Promise<void> { const body = documents.flatMap(doc => [ { index: { _index: 'products', _id: doc.id } }, this.transformDocument(doc) ]); await this.esClient.bulk({ body, refresh: false, // Don't refresh after each bulk timeout: '30s', // Enable request compression headers: { 'Content-Encoding': 'gzip' } }); }}Monitoring Delta Indexing Pipelines
A delta indexing pipeline is only as good as your ability to understand its behavior. Comprehensive observability is essential for maintaining data freshness and diagnosing issues.
Key Metrics
Lag Metrics
- Consumer Lag: How far behind is your pipeline from the latest change?
- Checkpoint Age: How old is the last successfully indexed change?
- Queue Depth: How many changes are waiting to be processed?
Throughput Metrics
- Changes Processed/Second: Raw processing rate
- Documents Indexed/Second: Rate of bulk API calls
- Enrichment Batch Duration: Time to fetch full documents
Error Metrics
- Failed Documents: Count and rate of indexing failures
- Enrichment Errors: Missing relations, timeout errors
- Checkpoint Failures: Issues persisting progress
Resource Metrics
- Memory Usage: Batch buffers, caches, connections
- CPU Usage: Transformation and serialization overhead
- Network Throughput: Bandwidth to databases and search cluster
| Alert | Condition | Severity | Response |
|---|---|---|---|
| Lag Exceeds Threshold | Consumer lag > 1 hour | High | Scale indexers, check for bottlenecks |
| Indexing Failure Rate | Failures > 1% of documents | High | Check mapping conflicts, document validation |
| Enrichment Timeout | DB queries > 30s | Medium | Check database performance, add indexes |
| Checkpoint Staleness | No checkpoint update in 10min | Critical | Check pipeline health, possible crash |
| Memory Pressure | Heap > 90% | Medium | Reduce batch sizes, check for memory leaks |
| Dead Letter Queue Growth | DLQ size increasing | Medium | Investigate failed documents, fix root cause |
Define a freshness Service Level Objective (SLO), such as '99% of changes indexed within 5 minutes.' Monitor this continuously, alert when violated, and use it in capacity planning. This transforms abstract 'delta indexing performance' into a concrete, measurable target.
Change Data Capture Integration
Change Data Capture (CDC) represents the most robust approach to delta detection. By tapping into database transaction logs, CDC captures every change reliably, including deletes, with guaranteed ordering.
Why CDC for Search Indexing?
Traditional timestamp-based polling has fundamental limitations:
- Clock skew causes missed updates
- Polling frequency limits freshness
- Deletes are invisible
- Database load increases with polling frequency
CDC solves all of these by streaming changes as they're committed, providing:
- Completeness: Every committed change is captured
- Ordering: Changes within a transaction are ordered correctly
- Efficiency: Near-zero overhead on the source database
- Low Latency: Changes stream in real-time, not polled intervals
Popular CDC Tools
Debezium: Open-source platform supporting PostgreSQL, MySQL, MongoDB, SQL Server. Runs on Kafka Connect.
Maxwell: Lightweight MySQL CDC to Kafka. Simple but limited to MySQL.
AWS DMS: Managed CDC service for AWS databases. Supports various targets.
Google Datastream: CDC for Cloud SQL, AlloyDB to BigQuery, GCS, or Pub/Sub.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Debezium PostgreSQL connector configuration// Captures changes from products, categories, and brands tables { "name": "search-indexing-connector", "config": { // Connector class for PostgreSQL "connector.class": "io.debezium.connector.postgresql.PostgresConnector", // Database connection "database.hostname": "primary.db.example.com", "database.port": "5432", "database.user": "replication_user", "database.password": "${secrets.db_password}", "database.dbname": "ecommerce", // Logical replication slot name "slot.name": "search_indexing_slot", "plugin.name": "pgoutput", // What to capture "table.include.list": "public.products,public.categories,public.brands", // Kafka topic routing "topic.prefix": "cdc", // Produces topics: cdc.public.products, cdc.public.categories, etc. // Schema handling "key.converter": "org.apache.kafka.connect.json.JsonConverter", "value.converter": "org.apache.kafka.connect.json.JsonConverter", "key.converter.schemas.enable": "false", "value.converter.schemas.enable": "true", // Snapshot mode: what to do on first start "snapshot.mode": "initial", // Take snapshot then stream changes // Heartbeat for health monitoring "heartbeat.interval.ms": "10000", // Transforms for convenience "transforms": "unwrap", "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState", "transforms.unwrap.add.fields": "op,ts_ms", "transforms.unwrap.delete.handling.mode": "rewrite" }} // Sample Debezium change event (after ExtractNewRecordState transform){ "id": "12345", "name": "Wireless Headphones", "price": 79.99, "category_id": "cat-001", "brand_id": "brand-abc", "updated_at": "2024-01-15T10:30:00.000000Z", "__op": "u", // 'c' = create, 'u' = update, 'd' = delete "__ts_ms": 1705315800000, "__deleted": "false"}CDC introduces new failure modes: replication slot growth during connector outages, schema changes breaking parsing, and slot invalidation from long-running transactions. Plan for these scenarios with monitoring, slot size limits, and schema evolution strategies.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.