Skip to content
← Writing

Incremental Code Index Refresh for AI Coding Agents Without Full Rebuilds

15 Jun 2026 · 6 min read

  • Code Retrieval
  • AI Coding Agents
  • Indexing
  • Monorepos
  • Search Infrastructure

A lot of repository retrieval stacks for coding agents still rely on a painful bargain. You either rebuild the whole index often enough to stay fresh, or you accept stale chunks and hope the model notices when a symbol moved three directories ago.

That trade is worse in monorepos. Full rebuilds chew CPU, contend with CI, and lag behind active branches. Stale retrieval is cheaper until it sends the agent toward deleted files, old interfaces, or code paths that were valid yesterday.

This post covers the workflow I would use instead: a commit-driven dirty set, symbol-level invalidation, bounded backfills, and a freshness ledger that makes index drift visible.

Why this matters

Fresh retrieval is part of agent reliability, not just search quality. If your code index is stale, the model can reason perfectly and still choose the wrong evidence packet.

In practice, an index refresh system has to answer four boring but important questions:

  • what changed since the last indexed commit?
  • which symbols and chunks are now unsafe to serve?
  • how quickly do we need updated embeddings or metadata?
  • what do we do when refresh falls behind?

Useful references: Tree-sitter, ripgrep, Sourcegraph code search, and OpenTelemetry.

Architecture or workflow overview

The trick is to separate detection, invalidation, recompute, and serving. A small dirty set beats a full rebuild almost every time.

flowchart LR
    A[Git change feed\nmerge commit or branch head] --> B[Dirty set builder\nfiles, symbols, ownership tags]
    B --> C[Indexer workers\nAST parse, chunk rebuild, embedding refresh]
    C --> D[Freshness ledger\nindexed commit, chunk version, lag]
    D --> E[Retrieval gateway\nserve fresh chunks or downgrade safely]
    D --> F[Backfill queue\nlow-priority cold paths]

Implementation details

1) Build a dirty set from commits, not filesystem timestamps

File mtimes are too noisy in CI and too weak across rebases. I prefer a commit-based feed that records changed paths, commit SHA, and merge base.

from dataclasses import dataclass
from pathlib import Path
import subprocess

@dataclass
class DirtyPath:
    path: str
    commit_sha: str
    change_type: str

def changed_paths(base_sha: str, head_sha: str, repo: Path) -> list[DirtyPath]:
    cmd = [
        'git', 'diff', '--name-status', f'{base_sha}..{head_sha}'
    ]
    output = subprocess.check_output(cmd, cwd=repo, text=True)
    dirty = []
    for line in output.splitlines():
        change_type, path = line.split('\t', 1)
        dirty.append(DirtyPath(path=path, commit_sha=head_sha, change_type=change_type))
    return dirty

This gives you a stable starting point for invalidation. Renames and deletes matter because they can leave orphan chunks behind even if retrieval still returns a high similarity score.

2) Invalidate by symbol neighborhood, not just file path

If auth/token.py changes, the right blast radius is usually bigger than one file and much smaller than the whole repo. I like a symbol graph that marks direct definitions, imports, and ownership edges as dirty.

from collections import defaultdict

def expand_dirty_symbols(changed_files: list[str], symbol_index: dict, import_graph: dict) -> set[str]:
    dirty_symbols = set()
    for path in changed_files:
        for symbol in symbol_index.get(path, []):
            dirty_symbols.add(symbol)
            dirty_symbols.update(import_graph.get(symbol, []))
    return dirty_symbols

That gives you a better refresh slice for chunking, summaries, embedding recompute, and repo-map snippets.

3) Make serving freshness-aware

Retrieval should know when a chunk is stale. Otherwise the index can fall behind quietly and the agent will still consume the results as if they were current.

retrieval_gate:
  max_commit_lag: 12
  max_age_minutes: 20
  high_risk_paths:
    - services/auth/**
    - infra/terraform/**
    - billing/**
  stale_action:
    default: degrade
    high_risk: block
  fallback_search:
    - rg_exact
    - symbol_lookup

My default is:

  • degrade to exact search plus live file reads for normal paths
  • block stale semantic chunks in high-risk directories
  • emit a trace attribute so stale-serving becomes visible in dashboards

4) Keep a freshness ledger that humans can inspect

The ledger is the part most teams skip, and it is the part that turns indexing bugs from folklore into operations.

{
  "repo": "github.com/acme/monorepo",
  "head_commit": "8a54d8c",
  "indexed_commit": "c1d44fe",
  "oldest_dirty_commit": "7b1f1a0",
  "queue_depth": 184,
  "stale_chunks_served_last_hour": 7,
  "high_risk_paths_blocked": 3
}

A good ledger tells you whether the system is healthy, degraded, or actively lying.

$ indexctl freshness status --repo acme/monorepo
HEAD COMMIT             8a54d8c
INDEXED COMMIT          c1d44fe
COMMIT LAG              11
OLDEST DIRTY AGE        14m
QUEUE DEPTH             184
STALE SERVES (1H)       7
HIGH-RISK BLOCKS        3
MODE                    DEGRADED, SAFE FALLBACK ACTIVE

What went wrong and the tradeoffs

Failure mode 1, delete and rename churn leaves ghost chunks

The classic bug is that the embedding store still holds vectors for a file that was renamed or removed. Similarity search happily returns them because the text is still relevant even though the path is dead.

Pitfall: if your invalidation layer cannot tombstone deleted chunks immediately, your retrieval stack will eventually serve ghosts.

Failure mode 2, aggressive neighborhood expansion becomes a stealth full rebuild

Symbol-level invalidation is useful until every hot package depends on every other hot package. Put a cap on neighborhood growth and spill the rest into a background backfill queue.

Failure mode 3, lazy-on-read refresh looks cheap until traffic spikes

Refreshing chunks only when a query touches them is attractive, but it pushes indexing latency into the interactive path. That is fine for cold paths and bad for auth, migrations, deploy code, and incident tooling.

Strategy Best for Good part Main risk
Full rebuild small repos, nightly jobs simplest correctness story expensive and slow under churn
Incremental refresh active large repos fast freshness with bounded work needs better invalidation design
Lazy on read long-tail cold code minimal background cost query latency and stale bursts

Security and reliability concerns

  • stale index entries can route agents into old secret-handling code or pre-hardening paths
  • rebases can make branch-local freshness look better than it is unless you compare against the right merge base
  • backlog growth should trip admission controls before retrieval quality collapses silently

Practical checklist

Best practice: if the index is not fresh enough to trust, let retrieval say that clearly and downgrade on purpose.

  • build dirty sets from commits, not mtimes
  • tombstone deleted and renamed chunks immediately
  • expand invalidation by symbol neighborhood, with a hard cap
  • separate hot-path refresh from low-priority backfills
  • track commit lag, oldest dirty age, queue depth, and stale serves
  • block stale semantic retrieval for security-sensitive paths
  • keep exact search and live file reads available as safe fallback tools
  • sample stale incidents weekly and compare them against agent failures

Conclusion

The best retrieval stack for coding agents is not the one with the biggest index. It is the one that stays fresh enough to trust under real code churn. Incremental refresh, symbol-aware invalidation, and a visible freshness ledger get you there without paying for full rebuilds every hour.