这一话解决三件事:知识怎么进向量库(ETL Pipeline)、怎么检索得准(分块、过滤、阈值)、 怎么组织检索链路(模块化 RAG)。最后给你一个最实用的开关:让模型在检索不到时老实说"不知道"。
SELECT ... ORDER BY distance LIMIT 5 的事。@Bean
CommandLineRunner ingest(VectorStore vectorStore,
@Value("classpath:/docs/售后手册.pdf") Resource pdf) {
return args -> {
// 1) 读:PDF 按页读;还有 TextReader / JsonReader / TikaDocumentReader(Word、PPT、HTML…)
var reader = new PagePdfDocumentReader(pdf);
// 2) 切:TokenTextSplitter(defaultChunkSize, minChunkSizeChars,
// minChunkLengthToEmbed, maxNumChunks, keepSeparator)
var splitter = new TokenTextSplitter(800, 350, 5, 10000, true); //!hl
// 3) 增强:给每个 chunk 自动生成关键字元数据,后续可做元数据过滤
var enricher = new KeywordMetadataEnricher(chatModel, 5); //!hl
List<Document> docs = enricher.apply(splitter.apply(reader.get()));
docs.forEach(d -> d.getMetadata().putAll(Map.of(
"tenant", "default", // 多租户隔离的关键字段 //!hl
"type", "aftersale",
"source", "售后手册.pdf")));
vectorStore.accept(docs); //!hl
};
}
TokenTextSplitter 不是按字符硬切,而是按 token 计数滚动累积:
defaultChunkSize=800:目标块大小(token)。太大 → 无关内容混入、吃上下文;太小 → 语义不完整。minChunkSizeChars=350:小于此字符数的碎块会与相邻块合并,避免"半句话"。maxNumChunks=10000:安全阀,防止超大文档把内存打爆。keepSeparator=true:尽量在自然边界(句号、换行、段落)断开,而不是从单词中间切开。经验值:中文技术文档 300~600 token、问答对类 200~400 token、法律/合同类按条款切(不要按字数切)。
add 是追加语义,重跑一次就翻倍。务必先按 source + version 删除旧数据(多数 VectorStore 提供 delete(filterExpression)),或把文档哈希作为幂等键。embedding-model 写进元数据,方便日后识别。var b = new FilterExpressionBuilder();
// 多租户隔离 + 文档类型限定 —— 必须在检索层做,不能靠模型自觉
Expression filter = b.eq("tenant", tenantId) //!hl
.and(b.in("type", "aftersale", "faq")) //!hl
.build();
SearchRequest req = SearchRequest.builder()
.query(userQuestion)
.topK(6) //!hl
.similarityThreshold(0.65) //!hl
.filterExpression(filter)
.build();
List<Document> docs = vectorStore.similaritySearch(req);
| 参数 | 含义与工程建议 |
|---|---|
topK | 召回条数。不是越大越好:每多一条都占用上下文 token 并稀释注意力。通用问答 4~8;需要跨段落推理时可先召回 20 再重排序取 5。 |
similarityThreshold | 低于该相似度分数的结果被过滤(不同向量库的分数区间与距离度量不同,0.5~0.75 需实测调参)。设太高会大量"无结果",设太低会引入噪声。 |
filterExpression | 元数据前置过滤(pre-filter)。多租户、权限、时效必须在这一层做,靠语义相似度做权限过滤是不安全的。 |
Spring AI 早期版本中 similarityThreshold 语义与向量库距离度量耦合较深,升级大版本后务必用一批固定问答回归验证召回质量。
另外 QuestionAnswerAdvisor(经典写法)在 1.x 中逐步让位于模块化 RAG,新项目建议直接用后者。
经典 QuestionAnswerAdvisor 是个黑盒:查询进去、上下文出来。模块化 RAG 把这条链路拆成六个可替换的角色 —— 这正是十年工程师最喜欢的部分。
@Bean
Advisor ragAdvisor(ChatClient.Builder builder, VectorStore vectorStore) {
return RetrievalAugmentationAdvisor.builder() //!hl
.queryTransformers(
// 上下文压缩:把"它多少钱?"结合历史改写成"iPhone 15 的售价是多少?"
CompressionQueryTransformer.builder()
.chatClientBuilder(builder).build(), //!hl
RewriteQueryTransformer.builder()
.chatClientBuilder(builder).build(),
TranslationQueryTransformer.builder()
.chatClientBuilder(builder).targetLanguage("zh").build())
.queryExpander(MultiQueryExpander.builder() //!hl
.chatClientBuilder(builder)
.numberOfQueries(3) // 生成 3 个语义等价的不同表述
.includeOriginal(false)
.build())
.documentRetriever(VectorStoreDocumentRetriever.builder() //!hl
.vectorStore(vectorStore)
.similarityThreshold(0.6)
.topK(8)
.filterExpression(currentTenantFilter())
.build())
.documentJoiner(new ConcatenationDocumentJoiner()) //!hl
.documentPostProcessor(docs -> rerank(docs)) // 自定义重排序 //!hl
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false) //!hl 检索为空时让模型说"不知道",禁止编造
.build())
.build();
}
| 角色 | 职责 | 不装会怎样 |
|---|---|---|
QueryTransformer | 把原始问题变成更适合检索的查询(压缩 / 改写 / 翻译 / 多跳分解) | 多轮对话里"它呢?""为什么?"这类省略句检索不到任何东西 |
QueryExpander | 一个查询扩成 N 个表述,多路召回 | 召回率偏低,尤其术语与口语不一致时 |
DocumentRetriever | 真正去向量库查(阈值、topK、过滤) | 链路无法工作 |
DocumentJoiner | 把多路结果合并去重(ConcatenationDocumentJoiner) | 多路扩展的结果重复占位 |
DocumentPostProcessor | 重排序、截断、脱敏、按 token 预算裁剪 | 噪声混入,长尾上下文挤爆窗口 |
QueryAugmenter | 把文档组装进 UserMessage 并注入"只依据上下文"的指令 | 模型会忽略上下文,直接用参数知识编答案 |
allowEmptyContext(false) 是本话最该记住的一行配置。当检索结果为空时,ContextualQueryAugmenter
不再"带着空上下文去问模型",而是让模型按指令回答"未找到相关信息"。这一个开关能消灭绝大多数知识库幻觉。
.documentPostProcessor(docs -> {
// 1) 按业务权重(时效、权威度)与相似度加权,重新排序
// 2) 按 token 预算截断,只保留能塞进上下文的内容
// 3) 去重(同一 source 的相邻 chunk 合并)
return rerankService.rerankAndTrim(docs, tokenBudget);
})
如果厂商提供 rerank 模型,把它放在 PostProcessor 里做二次排序,通常能把命中率提升 10~20 个百分点 —— 这是 RAG 里性价比最高的优化。
// 相关性评估:判断检索到的文档是否真的能支撑这个回答
var evaluator = new RelevancyEvaluator(ChatClient.builder(chatModel).build()); //!hl
EvaluationRequest req = new EvaluationRequest(
userQuestion, // 原始问题
retrievedDocuments, // 召回的文档
modelAnswer); // 模型最终回答
EvaluationResponse res = evaluator.evaluate(req); //!hl
boolean pass = res.isPass(); //!hl
| 指标 | 怎么算 | 健康线 |
|---|---|---|
| 检索命中率(Recall@K) | 标准问答集中,正确文档出现在 topK 里的比例 | ≥ 85%(内部知识库) |
| 忠实度(Faithfulness) | 回答中的事实是否都能在召回文档中找到出处 | ≥ 90% |
| 拒答率 | 无答案问题中被正确拒绝的比例 | 越高越好,但别误拒 |
| 平均上下文 token | 单次请求的上下文长度 | 与成本挂钩,需设上限 |
建一个 50~200 条的黄金问答集(问题 + 期望答案 + 期望来源文档),每次改分块策略 / 阈值 / 模型就跑一遍,把结果做成趋势图。 没有评测集的 RAG 调优,本质是玄学调参。
| 症状 | 根因 / 解法 |
|---|---|
| 答案里混进别的租户的数据 | 只在语义上"期望"模型区分 → 必须加 filterExpression 做前置过滤 |
| 回答凭空捏造,跟文档不符 | 没开 allowEmptyContext(false),或 QueryAugmenter 指令被自定义模板覆盖 |
| 换个问法就查不到 | 缺 QueryTransformer / Expander;术语不一致 → 建同义词表或用关键词元数据增强 |
| 上下文超长被截断 | topK 或 chunk 太大 → 在 PostProcessor 里做 token 预算裁剪 |
| PDF 抽出乱码 / 乱序 | 换 TikaDocumentReader 或人工转 Markdown;表格单独结构化 |
| 上线后越用越慢 | 向量库没建索引(PgVector 需 HNSW / IVFFlat),或每次全表扫描 |
第 08 话 —— 工具写多了就会发现:同一套能力要在不同 Agent、不同语言、不同进程里重复实现一遍。下一话讲 MCP:把工具从"方法"升级成"服务",让 Agent 直接接入整个生态的工具市场。