【WMS 仓储系统集成 AI Agent 实战】第 6 讲:RAG 知识库——“输出简单流程“为什么检索不到任何东西

【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 有两个问题:

  1. 不支持重叠(overlap)------相邻分片零重叠,跨片的句子被拦腰斩断,检索时两边都召回不全
  2. 按 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→600overlap 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;
}

设计要点:

  1. 短路条件:问题 >25 字且不含指代词,大概率是完整问题,跳过改写省一次 LLM 调用(延迟和算力都要省)
  2. 降级策略:改写调用失败时用原问题,RAG 主链路不因改写失败而挂
  3. 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 渲染的坑(#** 让你的对话界面版式起飞)。

相关推荐
用户8181870627461 小时前
第25章 Redis集群脑裂实录:一次真实故障复盘
java·后端
小七在进步1 小时前
类和对象(三)
java·开发语言
夜不会漫长1 小时前
C++:类和对象(1)
java·开发语言·c++
czt_java1 小时前
Java文件IO核心知识点总结
java·开发语言
EatFan1 小时前
从单体到模块化:我的 Spring Boot 项目为什么拆成 framework、module、server?
java·spring boot·后端
Java内核笔记1 小时前
Spring Boot 4 可观测性源码剖析:OpenTelemetry 全链路打通日志、指标、追踪
java·后端
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之跨域处理
java·开发语言·spring
我是大猴子1 小时前
MyBatis‑Plus & MyBatis‑Flex 区别
java·服务器·数据库
Bs_MoneyMagnet2 小时前
基于springboot+vue的在线音乐管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring