Incremental Sync & Index Freshness

10 min read Module 2 of 9 Topic 6 of 25

What you'll learn

  • Replace scheduled full crawls with change-driven sync using webhooks, delta APIs, and content hashes
  • Handle the three change types correctly: create, update (re-chunk and re-embed), and delete (tombstone everywhere)
  • Run a zero-downtime full reindex with the two-index alias swap pattern
  • Instrument sync lag as a first-class metric alongside content and permission freshness
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Lesson 1 called freshness a first-class constraint, alongside permissions, auditability, and scale. Here’s the uncomfortable arithmetic that makes it one: parsing plus chunking plus embedding a large corpus costs hours of compute time and real API dollars, as the previous two lessons made concrete. Do that nightly as a full rebuild, the naive approach most teams reach for first, and you get the worst of both worlds simultaneously: answers up to 24 hours stale, because the rebuild only runs once a day, and a five-figure monthly ingestion bill, most of it spent re-processing documents that did not actually change since yesterday’s run.

The fix is to stop treating ingestion as a batch job that runs on a clock and start treating it as a change stream that runs on events, which is a genuinely different architecture, not just a scheduling tweak.

Change-driven sync

Every serious source system offers some way to learn what changed without re-reading everything from scratch, and the specific mechanism varies by source but the pattern is consistent:

  • Webhooks: Slack, Notion, GitHub, and Jira push change events to you within seconds of the change happening, which is the fastest and most efficient mechanism where it’s available.
  • Delta APIs: Google Drive’s changes feed and Microsoft Graph’s delta queries return “everything since cursor X” in one cheap, paginated call, letting you poll efficiently even on sources without webhook support.
  • Polling with cursors: for sources with neither webhooks nor a delta API, poll a modified_since listing on a short interval. Crude compared to the other two, but still meaningfully incremental compared to a full nightly crawl of everything.

Events land in a queue, and workers process one document at a time through the same parse-chunk-embed path built in the previous two lessons, unchanged. The queue absorbs bursts gracefully: a folder migration that suddenly emits thousands of change events at once doesn’t overwhelm the system, it just takes the workers longer to drain the backlog, and the queue gives you retries for free when an individual document fails to process.

flowchart LR
    W["Webhooks +\ndelta APIs"] --> Q[("Change queue")]
    P["Cursor polling\n(legacy sources)"] --> Q
    Q --> WK["Ingestion workers"]
    WK --> H{"Content hash\nchanged?"}
    H -- "No" --> M["Update metadata\n+ ACLs only"]
    H -- "Yes" --> PIPE["Parse, chunk,\nembed, upsert"]
    WK --> DEL{"Delete event?"}
    DEL -- "Yes" --> TOMB["Tombstone: remove chunks\nfrom every index"]

    style W fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style P fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Q fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style WK fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style H fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style M fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style PIPE fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style DEL fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style TOMB fill:#EEF0F7,stroke:#6366F1,color:#0F172A

The three change types, and the one everyone forgets

Creates are easy: run the full pipeline, upsert the resulting chunks. There’s no prior state to reconcile against, so there’s little to get wrong here beyond ordinary error handling.

Updates hide a trap that catches teams who build this for the first time. You can’t just add the new version’s chunks alongside the old ones; the old version’s chunks must be removed, or a stale duplicate sits in the index forever, silently competing for retrieval rank against the current version and sometimes winning. The clean pattern is delete-then-insert keyed by doc_id: remove every chunk whose doc_id matches, then write the new set. Do it as a single transaction, or with a version tag that lets you filter atomically, so queries never see a half-updated document with half its chunks from the old version and half from the new. And hash before you do any of that expensive work, because a large share of “modified” events in practice change nothing about the actual text:

import hashlib

async def handle_update(event: ChangeEvent):
    raw = await fetch_from_source(event.doc_id)
    parsed = await parse(raw)  # the ParsedDoc from Lesson 4

    # Hash the normalized CONTENT, not the raw file bytes.
    # Raw bytes change when metadata changes; content does not.
    content = "\n".join(b.text for b in parsed.blocks)
    new_hash = hashlib.sha256(content.encode()).hexdigest()

    stored = await index.get_doc_meta(event.doc_id)

    if stored and stored.content_hash == new_hash:
        # Text unchanged: skip parse->embed cost entirely,
        # but ACLs or location may have moved, so sync those.
        await index.update_metadata(
            event.doc_id, acl_tags=parsed.acl_tags,
            url=parsed.url, updated_at=parsed.updated_at,
        )
        return

    # Text changed: atomically replace this doc's chunks.
    chunks = chunk(parsed)                  # Module 3
    vectors = await embed([c.text for c in chunks])
    await index.replace_doc(event.doc_id, chunks, vectors,
                            content_hash=new_hash)

Hashing normalized content rather than raw file bytes is the detail that makes this efficient in practice. Raw bytes change whenever anything about the file changes, including things that have nothing to do with the text: a permission grant, a folder move, a tag added by an automation bot. Content hashing sees straight through all of that noise and only triggers the expensive re-parse-chunk-embed path when the words a reader would actually see have genuinely changed.

Deletes are the change type everyone forgets, and the one with real legal teeth attached. A document deleted upstream, for any reason including a legal hold expiring or a compliance-driven purge, must be tombstoned out of the vector index, the lexical index, any caches holding derived answers, and any other derived artifacts built from it. Many delta APIs report deletions grudgingly, incompletely, or not at all, treating them as a second-class event type compared to creates and updates. So belt-and-suspenders systems also run a periodic reconciliation job: list all currently-live document IDs at the source, diff that list against what the index currently holds, and purge the orphans, the entries whose source document no longer exists anywhere. When legal deletes a document, “it’s still in the vector database somewhere” is not a sentence you want to be explaining to anyone.

Zero-downtime reindexing

Sometimes everything must be rebuilt at once, not incrementally: a new embedding model that isn’t compatible with existing vectors, a new chunking strategy, a new parser that handles tables better than the old one. This is the moment the incremental machinery above doesn’t help you, and you need a different pattern entirely. It’s blue-green, borrowed directly from web deployment practice:

  1. Build index_v2 completely in the background, running the full corpus through the new pipeline, while index_v1 keeps serving all live traffic uninterrupted.
  2. Run your evaluation suite from Module 8 against v2 before anyone sees it. This is the gate: no eval pass, no swap, full stop, regardless of how confident the change felt during development.
  3. Point the serving alias at v2. This is one atomic configuration change, not a data migration, so it happens with zero downtime.
  4. Keep v1 around for a day or two after the swap. Rollback, if the new version misbehaves under real traffic in a way the eval suite didn’t catch, is simply repointing the alias back.

One detail that trips up teams running this for the first time: keep live change events flowing into both indexes during the build phase, not just v1. Otherwise v2 launches already stale by however long the build took, sometimes hours, which defeats a meaningful part of the point of doing a careful, validated cutover.

Freshness earns a number on your dashboard

All of this machinery exists to serve one measurable outcome: sync lag, the elapsed time from a source change happening to that change being reflected in a queryable chunk. This number belongs on your operational dashboard from day one, not added later once someone complains about stale answers. Webhook-backed sources should sit comfortably under a minute of lag in steady state; polled sources should sit under their configured poll interval, with some margin. When lag spikes, meaning the queue is backing up or a source’s webhook silently stopped firing, you want an alert firing on your dashboard, not a user report arriving in a support channel days later as the first signal something went wrong.

With documents now flowing in clean, current, and correctly tombstoned when removed, Module 3 turns to the question that decides retrieval quality more than any single model choice you’ll make: how to cut these documents into chunks that are actually findable.

Knowledge Check

4 questions to test your understanding

1 A policy document was deleted from SharePoint on Monday. On Thursday the assistant still quotes it. Which ingestion failure is this?

2 You are switching embedding models, which requires re-embedding all 40 million chunks. How do you do this without downtime or mixed-model result quality?

3 Why hash normalized content instead of trusting source 'modified' timestamps to decide whether to re-embed?

4 A source system's delta API reliably reports creates and updates but is known to silently drop delete events under certain conditions. What belt-and-suspenders design closes this gap?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan