Spring AI Alibaba + RAG 实战:知识库检索模块从设计到落地
混合检索 + 幂等入库 + 动态权重,这是 AI 客服知识库能跑稳的核心
与上一篇的关系
上一篇讲了 AI 客服系统的整体架构------情绪感知、意图识别、Agent 工具链。这篇是那篇的续集,专门讲 RAG 知识库模块的设计与实现。
如果你没看过上一篇,这里先补一张对比表,让你知道 RAG 模块在整个系统里处于什么位置:
| 上一篇已有的 | 本篇新增的 |
|---|---|
| 情绪感知(EmotionAnalyzer) | 文档摄入管线(Ingest Pipeline) |
| 意图识别(IntentClassifier) | 多格式文档解析(PDF / MD / TXT / Word) |
| Agent 工具链(订单+退款) | 向量检索 + 关键词倒排双路并行 |
| Redis 会话缓存 | RRF 混合检索融合排序 |
| MyBatis-Plus 数据访问 | searchWithScore() 相似度评分扩展 |
| 多模块 Maven 架构 | 动态检索权重(system_config 表控制) |
一、RAG 模块在整体架构中的位置
先回顾一下整体请求链路,RAG 是其中的一个分支:
markdown
用户问题
→ ChatController
→ ConversationService(会话管理)
→ 意图识别 → 命中 RAG 意图
→ HybridSearchService(混合检索)
├── PgVectorKnowledgeStore(向量检索,语义相似度)
└── RedisKeywordIndex(关键词倒排索引,jieba 中文分词)
→ 构建 System Prompt(含检索到的 context)
→ ZhipuAI / Ollama 生成回答
核心代码在 ai-csr-rag 模块下,检索层 + 入库层 + 索引层三个组件各自独立、职责清晰,互不耦合。
二、文档摄入管线(Ingest Pipeline)
用户上传知识库文档之后,系统需要把它切块、生成向量、建索引,这一套流程就是摄入管线。
2.1 整体流程
文档上传
→ MultiFormatDocumentLoader(支持 PDF / TXT / Markdown / Word)
→ TokenTextSplitter(按 token 分块,默认 500)
→ PgVectorKnowledgeStore(Ollama 生成 embedding,写 pgvector)
→ RedisKeywordIndex(jieba 分词,建立 Redis 倒排索引)
→ KnowledgeDocument 状态更新为 READY
2.2 几个关键的设计决策
多格式统一入口
不同格式文档的解析逻辑差异很大------PDF 要提取正文并清洗特殊字符,Markdown 要去掉语法标记,Word 要处理样式嵌套。MultiFormatDocumentLoader 统一对外暴露一个接口,内部按文件类型分派具体解析器,上层调用方不用关心格式细节。
每个 Chunk 独立持有 metadata
这里有个不起眼但很重要的细节:分块时必须给每个 chunk new HashMap<>(doc.getMetadata()),而不是直接复用父文档的 metadata 引用。
为什么?因为如果共享同一个 Map,多个 chunk 写 chunk_index 时会互相覆盖,最后所有 chunk 的 chunk_index 都变成同一个值,入库触发唯一约束冲突。这个坑我踩过,留到踩坑篇细讲。
幂等写入
PgVectorKnowledgeStore.add() 在入库前先按 document_id DELETE 旧数据,再配合 ON CONFLICT DO NOTHING 写入。这样重复 ingest 同一份文档不会报错,也不会留下重复数据。线上知识库更新文档是高频操作,幂等是必须的。
三、混合检索(Hybrid Search)
这是整个 RAG 模块最核心的部分。
3.1 为什么要混合,而不是单纯向量检索
纯向量检索在语义相似的场景很好用,但有个明显短板:用户问"退款状态是 PENDING 是什么意思",向量检索会把语义相近的内容都召回来,但"PENDING 是精确词"------这种场景关键词检索命中率反而更高。
反过来,纯关键词检索应对近义词、缩写、语义跨度大的问法就不行了。
所以两条路都要走,再融合排序,这就是混合检索的出发点。
3.2 检索流程
ini
查询请求
├── 语义路:Ollama embedding → pgvector cosine 检索 → TopK 结果 + similarity score
└── 关键词路:jieba 分词 → Redis 倒排索引命中 → TopK 结果
→ RRF 融合(k=60)→ 统一排名列表
两路并行,互不阻塞,最后用 RRF(Reciprocal Rank Fusion) 做融合排序。RRF 的公式很简单:每个文档在两路结果里分别有排名,把 1/(k + rank) 加起来就是最终得分,k=60 是经验值,能平衡两路结果的权重差异。
3.3 动态权重配置
yaml
# 不在 yml 里写死,在 system_config 表里动态控制
rag.keyword_weight = 0.4
rag.vector_weight = 0.6
keyword_weight 和 vector_weight 从 system_config 表里读,不重启服务就能调整检索策略。这个设计在灰度调参时非常好用------先把 keyword_weight 调高跑几天,看召回质量,不满意再往回调,不用打包发版。
| 配置项 | 说明 | 推荐起始值 |
|---|---|---|
rag.keyword_weight |
关键词检索在融合中的权重 | 0.4 |
rag.vector_weight |
语义检索在融合中的权重 | 0.6 |
rag.top_k |
每路各取 TopK 结果 | 5 |
rag.similarity_threshold |
语义相似度过滤阈值 | 0.5 |
3.4 相似度评分扩展
Spring AI 的 VectorStore.similaritySearch() 默认不返回相似度分数,只返回文档列表。但 RRF 融合需要分数来排序,所以这里做了扩展:
- 新建
SearchHitDTO,包含document和score两个字段 - 在
PgVectorKnowledgeStore里新增searchWithScore()方法 - 通过向量内积计算余弦相似度,回填到
SearchHit.score
扩展之后,每条检索结果都带着 similarity 字段,既能用于 RRF 融合,也能在 System Prompt 里标注置信度,让大模型知道这条知识的可靠程度。
四、Session 会话管理
多轮对话不是独立的,前几轮的上下文要带进来,大模型才能理解"你刚才说的那个订单"指的是哪一个。
SessionService 维护 USER / ASSISTANT / SYSTEM 三种角色消息记录,每轮对话结束后追加写入,下一轮检索时一并注入 System Prompt。支持历史回溯和滑动窗口截断,避免上下文过长撑爆 token 限制。
五、当前系统状态
| 模块 | 状态 | 备注 |
|---|---|---|
| 文档摄入(PDF / TXT / MD) | ✅ 正常 | 12 个 chunk,embeddings 全入库 |
| 向量语义检索 | ✅ 正常 | 相似度 0.55--0.63 区间命中 |
| 关键词倒排检索 | ✅ 正常 | jieba NPE 已修复,fallback 兜底 |
| 混合检索 RRF 融合 | ✅ 正常 | 双路召回,k=60 融合 |
| 会话管理 | ✅ 正常 | USER / ASSISTANT 消息写入正常 |
| Ollama 本地模型 | ✅ 正常 | bge-large-zh-v1.5 向量模型就绪 |
六、后续计划
已完成
- Spring AI Alibaba 集成智谱 GLM-4
- 情绪感知模块
- 意图识别模块
- Agent 工具链(订单查询、退款申请)
- 订单查询服务(MyBatis-Plus)
- 退款申请服务
- RAG 知识库模块(文档上传 → 向量入库 → 检索问答)
正在做
- 多轮对话记忆优化
- 转人工功能(自动转接 + 手动转接)
后面要做
- 知识库运营后台
- 客服工作台(人工接待)
- 数据统计与监控大屏
源码怎么拿
公众号 「亦暖筑序」 底部菜单 【获取源码】,完整的 Gitee 仓库直接拉。
源码里除了文章提到的这些,还有几个文章没展开的:
- 数据库初始化脚本(5 张表,带模拟数据,跑起来就能测)
- 完整的多模块 pom 依赖配置(不用自己一个个试版本了)
- 情绪分析 / 意图识别的完整 Prompt 模板(文章里只是核心片段,源码里是完整可运行的)
说白了吧,看完文章理解思路,拿到源码照着跑,这才是最省时间的路径。不拿源码硬看,你会卡在"这行代码放哪个模块"这种低级问题上浪费半天。
💬 想继续聊的
你在项目里有没有遇到过"检索能命中,但回答驴唇不对马嘴"的情况?大概率是 Prompt 里的 context 注入方式有问题,或者 chunk 切分粒度没调好。评论区说一句,下篇可以专门讲这个。
RAG 知识库踩坑复盘(五个真实 Bug)已经写好了,关注等更新。