A Problem That's Easy to Overlook
Build the codebase knowledge base, run the first query, watch vector search and graph traversal return exactly what you wanted — and it's tempting to call the job done.
But the code keeps moving. New PRs merge. Functions get renamed. Interfaces change. New files appear, old modules get deleted. Three months later, the function signatures in your index may be stale, the call graph might point to a function that has been moved, and the embeddings might still represent a rewritten implementation. Your queries return plausible but wrong answers — and the failure is silent.
Knowledge base freshness is a harder problem than building the knowledge base in the first place, and it's much easier to ignore.
Full rebuild is simple: code changed, delete the index and rerun everything. For a medium-scale codebase that might take hours — you can't do it on every commit. The opposite extreme is 'never update' — let the index drift permanently away from the actual code, and watch retrieval quality degrade quietly over months.
This article charts the practical middle path: precisely determine which changes affect what, and rebuild only the parts that genuinely need it.
Starting From a Real detect_changes Output
Running detect_changes on the LightRAG codebase (HEAD~5 to HEAD) returns:
changed_files: [
".github/workflows/copilot-setup-steps.yml",
".github/workflows/tests.yml",
"lightrag_webui/bun.lock",
"lightrag_webui/package.json",
"lightrag_webui/README.md",
"README-ja.md",
"README.md",
"README-zh.md"
]
changed_count: 8
impacted_symbols: [
{name: "jobs", label: "Variable", file: ".github/workflows/..."},
{name: "LightRAG WebUI", label: "Section", file: "lightrag_webui/README.md"},
{name: "Installation", label: "Section", file: "lightrag_webui/README.md"},
...
]Eight changed files. The impacted_symbols list contains only CI variables (jobs, on) and Markdown headings (Section) — zero Functions, zero Methods, zero Classes.
The correct response to this change set is: do nothing.
Not because the changes are unimportant (updating CI config and README is real work) — but because none of these 8 files have any content that would affect the knowledge base's three retrieval paths. There's nothing from these files in the vector index, no nodes from them in the call graph, nothing to find in the symbol index. A full rebuild would be pure waste: hours of compute time and API costs, for zero improvement in retrieval quality.
This brings us to the first tier.
Tier 1: Does This Change Affect Retrievable Content?
Different file types have very different impacts on the knowledge base:
| File type | Affects vector index | Affects call graph | Affects symbol index | Decision |
|---|---|---|---|---|
.py function changes | Yes | Yes | Yes | Incremental update needed |
.ts / .tsx changes | Yes (if indexed) | Yes | Yes | Incremental update needed |
Test files (test_*.py) | Optional | Policy-dependent | Optional | Configure per team |
CI config (.yml) | No | No | No | Skip |
| Markdown / README | No | No | No | Skip |
package.json / lockfiles | No | No | No | Skip |
Config files (.env, .toml) | No | No | No | Skip |
The decision rule: Does impacted_symbols contain any entries with label equal to Function, Method, or Class? If not, skip this update entirely.
In practice, encode this as a git hook or CI step:
# Pseudocode — not runnable as-is
def should_update_index(changed_files: list[str]) -> bool:
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js'}
return any(
Path(f).suffix in CODE_EXTENSIONS
for f in changed_files
)For LightRAG's 5-commit window: {'.yml', '.lock', '.json', '.md'} — no code files. Return False. Index update cost for this batch = 0.
Tier 2: Which Functions Changed — Rebuild Only Those
When Tier 1 determines there are real code changes, enter Tier 2.
Suppose a change touches lightrag/operate.py and lightrag/pipeline.py. detect_changes returns the specific symbols that changed in those files. The strategy is:
Re-embed only the changed functions. Leave everything else untouched.
The underlying assumption: the embedding unit is a function, and functions are relatively self-contained semantic units. When a function's implementation changes, only its position in vector space needs to update — every other function's embedding vector remains valid.
The same principle applies to call graph updates:
- If
naive_queryinoperate.pyadds a new call to_find_related_text_unit_from_entities, update onlynaive_query's outgoing edges — don't rebuild the whole graph. - If a function is deleted, remove the corresponding node and all its edges.
Incremental update cost scales as (changed function count / total function count). LightRAG has 7,761 Function nodes. A change affecting 50 functions costs roughly 0.6% of a full rebuild.
Tier 3: High-Impact Changes — Trace the Call Chain
Tiers 1 and 2 handle 'I know which functions directly changed.' Tier 3 answers a deeper question: do these direct changes affect functions that themselves haven't changed?
Two signals matter here.
3a. Call graph propagation
Suppose a low-level function in parse_document.py changes its interface. It's called by analyze_multimodal in pipeline.py, which is called by the top-level API in lightrag.py. Every function in this chain may have changed behavior in the new version — even though only the leaf function is in the diff.
trace_path("changed_function", mode=calls, direction=inbound)
→ returns all upstream callers that depend on this function
→ flag these callers as 'potentially affected' (re-analyze, don't necessarily re-embed)3b. FILE_CHANGES_WITH: the hidden signal of temporal coupling
This is an edge type in codebase-memory-mcp that's easy to overlook but highly valuable. Its meaning: in historical git commits, these two files were frequently modified together.
Looking at the LightRAG knowledge graph's FILE_CHANGES_WITH edges reveals a clear pattern:
Selected FILE_CHANGES_WITH pairs:
FileProcessingPipeline.md ↔ routing.py # docs and routing code evolve together
FileProcessingPipeline.md ↔ parser.py # docs and parser evolve together
operate.py ↔ utils.py # extraction logic and utilities coupled
operate.py ↔ prompt.py # extraction logic and prompts move together
pipeline.py ↔ utils_pipeline.py # pipeline core and pipeline utilities
base.py ↔ lightrag.py # abstract base and main class evolve together
config.py ↔ lightrag.py # configuration and main class
chunk_schema.py ↔ pipeline.py # chunk schema and pipeline
document_routes.py ↔ routing.py # document API and routing layerThese relationships weren't derived from static analysis — they were mined from git history. What they tell you:
When
operate.pychanges, history shows a high probability thatutils.pyandprompt.pychanged simultaneously — even if this specific diff doesn't show them changing. If you only rebuildoperate.py's index, you might miss a synchronized adjustment inprompt.py's knowledge extraction logic.
This is coupling that static analysis can never surface. Function A doesn't call function B. File X doesn't import file Y. But historical evidence says they always move together — that's an implicit architectural convention, only visible in temporal data.
Practical use: when computing an incremental update, include the FILE_CHANGES_WITH neighbors of changed files in your inspection scope, even if they didn't change in this specific commit.
The Three-Tier Decision Tree
Assembled into an operational flow:
Tier 1: detect_changes(since=last_index_commit)
├── impacted_symbols all Section/Variable/Module?
│ └── Skip. Check again next time.
└── Function/Method/Class changes present?
│
Tier 2: Extract list of changed functions
├── Re-embed changed functions (overwrite old vectors)
├── Update call graph edges locally (add/remove/modify)
└── Update symbol index (renames/deletes/additions)
│
Tier 3: Expand impact scope
├── trace_path(changed_fn, direction=inbound)
│ └── Flag upstream callers as 'potentially affected'
└── FILE_CHANGES_WITH(changed_files)
└── Add historical co-change neighbors to next review cycleCost estimates for LightRAG:
| Scenario | Changed functions | Update cost | % of full rebuild |
|---|---|---|---|
| CI + README update | 0 | 0 | 0% |
| Small feature (1-2 files) | 5-20 | Minutes | < 1% |
| Module refactor (5-10 files) | 50-200 | 10-30 min | 2-4% |
| Large refactor (20+ files) | 500+ | Trigger full rebuild | 100% |
A Real Data Point: High-Complexity Functions and Change Cost
Querying for the highest-complexity functions in LightRAG:
MATCH (f) WHERE f.label IN ['Function','Method'] AND f.complexity > 15
RETURN f.name, f.file_path, f.complexity, f.transitive_loop_depth
ORDER BY f.complexity DESC LIMIT 5Results (excluding the bundled swagger-ui file):
adelete_by_doc_id lightrag/lightrag.py complexity=131 transitive_loop_depth=14
create_document_routes lightrag/api/routers/... complexity=118
analyze_multimodal lightrag/pipeline.py complexity=116 transitive_loop_depth=14
create_app lightrag/api/lightrag_server.py complexity=91
openai_complete_if_cache lightrag/llm/openai.py complexity=89adelete_by_doc_id has complexity=131 and transitive_loop_depth=14 — its execution path nests 14 levels deep, making it one of the hardest functions in the codebase to reason about and one of the most likely to propagate change effects.
If this function appears in a change set, Tier 3 isn't optional — every change to it requires a full inbound call-chain trace to verify that upstream callers' behavioral assumptions still hold.
Conversely, if the change is a paragraph in README.md, this complexity data is irrelevant to you. The first value of detect_changes is that it filters out the noisy commits — CI tweaks, doc fixes, dependency bumps — at Tier 1, before any analysis needs to happen.
Summary
Incremental updates are fundamentally a precision problem, not an engineering-effort problem. Full rebuild is simpler to implement. Incremental updates require finer-grained decision logic. But once that logic exists, the payoff compounds.
A three-tier incremental update system lets roughly 80% of commits (docs, config, test-only) bypass index updates entirely, and the remaining 20% (real code changes) rebuild only the affected fraction. For a continuously evolving codebase, this isn't an optimization — it's what makes the knowledge base viable over months and years.
Next article: we extend the scope from a single codebase to multi-repo scenarios — when one service calls another service's API, or when microservices communicate through message queues, how should the knowledge base build connections that span repository boundaries?
Follow me at dongqi.dev for more LLM engineering content.
At PrimeSkills, we help engineering teams set up continuous knowledge base maintenance pipelines — including incremental update strategy implementation and integration with CI/CD workflows. If your team is dealing with an index that's drifting away from the actual codebase, reach out.