CH 05RAG:检索增强生成
RAG(Retrieval-Augmented Generation)是企业级大模型应用的绝对主力技术。 一句话概括它的价值:让模型基于你提供的资料回答,而不是基于它的记忆胡猜。 第 4 章我们准备好了「向量」,这一章把向量、检索、提示词、生成串成一条能用的流水线。
5.1RAG 是什么:给模型「开卷考试」的权利
闭卷考试靠记忆,开卷考试查资料。大模型的两种「知识注入」方式正好对应这两种考试:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 微调 Fine-tuning |
用你的数据继续训练模型,把知识「写进」参数 | 推理时无需额外检索,延迟低 | 成本高 数据一变就要重训 无法溯源 易产生灾难性遗忘 |
| RAG 推荐 |
把知识放在外部库,回答前先检索相关片段塞进提示词 | 数据可随时更新 能给出引用出处 权限可控(谁能看哪些文档) 成本低 |
增加一次检索延迟 效果依赖检索质量 |
RAG 能解决的三个真实痛点
- 模型不知道你的私有数据:公司制度、产品手册、客户合同,模型训练时根本没见过。
- 模型的知识有截止日期:昨天的政策变更,模型不可能知道。
- 模型会一本正经地胡说(幻觉):RAG 让它在有据可依的情况下回答,并要求「资料没有就说没有」。
5.2RAG 的九步全链路(务必背下来)
5.3第①②步:文档加载与切分
DocumentReader:把各种格式读成 Document
需要额外依赖(在 0.4.7 的 pom 里已经有了):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId> <!-- 万能格式:PDF/Word/PPT/HTML/… -->
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.reader.JsonReader;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.ClassPathResource;
import java.util.List;
// ---------- ① 纯文本 ----------
TextReader textReader = new TextReader(new FileSystemResource("docs/policy.txt"),
Map.of("source", "policy.txt"));
List<Document> docs1 = textReader.get();
// ---------- ② Markdown(按标题层级自动切成多个 Document,很适合技术文档) ----------
MarkdownDocumentReaderConfig mdConfig = MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true) // 遇到 --- 分隔线也切分
.withIncludeCodeBlock(false) // 代码块不要(通常对问答无用,还占 Token)
.withIncludeBlockquote(true) // 保留引用块
.withAdditionalMetadata("category", "技术文档")
.build();
MarkdownDocumentReader mdReader =
new MarkdownDocumentReader("classpath:docs/guide.md", mdConfig);
List<Document> docs2 = mdReader.get();
// ---------- ③ JSON(适合结构化知识库:FAQ、产品参数表) ----------
// JSON 形如 [{"question":"如何退款","answer":"7天内提交..."}, ...]
JsonReader jsonReader = new JsonReader(new ClassPathResource("docs/faq.json"),
"question", // 用哪个字段作为正文
"answer"); // 可以传多个字段,会被拼接
List<Document> docs3 = jsonReader.get();
// ---------- ④ PDF / Word / PPT / HTML(Tika 万能解析) ----------
TikaDocumentReader tikaReader =
new TikaDocumentReader(new FileSystemResource("docs/manual.pdf"));
List<Document> docs4 = tikaReader.get();
- 扫描版 PDF 读出来是空的——因为里面是图片不是文字。需要先做 OCR,Tika 不做 OCR。
- 表格被拆散——Tika 会把表格按行拉成文本,列对不上。财务/参数类文档建议先转成 Markdown 表格再灌。
- 中文断行错乱——PDF 里每个字符都带定位信息,Tika 可能把一句话断成多行。可以后处理:把「行尾无标点」的行合并。
TokenTextSplitter:把长文切成合适的块
为什么必须切分?① 模型上下文有限,整本书塞不进去;② 检索要的是「精准片段」,一整章里只有一段相关,会把无关信息也带进去。
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
// 默认配置:chunkSize=800 token,适合大多数场景
TokenTextSplitter defaultSplitter = new TokenTextSplitter();
// 精细配置(推荐按文档类型调整)
TokenTextSplitter splitter = TokenTextSplitter.builder()
// 每块目标大小(token 数)。中文大约 1 字 ≈ 0.7~1 token,800 token ≈ 500~700 中文字
.withChunkSize(800)
// 最小块字符数:小于它的块会尝试与前一块合并,避免产生"碎片"
.withMinChunkSizeChars(350)
// 太短的块(少于此 token)直接丢弃,通常是页眉页脚之类的噪音
.withMinChunkLengthToEmbed(5)
// 单文档最多切多少块,防止超大文档把内存打爆
.withMaxNumChunks(10000)
// 是否保留分隔符(换行、句号)。建议 true,能保住语义边界
.withKeepSeparator(true)
.build();
// 用法:把 reader 读出来的 Document 列表直接喂进去
List<Document> chunks = splitter.apply(docs4);
System.out.println("原文档 " + docs4.size()
+ " 个,切分后 " + chunks.size() + " 块");
切分策略对比:没有最好的,只有最合适的
| 策略 | 做法 | 适用 |
|---|---|---|
| 固定长度 | 每 N 个字符/token 切一刀(TokenTextSplitter) | 通用默认;实现简单 |
| 按结构切 | 按标题、段落、--- 分隔(MarkdownDocumentReader) | ✅ 制度文档、技术手册(效果最好) |
| 按语义切 | 用模型判断语义边界再切 | 高价值长文,成本高 |
| 父文档 / 子块 | 用小块检索,返回它所属的大块给模型 | ✅ 解决「块太小缺上下文」的经典方案 |
- 知识库是 FAQ / 短问答:一条就是一块,别切。
- 知识库是 制度 / 手册:按章节切,400~800 token。
- 知识库是 长篇小说 / 访谈:800~1200 token,并保留重叠。
- 切完之后一定要人工抽查 10 块——看看有没有把一句话切断、有没有把标题和正文分离。这一步能提前发现 80% 的检索问题。
5.4最简 RAG:一个 Advisor 就搞定
Spring AI 提供了 QuestionAnswerAdvisor,它把第⑤⑥⑦步全部封装好了。你只需要挂上它,剩下的交给框架。
<!-- 依赖(pom 里已有) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
// ① 一个会被"粘贴"进提示词的模板:{question_answer_context} 是框架约定的占位符
// 这个占位符会被替换成检索到的片段拼接结果,千万别写错名字。
String qaTemplate = """
请仅依据下面 <参考资料> 中的内容回答问题。
<参考资料>
{question_answer_context}
参考资料>
回答规则:
1. 如果参考资料中没有相关信息,直接回答"资料中未提及",不要编造。
2. 引用资料时,用 [来源:文件名] 的形式标注出处。
3. 用简体中文回答,条理清晰。
问题:{query}
""";
// ② 构建 Advisor:把向量库 + 检索参数 + 提示模板绑在一起
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(5) // 取 5 个片段
.similarityThreshold(0.5) // 过滤弱相关内容(第 4 章讲过,非常重要)
.build())
.promptTemplate(new PromptTemplate(qaTemplate))
.build();
// ③ 挂到 ChatClient 上
ChatClient ragClient = ChatClient.builder(chatModel)
.defaultAdvisors(qaAdvisor)
.build();
// ④ 用起来和普通对话一模一样,检索是透明的
String answer = ragClient.prompt()
.user("退款要几天到账?")
.call()
.content();
{question_answer_context} → 得到最终的 Prompt → 交给模型。
所以「检索」这一步对你完全透明,但也意味着你不能在业务代码里直接拿到检索结果——
如果需要在接口里返回引用来源,就得用 5.6 节的方法。
只想快速验证效果?用 ChatClient 的快捷方法
// 最简版本:几行代码跑通 RAG,适合做完第 4 章后立刻验证
String answer = ChatClient.create(chatModel)
.prompt()
.advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.user("退款要几天到账?")
.call()
.content();
System.out.println(answer);
logging.level.org.springframework.ai: debug 打开,
你会看到完整的、包含检索片段的 Prompt。这是理解 RAG 最快的方式——
强烈建议在第 5 章至少认真读一遍这条日志。
5.5进阶:模块化 RAG(RetrievalAugmentationAdvisor)
QuestionAnswerAdvisor 够简单,但不够灵活:不能做查询改写、不能做重排、不能做多路检索。
这时候用「模块化 RAG」——它把 RAG 拆成了可插拔的组件。
① 最常用的配置:检索 + 空上下文策略
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.build())
.queryAugmenter(
ContextualQueryAugmenter.builder()
// ★ 关键配置:检索为空时怎么办
// false(默认)= 直接抛异常,阻止调用模型
// true = 允许空上下文,让模型按提示词里的"资料不足"规则回复
.allowEmptyContext(true)
.build())
.build();
ChatClient ragClient = ChatClient.builder(chatModel)
.defaultAdvisors(ragAdvisor)
.build();
allowEmptyContext 的默认值是 false,这会让很多新手一脸懵。
当检索不到任何结果时,默认行为是抛出异常,你看到的是 500 错误,而不是「资料中未提及」。
生产环境建议设成 true,并在提示词里写清楚「资料为空时怎么回答」。
② 加查询改写:解决「多轮对话中的指代问题」
真实场景:用户先问「2024001 这个订单」,接着问「它什么时候发货?」 第二句单独拿去检索,向量库里根本没有「它」的信息——检索必然失败。查询改写就是解决这个的:
import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
// 方案 A:压缩式改写——把「多轮对话 + 当前问题」压成一个独立完整的查询
// 它会把 "它什么时候发货" + 上文 "订单 2024001" → 改写成 "订单 2024001 什么时候发货"
CompressionQueryTransformer compression = CompressionQueryTransformer.builder()
.chatClientBuilder(ChatClient.builder(chatModel))
.build();
// 方案 B:改写式——把口语化的问法改写成更适合检索的表述
// "钱啥时候能回来啊" → "退款到账时间"
RewriteQueryTransformer rewriter = RewriteQueryTransformer.builder()
.chatClientBuilder(ChatClient.builder(chatModel))
// 可以指定用便宜的小模型做改写,省成本
.build();
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(compression) // ★ 挂上改写器
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(5)
.similarityThreshold(0.5)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
③ 元数据过滤 + 多租户隔离(企业项目必需)
// 关键:把租户 ID 从请求上下文里带进来,限制检索范围
// 这是"防止 A 公司查到 B 公司文档"的最后一道防线
String tenantFilter = "tenantId == '" + currentTenantId + "'";
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.filterExpression(tenantFilter) // ★ 租户隔离
.build())
.build();
filterExpression,让别人的数据根本查不出来。
5.6引用溯源:让回答带上出处
企业场景里,「答案是什么」和「答案从哪来」同样重要。财务、法务、医疗类应用如果没有引用, 用户根本无法验证真伪,也就不会信任它。
方法一:从 Advisor 上下文里取回检索结果
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
// QuestionAnswerAdvisor 会把检索到的文档放进响应的 context,key 是固定的常量
ChatClientResponse response = ragClient.prompt()
.user("退款要几天到账?")
.call()
.chatClientResponse();
// 取出检索到的文档列表
List<Document> retrieved = (List<Document>) response.context()
.get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);
// 组装成引用列表返回给前端
List<Citation> citations = retrieved == null ? List.of() : retrieved.stream()
.map(d -> new Citation(
String.valueOf(d.getMetadata().get("source")),
String.valueOf(d.getMetadata().get("page")),
d.getScore(),
// 只回传 200 字摘要,不要整段回传(可能很长)
d.getText().length() > 200 ? d.getText().substring(0, 200) + "…" : d.getText()))
.toList();
System.out.println("回答:" + response.chatResponse().getResult().getOutput().getText());
System.out.println("引用:" + citations);
方法二:自己控制检索(推荐,最灵活)
不依赖 Advisor,手工做「检索 → 组装 → 生成」,这样每一步都能拿到数据、都能打日志、都能做实验:
@Service
public class ManualRagService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
/** 提示词模板:{context} 是我们自己定义的占位符 */
private static final String TEMPLATE = """
你是企业知识库助手。请严格依据下方【参考资料】回答用户问题。
【参考资料】
{context}
回答要求:
1. 只使用参考资料中的信息;资料不足时回答"根据现有资料无法确定"。
2. 在每个结论后用 [序号] 标注它来自哪条参考资料。
3. 不要复述参考资料原文,用你自己的话组织答案。
4. 回答控制在 300 字以内。
用户问题:{question}
""";
public RagAnswer ask(String question, String tenantId) {
// ---------- 第⑤步:检索 ----------
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.5)
.filterExpression("tenantId == '" + tenantId + "'")
.build());
// ★ 调试必看:把检索结果打出来,这是排查 RAG 问题的第一现场
log.info("【RAG检索】问题=「{}」命中={}条", question, docs.size());
for (int i = 0; i < docs.size(); i++) {
log.info(" [{}] score={} source={} 内容前80字={}",
i + 1, docs.get(i).getScore(),
docs.get(i).getMetadata().get("source"),
docs.get(i).getText().substring(0, Math.min(80, docs.get(i).getText().length())));
}
// 检索为空:直接返回,不浪费一次模型调用
if (docs.isEmpty()) {
return new RagAnswer("根据现有资料无法确定,建议联系人工客服。", List.of());
}
// ---------- 第⑦步:组装上下文 ----------
StringBuilder ctx = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document d = docs.get(i);
ctx.append("[%d] 来源:%s 内容:%s\n\n".formatted(
i + 1,
d.getMetadata().get("source"),
d.getText()));
}
// ---------- 第⑧步:生成 ----------
String answer = chatClient.prompt()
.user(u -> u.text(TEMPLATE)
.param("context", ctx.toString())
.param("question", question))
.call()
.content();
// ---------- 第⑨步:溯源 ----------
List<Citation> citations = new java.util.ArrayList<>();
for (int i = 0; i < docs.size(); i++) {
Document d = docs.get(i);
citations.add(new Citation(
i + 1,
String.valueOf(d.getMetadata().get("source")),
String.valueOf(d.getMetadata().getOrDefault("page", "-")),
d.getScore(),
d.getText()));
}
return new RagAnswer(answer, citations);
}
public record RagAnswer(String answer, List<Citation> citations) {}
public record Citation(int index, String source, String page,
Double score, String excerpt) {}
}
5.7完整示例:企业知识库问答
把①②③④(离线)和⑤⑥⑦⑧⑨(在线)拼成完整可运行的服务。
① 离线索引服务:一键灌库
package com.example.springai.ch05;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class KnowledgeIndexService {
/** 文档目录,可在 yml 里配置:knowledge.docs-path=classpath:docs/*.md */
@Value("${knowledge.docs-path:classpath:docs/*.md}")
private String docsPathPattern;
private final VectorStore vectorStore;
/** 切分器:按第 5.3 节的建议参数配置 */
private final TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(600)
.withMinChunkSizeChars(200)
.withMinChunkLengthToEmbed(5)
.withKeepSeparator(true)
.build();
public KnowledgeIndexService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/**
* 全量重建索引。
* 建议做成一个管理接口(POST /admin/reindex),知识库更新时手动触发。
*/
public int rebuildIndex(String tenantId) {
try {
// ① 加载:用通配符匹配目录下所有 md 文件
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources(docsPathPattern);
List<Document> allChunks = new ArrayList<>();
for (Resource res : resources) {
String fileName = res.getFilename();
log.info("正在处理:{}", fileName);
// 按结构切:Markdown 的标题层级天然是语义边界
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true)
.withIncludeCodeBlock(false)
.withAdditionalMetadata("source", fileName)
.withAdditionalMetadata("tenantId", tenantId) // ★ 多租户
.withAdditionalMetadata("docType", "knowledge")
.build();
List<Document> raw = new MarkdownDocumentReader(res, config).get();
// ② 切分:结构切完后如果某块还太大,再按 token 二次切
List<Document> chunks = splitter.apply(raw);
// ③ 补元数据:chunkIndex 便于调试时定位是哪一块
for (int i = 0; i < chunks.size(); i++) {
chunks.get(i).getMetadata().put("chunkIndex", i);
}
allChunks.addAll(chunks);
}
// ④ 灌库前的幂等处理:清掉本租户的旧数据
vectorStore.delete("tenantId == '" + tenantId + "'");
// ⑤ 写入向量库(内部自动向量化)
vectorStore.add(allChunks);
log.info("索引重建完成:{} 个文件,{} 个片段", resources.length, allChunks.size());
return allChunks.size();
} catch (Exception e) {
log.error("索引重建失败", e);
throw new IllegalStateException("索引重建失败:" + e.getMessage(), e);
}
}
}
② 接口层
@RestController
@RequestMapping("/api/v1/knowledge")
public class KnowledgeQaController {
private final KnowledgeQaService qaService; // 5.6 节的 ManualRagService
private final KnowledgeIndexService indexService;
public KnowledgeQaController(KnowledgeQaService qa, KnowledgeIndexService i) {
this.qaService = qa;
this.indexService = i;
}
/** 问答:返回答案 + 引用来源 */
@PostMapping("/ask")
public KnowledgeQaService.RagAnswer ask(@RequestBody AskRequest req) {
// 真实项目里 tenantId 应从登录态取,这里为演示放在请求体
return qaService.ask(req.question(), req.tenantId());
}
/** 重建索引:管理接口,生产上要加权限控制 */
@PostMapping("/reindex")
public Map<String, Object> reindex(@RequestParam String tenantId) {
int n = indexService.rebuildIndex(tenantId);
return Map.of("chunks", n, "tenantId", tenantId);
}
public record AskRequest(String question, String tenantId) {}
}
③ 验证:完整的 RAG 闭环
# ① 重建索引(把 docs/ 下的 md 灌进向量库)
curl -X POST "http://localhost:8080/api/v1/knowledge/reindex?tenantId=default"
# 期望:{"chunks":18,"tenantId":"default"}
# ② 提问——用口语化、不含原文关键词的问法
curl -X POST http://localhost:8080/api/v1/knowledge/ask \
-H "Content-Type: application/json" \
-d '{"question":"我买的东西不想要了,钱什么时候能退给我?","tenantId":"default"}'
# 期望输出结构:
# {
# "answer": "订单完成后的 7 天内可以提交退款申请[1];审核通过后 3 个工作日内退回原支付账户[2]。",
# "citations": [
# {"index":1,"source":"refund-policy.md","score":0.78,"excerpt":"..."},
# {"index":2,"source":"refund-policy.md","score":0.74,"excerpt":"..."}
# ]
# }
# ③ 问一个知识库里没有的问题——验证"不瞎编"
curl -X POST http://localhost:8080/api/v1/knowledge/ask \
-H "Content-Type: application/json" \
-d '{"question":"公司年会在哪一天举办?","tenantId":"default"}'
# 期望:answer 为"根据现有资料无法确定…",citations 为空数组
# 如果它编了一个日期出来 → 你的提示词约束不够强,或阈值太低捞进了无关片段
5.8效果优化与排错
RAG 效果差的排查顺序(重要!按这个顺序查,不要乱试)
-
先看检索结果(90% 的问题在这里)。打开日志确认:
· 检索到的片段里到底有没有正确答案?没有 → 继续第 2 步。
· 有,但排在第 4、5 位 → 加大 topK,或降低 threshold。 - 检查切分质量。把 chunk 打印出来看:是不是把关键的一句切成两半了?是不是标题和正文分离了? 换成按结构切(Markdown 标题 / 段落)。
-
检查向量模型。中文语料用了英文优化的模型?换
text-embedding-v3或BAAI/bge-large-zh重灌。 - 检查用户问法。「钱啥时候回来」这种口语化问题,检索表现通常差。 开启查询改写(5.5 节)或做同义词扩充。
- 最后才怀疑模型。检索明明是对的,模型还是答错—— 这时检查提示词是不是太弱(没说「只依据资料」)、上下文是不是太长(关键信息被淹没)。
六个高频优化手段
| 手段 | 怎么做 | 收益 |
|---|---|---|
| 混合检索 | 向量检索 + 关键词检索(BM25)各取 20 条,合并去重后重排 | 专有名词、编号类查询提升明显 |
| 重排(Rerank) | 先召回 20 条,再用重排模型精排取前 5 | 精度大幅提升,增加一次调用延迟 |
| 父块检索 | 用小块做匹配,把小块所属的大块给模型 | 解决「片段太小丢上下文」 |
| 元数据预过滤 | 按部门/时间/文档类型先缩范围 | 免费且显著,强烈推荐 |
| 上下文压缩 | 把检索到的长片段先摘要,再放进提示词 | 省 Token、降低干扰 |
| 多查询检索 | 把一个问题改写成 3 个不同角度的查询,分别检索后合并 | 召回率提升,成本翻倍 |
排错对照表
| 现象 | 原因 | 解决 |
|---|---|---|
| 永远回答"资料中未提及" | 阈值太高 / 检索为空 / 占位符名字写错 | 把 threshold 先设 0 测试;确认占位符是 {question_answer_context} |
allowEmptyContext=false 时抛异常 | 检索结果为空时的默认行为 | 设成 true,并在提示词里定义"资料为空"的回答 |
| 回答里出现了知识库里没有的信息 | 提示词没禁止编造,或 topK 太小导致模型用先验知识补全 | 加「只依据资料,不足则说无法确定」;把 temperature 降到 0.1~0.3 |
| 回答正确但引用来源是错的 | 模型只是模仿了 [1][2] 的格式,序号和实际来源不对应 | 不要把序号交给模型自由发挥;改用「我们必须程序化生成引用列表」(5.6 节方法二) |
| 同一个问题换成不同问法,答案差很多 | 检索不稳定 | 开查询改写;或对同一问题做多查询检索后合并 |
| 灌库后检索不到新文档 | 忘了重建索引,或灌错了租户 | 调 /reindex;检查 tenantId 是否一致 |
| 响应特别慢(> 10s) | topK 太大、片段太长、查询改写多一次调用 | 压小 topK;对片段做摘要;单轮对话关闭改写 |
| 成本突然暴涨 | 上下文里塞了太多片段 | 统计每次调用的 promptTokens(第 3 章那个 Advisor),把 topK × 平均片段长度 控制住 |
[1] [2] 这种序号,
本质上是把「引用和来源的映射关系」交给模型记忆,模型中途一定会搞错。
正确做法是:让模型只管生成答案,引用列表由你的代码根据检索结果直接构造
(就是我们 5.6 节方法二里的 citations)。这样引用 100% 准确。
5.9自测题
Q1. RAG 和微调各自适合什么场景?为什么企业知识问答首选 RAG?
微调适合注入「风格/格式/能力」,RAG 适合注入「事实知识」。 企业知识问答首选 RAG 的原因:① 数据频繁更新,RAG 只需重灌索引;② 必须能给出引用出处,微调做不到;③ 可以做细粒度权限控制(谁能检索哪些文档); ④ 成本低、见效快、可解释。
Q2. 完整的 RAG 有哪九个步骤?哪些是离线的,哪些是在线的?
离线:① 加载(Reader)② 切分(Splitter)③ 向量化(EmbeddingModel)④ 存储(VectorStore)。 在线:⑤ 检索 ⑥ 重排 ⑦ 组装上下文 ⑧ 生成 ⑨ 溯源。 离线步骤只在灌库/更新时执行,在线步骤每次提问都执行。
Q3. 检索明明是对的,模型却答错了,可能的三个原因是什么?
① 提示词没约束「只依据资料」,模型用自己的先验知识覆盖了资料; ② 上下文太长,关键信息淹没在中间(模型对上下文中部信息注意力较弱); ③ 检索到的片段中包含相互矛盾的信息(例如新旧两版制度都在库里),模型选错了。 第 ③ 点需要在灌库时做版本管理,只保留最新版本。
Q4. 为什么「多租户隔离」必须做在检索条件里?
因为检索发生在模型调用之前。如果检索阶段就把别的租户的文档捞了出来,
数据已经进入了 Prompt 上下文,再让模型「不要说出来」是掩耳盗铃——
稍微巧妙的提问就能诱导它泄露。必须在向量库层面用 filterExpression 物理隔离。
Q5.【动手题】把本节的 topK=5 / threshold=0.5 改成三组不同参数,各跑同一批 10 个问题,统计「正确命中数」和「平均耗时」,选出一组最优参数。
这是第 10 章「评测」的雏形。步骤:固定问题集 → 对每组参数跑一遍 → 人工或模型判定答案是否正确 → 记录命中率与延迟 → 做折中决策。注意:不要只盯命中率, topK 从 5 调到 20 可能命中率提升 3%,但成本和延迟翻倍,未必值得。
- 能默写 RAG 九步,并指出每步对应哪个 Java 类/接口。
- 能独立完成「加载 md → 切分 → 灌库 → 检索 → 生成 → 溯源」的完整链路。
- 口语化提问(不含原文关键词)能命中正确片段。
- 知识库外的问题能正确回答「无法确定」,不编造。
- 返回结构里带引用来源,且来源与答案真实对应。
- 能说出至少四种提升检索效果的手段。