用 Spring AI 为依赖源码建立可引用问答
教程使用 Spring AI 与 PgVector 索引项目依赖源码,涵盖去重、按 token 分块及通过 MCP 触发索引。聊天接口从检索元数据生成文件引用,帮助模型依据项目实际使用的 API 回答问题。
AI 深度解读
模型能解释一个库,不代表它知道项目刚升级的 API。原文搭了一套本地源码检索增强生成(RAG)系统:把实际依赖的源码存进 PgVector,再让 Spring AI 检索代码、生成回答,并返回文件引用。
它最有价值的部分是索引维护和引用可信度:重复运行怎么避免重复付费,源码切块后怎么清理旧数据,以及为什么接上 RAG,模型仍然会编造文件路径。这些问题,比跑通一次问答更接近日常使用。
源码索引能重复运行,才有实际用途
项目使用 Spring Boot 4.1、Spring AI 和 PgVector。Gradle 任务 downloadDependencySources 下载运行时依赖的 source jar,解压到 sources/;SourcesIndexerService 遍历文件并建立索引,SourcesQueryService 查询存储状态。index_sources MCP 工具负责触发索引,/api/chat 负责问答。
两条入口需要分清:原文没有把问答自动暴露给外部 MCP agent;如果需要,还要单独封装 CodeAssistantService.ask(...)。
索引会反复执行。随机文档 ID 会让同一文件不断新增记录,占满 Top-K 检索结果。作者因此组合了三层机制:
- 用「库名+源码路径」生成稳定 ID,区分内容相同的不同文件。
- 把文件的 CRC32 checksum 存进 metadata,判断内容是否变化。
- 用
JdbcTemplate提前查询,只有新增或变化的文件才进入 embedding。
稳定 ID 防止重复存储,提前查重才减少 embedding 调用。
new Document(text, metadata) 默认生成随机 ID,指定 ID 要用 new Document(id, text, metadata)。PgVector 通过 ON CONFLICT (id) DO UPDATE 更新已有行,但 embedding 在写库之前就完成了。
直接用向量搜索查 checksum 也有隐形成本:即使传空查询、topK(1) 和零阈值,similaritySearch(...) 仍然会调用 embedding 模型生成查询向量。作者改用 SQL,查询 public.vector_store 中指定 id 的 metadata ->> 'checksum'。
可读路径 ID 还要求设置 spring.ai.vectorstore.pgvector.id-type=TEXT,默认 ID 类型是 UUID。保留默认类型时,就需要确定性 UUID。
代价也明确:这套预检查绑定了 PgVector 的表结构,迁移向量库时需要调整。
切块不只是防止超长,还会改变更新规则
原文遇到的异常来自 spring-ai-model-2.0.0-RC2.jar:Tokens in a single document exceeds the maximum number of allowed input tokens。
原因是单个文档超限。批处理策略能把多个小文档分批,却不会自动拆开一个大文件,增大 batch size 无法解决。
作者用下面的配置,在 embedding 前执行本地切块:
@Configuration
public class SourcesIndexingConfiguration {
@Bean
public TokenTextSplitter tokenTextSplitter() {
return TokenTextSplitter.builder()
.withEncodingType(EncodingType.CL100K_BASE)
.withChunkSize(1000)
.withMinChunkSizeChars(400)
.withMinChunkLengthToEmbed(10)
.withMaxNumChunks(10000)
.withKeepSeparator(true)
.withPunctuationMarks(List.of('\n', ';', '}'))
.build();
}
}
要看清 .withChunkSize(1000):这是作者选的安全预算,不是模型的精确上限。而且注册 bean 后,应用仍须显式调用 splitter,VectorStore.add(...) 不会自动应用它。
TokenTextSplitter 使用 com.knuddels:jtokkit,默认编码为 CL100K_BASE。原文的 embedding 模型却是 text-embedding-nomic-embed-text-v1.5,属于另一套 tokenizer 家族。端点兼容 OpenAI API,不代表 token 计算方式兼容。需要精确计数时,应实现采用 Nomic/BERT tokenizer 的自定义 DocumentTransformer。
这个 splitter 也不理解 Java 语法。它偏向换行、分号和右花括号切分,仍可能切断方法。maxNumChunks 也不能充当安全截断:设置过低,最后一块可能吞下剩余内容,再次超限。生成文件应在入口按大小、扩展名或路径过滤。
切块之后,文件成为父文档,数据库存的是多个 chunk。splitter 会复制 metadata,加入 parent_document_id、chunk_index、total_chunks,但默认 chunk ID 仍是随机的。作者因此重新分配稳定 ID:
private String chunkId(String parentDocumentId, int chunkIndex) {
return parentDocumentId + "#chunk-" + chunkIndex;
}
关键是 chunkIndex:每块必须有独立 ID,否则后面的块会覆盖前面的块。
更新还有尾巴要收拾。文件从十块缩到六块,upsert 只覆盖 0..5,旧的 6..9 仍然存在。作者按 parent_document_id 定位文件,再删除 checksum 不同的旧块。
预检查则查询 chunk 0 的 ID 和文件 checksum。但它只能证明第一块存在,不能证明整份文件完整入库。原文明确承认这个演示前提;需要容忍部分写入时,应保存文件 manifest,或者核对已存块数与 total_chunks。
为什么检索到了源码,模型还会编造路径
问答服务使用 ChatClient、memory advisor 和 QuestionAnswerAdvisor,返回结构化的 CodeAssistantReply,字段包括 answer、evidences、absoluteSourceFilePaths。
作者发现:回答和证据看起来合理,文件路径却不存在。
原因藏在 advisor 的上下文组装里。它检索文档后,只拼接 Document.getText(),没有把路径、库名和 checksum 等 metadata 放进提示词。模型收到代码,又被输出 schema 要求填写路径,只能补出貌似合理的字符串。
检索系统知道文件在哪,不等于模型收到的上下文里有路径。
作者的修复方向是拆分职责:模型生成解释和证据引用,服务端从检索文档的 metadata 提取路径。把 .entity(...) 换成 .responseEntity(...),便可同时取得解析后的对象和完整 ChatResponse;后者通过 QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS 提供检索结果。
排查也有具体顺序:先用 SimpleLoggerAdvisor 检查实际提示词,再对比检索 metadata 与输出路径。测试应约束每个路径都指向索引根目录中的真实文件;证据若要求逐字引用,还要验证字符串确实出现在检索文本里。
不过,提供的原文在最终 CodeAssistantService 的 system prompt 开头截断了。修复思路有说明,完整落地代码没有展示,不能据此认定证据与文件已经建立逐条对应关系。
我的判断
这篇最扎实的地方,是把「数据可重复更新」和「答案可追溯」拆成了明确的工程规则。稳定 ID、写入前预检查、旧块清理,都能解释具体故障,适合作为实现检查项。
但「约 1000 tokens 比较安全」仍是作者对当前环境的经验选择。原文没有给出切块方案、检索质量或调用成本的对照数据,也没明确本地 embedding 服务的实际限制,不能把这个数字当通用配置。
源码索引适合追踪当前依赖版本的实现细节;回答入门用法时,文档检索往往能提供更集中的说明和示例。两者如何组合,原文没有比较。按标点切块容易落地,按类和方法切块更贴近代码结构,但后者的收益也还没有实测支撑。
引用方面还要再收紧一步:从 metadata 返回的路径可以避免模型凭空生成地址,却不能保证每条结论都被相应文件支持。真实路径只是可追溯的起点。
能拿来做什么
- 给依赖源码问答工具增加写入前预检查,将「有没有重复行」和「有没有重复调用 embedding」分开验证,并覆盖文件缩短后的旧块清理。
- 在日常 AI 编程中解释 Spring 或 Java 依赖的新 API 时,先确认源码导出对应当前项目版本,再建立索引,减少模型沿用旧接口的机会。
- 调试 RAG 引用时,同时检查提示词和检索 metadata;让服务端生成文件地址,并校验路径存在、证据原文可查。
- 把 chunk 0 检查视为演示级实现。准备长期维护索引时,补上文件完整性记录,避免一次部分写入让后续运行误判为已完成。