【WMS 仓储系统集成 AI Agent 实战】第 6 讲:RAG 知识库------"输出简单流程"为什么检索不到任何东西
RAG 是这个项目里"看着简单、做着深"的部分。这一讲覆盖完整链路:文档上传解析 → 向量分片 → 混合检索 → 多轮追问。最后那个追问检索为空的 bug,是整个 RAG 模块最有价值的修复。
本讲复现环境与版本
| 项 | 版本/说明 |
|---|---|
| Spring AI | 1.0.9(spring-ai-tika-document-reader / PgVectorStore / EmbeddingModel) |
| Embedding 模型 | bge-m3(1024 维,约 1.2GB)· Ollama Windows 版 |
| 向量库 | PostgreSQL 17.10 + pgvector v0.8.5-pg17 |
| 对话模型 | qwen2.5:7b(RAG 生成 + 查询改写) |
| Nginx | 1.30.4(Windows) |
| 前端 | Vue 3.5.13 + Element Plus 2.9.1 |
本讲问题均按「版本号 → 复现环境 → 真实报错 → 项目实际现象」四要素记录。
整体链路
css
用户上传文档
↓
Tika 解析(PDF/Word/txt → 纯文本)
↓
TextSplitter 分片(句子边界 + 重叠)
↓
bge-m3 向量化(1024 维)
↓
pgvector 存储(vector_store 表)
用户提问
↓
[查询改写] LLM 把追问改写为完整检索问题
↓
[粗召回] 向量相似度 ≤ 0.35 阈值,topK×3 候选
↓
[精排] 0.65×向量得分 + 0.35×关键词覆盖率
↓
[保留] 向量达标 OR 关键词覆盖 ≥ 50%
↓
[生成] qwenChatClient 基于片段回答 + 引用来源
上传与解析:三个坑
坑 1:上传直接 404
📋 问题档案
- 版本 :Spring AI 1.0.9(未配 embedding 模型时默认调
mxbai-embed-large)· Ollama Windows 版- 复现环境:本地 Ollama 未安装 mxbai-embed-large,知识库页上传任意 PDF/Word/txt
- 真实报错(后端日志):
text
Ollama embedding 接口返回 404:
{"error":"model 'mxbai-embed-large' not found, try pulling it first"}
- 项目实际现象:上传进度走到一半失败------Tika 解析成功,向量化环节 404。知识库功能整体不可用
现象:上传文档,向量化接口 404。
根因:application.yml 没配 embedding 模型,Spring AI 默认调 mxbai-embed-large,本地没装。
yaml
spring:
ai:
ollama:
embedding:
options:
model: bge-m3 # ← 不配默认调 mxbai-embed-large
vectorstore:
pgvector:
dimensions: 1024 # ← bge-m3 输出 1024 维,必须一致
敲黑板 :dimensions 必须与 embedding 模型输出维度严格一致,配错的话向量写入直接报维度不匹配。
坑 2:vector_store 表从未创建
📋 问题档案
- 版本 :Spring AI 1.0.9(手动
PgVectorStore.builder()构建Bean)- 复现环境 :手动 Builder 构建 PgVectorStore,不传
initializeSchema,启动后查库- 真实报错(启动日志原文,注意它只是 INFO 级别不报错):
text
Skipping the schema initialization
- 项目实际现象 :服务正常启动、无任何报错,但
erp_ai库里 vector_store 表压根不存在 ;上传文档时才炸出"relation does not exist"。坑在初始化阶段静默跳过,报错延迟到使用阶段,两者隔了整条链路
日志显示 Skipping the schema initialization,pgvector 的 vector_store 表压根没建。
根因:我手动 PgVectorStore.builder() 构建 Bean 时,initializeSchema 默认是 false。
java
@Bean
public PgVectorStore pgVectorStore(EmbeddingModel embeddingModel, JdbcTemplate jdbcTemplate) {
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
.vectorTableName("vector_store")
.dimensions(1024)
.initializeSchema(true) // ← 手动 Builder 默认 false,必须显式开启
.build();
}
自动配置(starter)默认会建表,手动 Bean 不会------这个差异文档里写得很小,容易漏。
坑 3:大文档 413
📋 问题档案
- 版本 :Nginx 1.30.4(默认
client_max_body_size 1m)· 后端 multipart 50MB- 复现环境:通过 Nginx 80 端口上传大于 1MB 的任何文档,必现
- 真实报错(Nginx 返回页面):
text
<html>
<head><title>413 Request Entity Too Large</title></head>
<body>
<center><h1>413 Request Entity Too Large</h1></center>
<hr><center>nginx/1.30.4</center>
</body>
</html>
- 项目实际现象 :小文档(几百 KB)上传正常,30MB 的 PDF 直接 413------问题与文档内容无关,与大小有关,这个规律直接指向传输层限制而不是后端解析
上传 30MB 的 PDF 报 413 Request Entity Too Large。Nginx 默认 client_max_body_size 1m:
nginx
http {
client_max_body_size 100m;
}
后端 application.yml 也要配套:
yaml
spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
(前端上传提示文案记得与后端保持一致,我前端写 100MB 后端 50MB,用户传 60MB 文件被拒------这种不一致用户会以为系统坏了。)
分片:为什么我重写了 TokenTextSplitter
Spring AI 自带的 TokenTextSplitter 有两个问题:
- 不支持重叠(overlap)------相邻分片零重叠,跨片的句子被拦腰斩断,检索时两边都召回不全
- 按 token 硬切------不考虑句子/段落边界,"库存不足时"可能被切成"库存不足" + "时"
我重写了分片器,核心逻辑:句子/段落边界优先 + 真重叠贪心分片:
java
public class TextSplitter {
public List<String> split(String text) {
// 1. 按段落(\n\n)再按句子(。!?.!?)切出原子单元
List<String> sentences = splitToSentences(text);
// 2. 贪心组装:chunkSize=600 内尽量多装句子
// 下一片从"回退 overlap=120 字符"的句子边界开始
List<String> chunks = new ArrayList<>();
StringBuilder current = new StringBuilder();
int chunkStart = 0;
for (String sentence : sentences) {
if (current.length() + sentence.length() > chunkSize && current.length() > 0) {
chunks.add(current.toString());
// 重叠:下一片回退约 overlap 字符的句子
current = new StringBuilder(tailOverlap(current.toString(), overlap));
chunkStart = ...;
}
current.append(sentence);
}
if (current.length() > 0) chunks.add(current.toString());
return chunks;
}
}
参数经过实测调优:chunkSize 500→600、overlap 50→120。分片过大召回噪声多,过小上下文碎;overlap 120 大约能保住 1-2 个句子的跨片上下文。
一个重要提醒 :改了分片参数后,已入库的旧文档分片还是旧策略的。必须对旧文档点"重建索引"才生效(这是产品逻辑上的坑,用户以为参数改了就全生效了)。检索参数(topK/阈值)走热加载即时生效,分片参数不行------因为分片是入库时固化的。
检索:为什么要两阶段混合检索
纯向量检索有个致命弱点:对术语和编号不敏感。
用户问"MAT-001 的检验标准",向量检索可能召回一堆"检验流程"的相似段落,但真正含 "MAT-001" 的片段可能排在第 8 位之后。
我的两阶段混合检索:
java
public class VectorRetriever {
public List<Document> retrieve(String query, int topK, double threshold, String filter) {
// ===== 阶段 1:粗召回 =====
// 阈值放宽(≤0.35 的距离),取 topK×3 候选,宁滥勿缺
List<Document> candidates = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK * 3)
.similarityThreshold(0.35)
.build());
// ===== 阶段 2:精排 =====
// 0.65 × 向量得分 + 0.35 × 关键词覆盖率
for (Document doc : candidates) {
double vectorScore = 1 - distance(doc); // 向量相似度
double keywordScore = keywordCoverage(query, doc.getText()); // 关键词覆盖
doc.setScore(0.65 * vectorScore + 0.35 * keywordScore);
}
// ===== 保留条件:向量达标 OR 关键词覆盖 ≥ 50% =====
// 排序去重(LinkedHashMap 保持顺序),取 topK
return ranked.values().stream().limit(topK).collect(toList());
}
/** 中文 bigram + 英文单词的关键词覆盖率 */
private double keywordCoverage(String query, String text) {
Set<String> queryTerms = extractTerms(query); // 中文两字滑窗 + 英文整词
if (queryTerms.isEmpty()) return 0;
long hit = queryTerms.stream().filter(text::contains).count();
return (double) hit / queryTerms.size();
}
}
关键词覆盖用中文 bigram("采购流程" → "采购"+"购流"+"流程")而不是分词器------不引依赖,对短术语和编号的覆盖反而更好。
另外一个小坑:早期去重用 HashMap.values(),打乱了得分排序 ,改成 LinkedHashMap 保序。
本讲最有价值的 bug:多轮追问检索为空
现象
📋 问题档案
- 版本 :Spring AI 1.0.9 检索链路 ·
qwen2.5:7b(改写模型)- 复现环境:知识库已上传公司采购流程文档;先问完整问题,再用短句追问
- 真实报错:无异常------检索结果为空是"正常返回",返回体:
json
{"answer":"抱歉,知识库中未检索到与您问题相关的内容。","sources":[]}
- 项目实际现象(对话记录原文):
text
用户:公司的采购流程是什么?
AI: (正常回答,基于 3 条检索片段)
用户:输出简单流程
AI: 抱歉,知识库中未检索到与您问题相关的内容。
前一轮明明答上来了,追问却检索不到------知识就在库里,检索词没带主题。
修复后的真实日志(改写生效的证据):
text
RAG query rewritten: [输出简单流程] -> [公司采购流程的简化版本]
RAG query rewritten: [那第二步呢] -> [采购流程的第二步骤详细说明]
根因
向量检索的 query 是当前问题原文:"输出简单流程"。
这六个字拿去做向量检索,跟"采购流程"文档的相似度低到阈值之外------废话,"输出简单流程"这句话本身就没说主题是什么。检索器不知道对话上下文,它只看这一句。
修复:检索前的查询改写
用 LLM 把"依赖上下文的短句追问"改写成"可独立检索的完整问题":
java
private String buildSearchQuery(String query, List<Map<String, String>> history) {
// 短路条件:无历史 / 问题够长且不含指代词 → 不改写,省一次 LLM 调用
if (history == null || history.isEmpty()) return query;
String q = query == null ? "" : query.trim();
if (q.length() > 25 && !containsReferenceWord(q)) return query;
StringBuilder prompt = new StringBuilder();
prompt.append("你正在协助一个知识库检索系统。请根据以下对话历史,把用户的\"当前问题\"改写成一个可以独立用于向量检索的完整问题。")
.append("改写后的问题必须包含完整的主题和意图,不要省略关键词;只输出改写后的问题,不要解释。\n\n")
.append("对话历史:\n");
for (Map<String, String> h : history) {
String role = "user".equalsIgnoreCase(h.get("role")) ? "用户" : "助手";
if (!h.getOrDefault("content", "").isBlank())
prompt.append(role).append(":").append(h.get("content")).append("\n");
}
prompt.append("\n当前问题:").append(q).append("\n\n改写后的问题:");
try {
String rewritten = qwenChatClient.prompt().user(prompt.toString()).call().content();
if (rewritten != null && !rewritten.isBlank()) {
String result = rewritten.trim().replaceAll("^[\"'"]+|[\"'"]+$", "");
if (!result.isEmpty() && !result.equalsIgnoreCase(q)) {
log.info("RAG query rewritten: [{}] -> [{}]", q, result);
return result;
}
}
} catch (Exception e) {
log.warn("RAG query rewrite failed, fallback to original query: {}", q, e);
}
return query; // 改写失败降级用原问题
}
private boolean containsReferenceWord(String query) {
String[] refs = {"这","那","此","它","上述","这个","那个","第二步","下一步",
"上一","下一","刚才","之前","后面","接下来"};
for (String r : refs) if (query.contains(r)) return true;
return false;
}
设计要点:
- 短路条件:问题 >25 字且不含指代词,大概率是完整问题,跳过改写省一次 LLM 调用(延迟和算力都要省)
- 降级策略:改写调用失败时用原问题,RAG 主链路不因改写失败而挂
- prompt 约束:明确"只输出改写后的问题",并 trim 掉模型可能输出的引号
修复效果
arduino
1. "公司的采购流程是什么?" → 直接检索,命中 3 条来源
2. "输出简单流程" → 改写为"公司采购流程的简化版本" → 命中 2 条
3. "那第二步呢" → 改写为"采购流程的第二步骤详细说明" → 命中 1 条
三轮全部通过。这个修复的通用性很强------任何 RAG 系统遇到多轮追问检索质量下降,第一嫌疑犯就是"检索 query 没结合历史"。
生成:基于上下文回答 + 引用溯源
java
public RAGResponse ask(String query, List<Map<String, String>> history) {
String searchQuery = buildSearchQuery(query, history); // 查询改写
List<Document> docs = vectorRetriever.retrieve(searchQuery, topK, 0, null);
if (docs.isEmpty()) {
return RAGResponse.of("知识库中未检索到与您问题相关的内容。", List.of());
}
String context = docs.stream()
.map(d -> d.getText())
.collect(Collectors.joining("\n\n---\n\n"));
String answer = qwenChatClient.prompt()
.system("你是企业知识库问答助手。仅基于提供的上下文回答,"
+ "上下文没有的信息如实说明不知道,不要编造。")
.user("上下文:\n" + context + "\n\n问题:" + searchQuery)
.call()
.content();
List<RAGResponse.Source> sources = docs.stream()
.map(d -> new Source(d.getMetadata().get("docName"),
truncate(d.getText(), 100), d.getScore()))
.collect(toList());
return RAGResponse.of(answer, sources);
}
前端把 sources 渲染成"引用来源"折叠面板(含相关度分数),用户可以核对 AI 的回答有没有出处------可溯源是企业级 RAG 的底线,不然用户凭什么信。
本讲踩坑清单
| # | 坑 | 涉及版本 | 根因 | 解法 |
|---|---|---|---|---|
| 1 | 上传向量化 404 | Spring AI 1.0.9(未配 embedding) | 默认调 mxbai-embed-large | bge-m3 + dimensions 1024 |
| 2 | vector_store 表不存在 | Spring AI 1.0.9 手动 Builder | initializeSchema 默认 false | .initializeSchema(true) |
| 3 | 大文件 413 | Nginx 1.30.4 | 默认 1m 限制 | client_max_body_size 100m |
| 4 | 跨片句子召回不全 | Spring AI TokenTextSplitter | 无 overlap | 重写句子边界分片器,overlap=120 |
| 5 | 术语/编号检索弱 | 纯向量检索 | 向量对编号不敏感 | 两阶段混合检索 + bigram 关键词覆盖 |
| 6 | 去重后排序乱 | HashMap.values() | 无序集合 | LinkedHashMap |
| 7 | 追问检索为空 | 检索链路无历史 | query 未结合上下文 | LLM 查询改写 + 指代词检测短路 |
| 8 | 改参数后检索没变化 | 入库时固化分片 | 旧分片还是旧策略 | 提示用户重建索引 |
写在最后
RAG 模块做完,系统三大能力(工具调用 / 流式对话 / 知识库问答)就齐了。回看整个模块,最值得记住的一句话是:检索质量的上限在入库时(分片策略)就决定了,检索策略只是在兑现这个上限。
下一篇回到前端:Pinia 状态设计、Axios 拦截器工程化、Markdown 渲染的坑(# 和 ** 让你的对话界面版式起飞)。