大模型应用实战

代码库知识库系列(10):增量更新——什么时候该重建索引,重建哪些部分

代码在持续演进,知识库不可能每次 commit 都全量重建。这篇文章从一次真实的 detect_changes 输出出发,设计一个三层决策树:先判断本次变更是否影响可检索内容,再精确找到变更的函数,最后通过 FILE_CHANGES_WITH 边发现隐藏的时序耦合——只重建真正需要重建的部分,把增量更新成本压到全量的一个零头。

·约 10 分钟阅读·AI Engineering

一个很快会被忽视的问题

如果你按照前几篇的方案建好了代码库知识库,第一次跑通查询,看到向量搜索和图遍历精准返回结果——那种感觉会让人觉得"大功告成"。

然后代码库继续演进。新 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 变量(jobson)和 Markdown 标题(Section)——零个 Function、零个 Method、零个 Class。

这次变更的正确处理方式是:什么都不做。

不是因为变更不重要(更新 CI 配置和 README 都是真实工作),而是因为这 8 个文件里没有任何内容会影响代码库知识库的三路检索路径——向量索引里没有这些文件的内容,调用图里没有这些文件的节点,符号索引里也找不到它们。全量重建不仅不必要,还会白白消耗几十分钟和 API 费用。

这就引出了第一层判断。


第一层:这次变更影响可检索内容吗?

不同类型的文件对知识库的影响截然不同:

文件类型影响向量索引影响调用图影响符号索引结论
.py 函数变更需要增量更新
.ts / .tsx 变更是(如果已索引)需要增量更新
测试文件 (test_*.py)可选取决于策略可选根据配置决定
CI 配置 (.yml)跳过
Markdown / README跳过
package.json / 锁文件跳过
配置文件 (.env, .toml)跳过

判断规则: impacted_symbols 里是否存在 labelFunctionMethodClass 的节点?如果没有,直接跳过本次更新。

实际工程中,可以把这个规则写成一个 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.pylightrag/pipeline.pydetect_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.pyprompt.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 更新000%
小功能修改 (1-2 文件)5-20几分钟< 1%
模块重构 (5-10 文件)50-20010-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=89

adelete_by_doc_idcomplexity=131transitive_loop_depth=14——这意味着它的执行路径嵌套深度可达 14 层,是整个代码库里最难理解、最容易引入 bug 的函数。

如果这个函数出现在变更列表里,第三层的扩展分析就不是"可选项"而是"必选项"——它的每一次变更都要追溯完整的入度调用链,确认上游调用方的行为假设是否还成立。

反过来,如果变更的是 README.md 里的一段说明文字,这个复杂度数据与你无关。detect_changes 的第一个价值,就是让大量无关的"噪声 commit"在第一层就被过滤掉。


总结

增量更新的本质是一个精确度问题,不是工程量问题。全量重建在工程上是最简单的,增量更新反而需要更精细的判断逻辑。

但判断逻辑一旦建立,收益是持续的。一个有三层决策的增量更新系统,可以让 80% 的 commit(文档、配置、测试)完全跳过索引更新,让剩下 20% 的代码变更只重建受影响的那一小部分。对一个持续演进的项目来说,这不是优化——这是让知识库保持长期可用的前提条件。

下一篇,我们把视角从单一代码库扩展到多库场景:当一个服务调用另一个服务的 API,或者当微服务之间通过消息队列通信,知识库应该如何在库与库之间建立跨越边界的连接。


如果这个系列对你有帮助,欢迎关注我的个人主页 dongqi.dev,持续更新 LLM 工程实践内容。

在 PrimeSkills,我们帮助工程团队建立代码库知识库的持续维护流程,包括增量更新策略的落地和与 CI/CD 流水线的集成。如果你的团队正在为"索引越来越旧"的问题头疼,欢迎联系我们探讨解决方案。

primeskills.dev