一个很快会被忽视的问题
如果你按照前几篇的方案建好了代码库知识库,第一次跑通查询,看到向量搜索和图遍历精准返回结果——那种感觉会让人觉得"大功告成"。
然后代码库继续演进。新 PR 合入,函数被重命名,接口被修改,新文件出现,旧模块被删除。三个月后你再拿同一套查询去问,索引里的函数签名可能已经失效,调用图可能指向一个已经被移走的函数,embedding 可能还在用一个被重写过的实现来表示语义。
知识库的时效性问题,比建库问题更难解决,也更容易被忽视。
全量重建最简单——代码变了,把整个索引删掉重跑。但对一个中型代码库来说,全量索引可能需要数小时,不可能每次 commit 都跑一遍。另一个极端是"不更新"——永远用初始索引,越来越偏离实际代码,查询结果越来越不可信。
这篇文章要做的,是在两个极端之间找到工程上实用的中间路径:精确判断哪次变更影响了什么、只重建真正需要重建的部分。
从一次真实的 detect_changes 说起
对 LightRAG 代码库(HEAD~5 到 HEAD)运行 detect_changes,返回:
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"},
...
]8 个变更文件,impacted_symbols 清单里只有 CI 变量(jobs、on)和 Markdown 标题(Section)——零个 Function、零个 Method、零个 Class。
这次变更的正确处理方式是:什么都不做。
不是因为变更不重要(更新 CI 配置和 README 都是真实工作),而是因为这 8 个文件里没有任何内容会影响代码库知识库的三路检索路径——向量索引里没有这些文件的内容,调用图里没有这些文件的节点,符号索引里也找不到它们。全量重建不仅不必要,还会白白消耗几十分钟和 API 费用。
这就引出了第一层判断。
第一层:这次变更影响可检索内容吗?
不同类型的文件对知识库的影响截然不同:
| 文件类型 | 影响向量索引 | 影响调用图 | 影响符号索引 | 结论 |
|---|---|---|---|---|
.py 函数变更 | 是 | 是 | 是 | 需要增量更新 |
.ts / .tsx 变更 | 是(如果已索引) | 是 | 是 | 需要增量更新 |
测试文件 (test_*.py) | 可选 | 取决于策略 | 可选 | 根据配置决定 |
CI 配置 (.yml) | 否 | 否 | 否 | 跳过 |
| Markdown / README | 否 | 否 | 否 | 跳过 |
package.json / 锁文件 | 否 | 否 | 否 | 跳过 |
配置文件 (.env, .toml) | 否 | 否 | 否 | 跳过 |
判断规则: impacted_symbols 里是否存在 label 为 Function、Method、Class 的节点?如果没有,直接跳过本次更新。
实际工程中,可以把这个规则写成一个 git hook 或 CI step:
# 伪代码(非可运行)
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
)对于 LightRAG 这个例子:{'.yml', '.lock', '.json', '.md'} — 没有代码文件,直接返回 False,本次 5 个 commit 的索引更新成本 = 0。
第二层:哪些函数改了,只重建那些
当第一层判定"确实有代码变更"时,进入第二层。
假设一次变更包含 lightrag/operate.py 和 lightrag/pipeline.py,detect_changes 会返回这些文件里改动的具体符号。这时候的策略是:
只重新 embed 发生变更的函数,其他函数保持不变。
这个策略背后有一个预设:embedding 的单元是函数,而函数是相对独立的语义单位。一个函数的实现改变了,只影响这个函数在向量空间里的位置;没改变的函数,它们的 embedding 向量仍然有效。
同样地,调用图的更新也是局部的:
- 如果
operate.py里的naive_query新增了一个对_find_related_text_unit_from_entities的调用,只需要在图里更新naive_query的出边,不需要重建整个图。 - 如果一个函数被删除了,只需要删除对应节点及其边。
局部更新的成本大约是全量重建的 (变更函数数 / 总函数数)。 LightRAG 有 7,761 个 Function 节点,如果一次变更影响了 50 个函数,更新成本大约是全量的 0.6%。
第三层:哪些高影响函数改了,触发调用链重分析
前两层处理的是"我知道哪些函数直接变了"。第三层要回答一个更深的问题:这些直接变化,会影响哪些本身没有改变的函数?
这里用到两类数据。
3a. 调用图的传播
假设 parse_document.py 里的某个低层函数改了接口,它被 pipeline.py 里的 analyze_multimodal 调用,analyze_multimodal 又被 lightrag.py 里的顶层 API 调用。从变更文件到顶层 API 之间的整条调用链,在新版本里行为都可能发生了变化——哪怕中间的函数没有直接修改。
trace_path("changed_function", mode=calls, direction=inbound)
→ 找出所有依赖这个函数的上游调用方
→ 这些调用方也需要重新分析(但不一定需要重新 embed)3b. FILE_CHANGES_WITH:时序耦合的隐藏信号
这是 codebase-memory-mcp 里一个容易被忽略但非常有价值的边类型。它的含义是:在历史 git 提交中,这两个文件经常一起修改。
查一下 LightRAG 代码库里这类边的分布,能看到一些有趣的规律:
FILE_CHANGES_WITH 示例(部分):
FileProcessingPipeline.md ↔ routing.py # 文档和路由代码同步演进
FileProcessingPipeline.md ↔ parser.py # 文档和解析器同步演进
operate.py ↔ utils.py # 知识提取和工具函数耦合
operate.py ↔ prompt.py # 知识提取和提示词同步变化
pipeline.py ↔ utils_pipeline.py # 管道核心和管道工具函数
base.py ↔ lightrag.py # 抽象基类和主类同步演进
config.py ↔ lightrag.py # 配置和主类同步演进
chunk_schema.py ↔ pipeline.py # 分块 schema 和管道
document_routes.py ↔ routing.py # 文档 API 和路由层这些关系不是通过 AST 静态分析得到的,而是从 git 历史里挖出来的。它告诉你的信息是:
当
operate.py变了,历史上有很高概率utils.py和prompt.py也同时发生了修改——哪怕这次 diff 里没有看到它们变。如果你只重建operate.py的索引,可能会错过prompt.py里同步调整的知识提取逻辑。
这是静态分析永远发现不了的耦合。 函数 A 不调用函数 B,文件 X 不 import 文件 Y,但历史经验告诉你它们总是一起动——这是一种"隐式的架构约定",只有在时序数据里才能看到。
使用方式:当你做增量更新时,把变更文件的 FILE_CHANGES_WITH 邻居也纳入检查范围,哪怕它们这次没有直接改动。
三层决策树汇总
把以上逻辑整理成一个可操作的流程:
Level 1: detect_changes(since=last_index_commit)
├── impacted_symbols 全是 Section/Variable/Module?
│ └── 跳过,下次再检查
└── 有 Function/Method/Class 改动?
│
Level 2: 提取变更的函数列表
├── 重新 embed 这些函数(覆盖旧向量)
├── 更新调用图的局部边(增/删/改)
└── 更新符号索引(重命名/删除/新增)
│
Level 3: 扩展影响范围
├── trace_path(changed_fn, direction=inbound)
│ └── 标记上游调用方为"可能受影响"
└── FILE_CHANGES_WITH(changed_files)
└── 检查历史共变邻居,纳入下一轮复核这个流程的成本估算(以 LightRAG 为例):
| 场景 | 变更函数数 | 索引更新成本 | 占全量比例 |
|---|---|---|---|
| CI + README 更新 | 0 | 0 | 0% |
| 小功能修改 (1-2 文件) | 5-20 | 几分钟 | < 1% |
| 模块重构 (5-10 文件) | 50-200 | 10-30 分钟 | 2-4% |
| 大型重构 (20+ 文件) | 500+ | 触发全量重建 | 100% |
一个真实验证:高复杂度函数的变更成本
运行 Cypher 查询,找 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 5结果(去掉 swagger-ui 打包文件噪声):
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 的 complexity=131,transitive_loop_depth=14——这意味着它的执行路径嵌套深度可达 14 层,是整个代码库里最难理解、最容易引入 bug 的函数。
如果这个函数出现在变更列表里,第三层的扩展分析就不是"可选项"而是"必选项"——它的每一次变更都要追溯完整的入度调用链,确认上游调用方的行为假设是否还成立。
反过来,如果变更的是 README.md 里的一段说明文字,这个复杂度数据与你无关。detect_changes 的第一个价值,就是让大量无关的"噪声 commit"在第一层就被过滤掉。
总结
增量更新的本质是一个精确度问题,不是工程量问题。全量重建在工程上是最简单的,增量更新反而需要更精细的判断逻辑。
但判断逻辑一旦建立,收益是持续的。一个有三层决策的增量更新系统,可以让 80% 的 commit(文档、配置、测试)完全跳过索引更新,让剩下 20% 的代码变更只重建受影响的那一小部分。对一个持续演进的项目来说,这不是优化——这是让知识库保持长期可用的前提条件。
下一篇,我们把视角从单一代码库扩展到多库场景:当一个服务调用另一个服务的 API,或者当微服务之间通过消息队列通信,知识库应该如何在库与库之间建立跨越边界的连接。
如果这个系列对你有帮助,欢迎关注我的个人主页 dongqi.dev,持续更新 LLM 工程实践内容。
在 PrimeSkills,我们帮助工程团队建立代码库知识库的持续维护流程,包括增量更新策略的落地和与 CI/CD 流水线的集成。如果你的团队正在为"索引越来越旧"的问题头疼,欢迎联系我们探讨解决方案。