📌系列说明:一个 Java 后端视角的 Spring AI 渐进式实战教程,载体为开源项目「劳小司 · 智能法律助手」。
- 序章:技术栈全景与 AI 学习指南
- 阶段一 · 流式对话内核:篇1 SSE 流式·停止·思考可见化 / 篇2 会话记忆压缩与滚动体验
- 阶段二 · 工具调用:篇1 Function Calling 与法律计算器 / 篇2 联网搜索与工具预算
- 阶段三 · RAG 知识库:篇1 起步与底账化 / 篇2 Agentic RAG 与引用可信度 /篇3 检索质量工程与知识库体验(本文)
- 阶段四 · 多模型路由:篇1 五路级联路由
- 阶段五 · 安全与质量门:篇1 安全层与强制检索 / 篇2 质量门与评估门禁 / 篇3 指代消解与阻塞隔离
- 阶段六 · 产品化与用户体系:篇1 认证·配额·门禁 / 篇2 前端·移动端·身份 / 篇3 劳动法专精与多模态
- 阶段七 · 存储演进与部署:篇1 存储迁移 / 篇2 部署契约
📦本篇涉及:
rag/KnowledgeRetrievalService.java、rag/RerankService.java、rag/LawArticleBrowseService.java。
一、单纯向量检索的天花板
篇1、篇2 打通了"检索得到、同步得起、引用可信",但一个核心问题还没解决:检索准不准?
单靠向量 Top-K 有两个天花板:
- Bi-Encoder 精度有限:向量检索是 query 和 doc各自独立编码成向量再算余弦,无法建模词级交互(比如"合同解除"和"解除合同"语义近,但"未解除合同"和"解除合同"向量可能很近,语义却相反);
- 离散符号命中差:用户问"第四十七条",向量检索对这种精确编号反而不如关键词匹配。
一个直觉的错误做法是"把 topK 调大点,多召回一些总没错"。大错特错——噪声条目会稀释模型注意力、撑爆上下文、还会诱发模型"从一堆半相关条文里硬凑依据"的幻觉。召回率和精度不能靠一个 topK 硬扛。
工业界的标准解是两阶段:宽进粗召回 + 窄出精排。本项目落地为五阶段管线。
二、五阶段检索管线
问题(Planner 改写后) ① 稠密路粗召回 pgvector KNN,recallTopK=12,similarity-threshold=0.55 截断噪声 ② 稀疏路召回 pg_trgm GIN(ILIKE),bm25TopK=10,补"第四十七条"类离散编号精确匹配 ③ RRF 融合 score = Σ 1/(60+rank),两路互补,条文级去重 ④ rerank 精排 百炼 gte-rerank-v2 Cross-Encoder 取 topK=5(失败降级融合序) ⑤ Small-to-Big chunk 子块命中 → 按 articleId 还原整条全文注入对应retrieveCitations()的主干:稠密召回 → 稀疏召回 → RRF 融合 → rerank → 整条还原。下面逐个拆。
① 稠密路:向量 KNN 粗召回
SearchRequest.builder().query(query).topK(recallTopK)// 12,宽进.similarityThreshold(similarityThreshold)// 0.55,低于此视为未命中.build();recallTopK=12是"宽进"——先多召回一些保召回率;similarity-threshold=0.55是降噪闸门,低于阈值的直接砍掉,同时激活"检索落空"分支(落空时提示模型换词或转通用知识)。可选category过滤(劳动/民事…)缩小检索域。
② 稀疏路:pg_trgm 补精确匹配
向量不擅长的"精确编号/术语",交给稀疏检索。本项目用 PostgreSQL 的pg_trgm GIN 索引(三元组 + ILIKE)近似 BM25,命中"第四十七条"这种离散符号。它是向量路的互补,不是替代。
③ RRF 融合:两路合成一个序
Reciprocal Rank Fusion,公式极简:score(d) = Σ 1/(k + rank),k=60(工业经验值)。
scores.merge(key,1.0/(RRF_K+i+1),Double::sum);// rank 从 1 起妙处在于:只用名次不用原始分数,天然规避了"向量余弦分"和"BM25 分"不同量纲无法直接相加的问题。同一条文的多个 chunk 按lawName|articleNo条文级去重,保留融合分最高的。
④ rerank 精排:Cross-Encoder 的关键一跃
前一步的融合还是 Bi-Encoder 级别的排序。这里上Cross-Encoder:把 query 和每个候选 doc拼在一起送进gte-rerank-v2,模型输出精确的相关性分数,重排取 topK=5。
Map<String,Object>body=Map.of("model","gte-rerank-v2","input",Map.of("query",query,"documents",docs),"parameters",Map.of("top_n",topN,"return_documents",false));精度高但慢、贵,所以只用在"窄出"这一层(对 12 条候选精排,不是对全库)。和联网搜索一样,Spring AI 没有 rerank 自动装配,手写 RestClient 直调。精排的relevance_score回写进citation.score——前端引用卡上的TOP 0.xx就是 Cross-Encoder 的真实分数(注意:它和 ① 的余弦相似度阈值不是同一个量表)。
⑤ Small-to-Big:命中小块,注入整条
篇1 分块时把长条文拆成了子块(检索粒度小、保精度)。命中子块后,按articleId回 PG 底账还原整条全文再注入 Prompt(注入粒度大、保上下文完整):
if(!c.chunked()||c.articleId()==null)returnc;LawArticleEntitya=lawArticleMapper.findById(c.articleId());returnnewLawCitation(a.getId(),...,a.toEmbeddingText(),false);// 整条还原检索用小块、注入用整条——这就是 Small-to-Big 的精髓。
三、全链路降级信条
五阶段里每一阶段失败都不阻断,只降级:
| 阶段失败 | 降级 |
|---|---|
| 分类过滤检索失败 | 去掉过滤重搜 |
| BM25 稀疏路失败(索引未建) | 退纯稠密路 |
| rerank 精排失败 | 退 RRF 融合序截断 |
| Small-to-Big 还原失败 | 退 chunk 子块文本 |
| 整条向量库不可用 | 返回空列表,对话退化为纯 LLM |
检索是"锦上添花"的能力,绝不能因为精排服务抖动就把用户的对话带崩。
四、知识库前端体验
检索质量之外,法条库浏览页(LawArticleBrowseService)也做了体验精修:faceted 高级检索(按法名/领域/条号分面过滤)、分页栏固定底部、法条正文的字体与标签样式优化。这部分偏前端,核心是"把后端检索能力如实、清晰地呈现给用户",与引用可信度一脉相承。
五、踩坑备忘
① topK 不是越大越好。调大 topK 看似提高召回,实则引入噪声、稀释注意力、诱发硬凑幻觉。要召回率和精度兼得,靠的是"宽进(recallTopK)+ 窄出(rerank topK)"两阶段,不是单点调参。
② pg_trgm 索引要先建。稀疏路依赖pg_trgmGIN 索引,扩展没装或索引没建时keywordSearch会抛异常——代码里 catch 住降级为纯稠密路,不阻断。上线前记得确认扩展和索引就位。
③ rerank 分数 ≠ 向量相似度。两个量表别混:引用卡TOP 0.xx是 Cross-Encoder 相关性分,similarity-threshold=0.55是余弦相似度阈值。给用户的文案别写错。
④ RRF 融合用名次不用分数。千万别试图把余弦分和 BM25 分加权平均——量纲不同,加出来是错的。RRF 只取名次,是这个场景的标准解。
六、小结
| 阶段 | 作用 |
|---|---|
| 稠密粗召回 | 向量 KNN,宽进保召回 |
| 稀疏召回 | pg_trgm 补精确编号 |
| RRF 融合 | 名次合成,跨量纲 |
| rerank 精排 | Cross-Encoder 窄出提精度 |
| Small-to-Big | 小块命中、整条注入 |
七、下篇预告
到这里,RAG 知识库这条线(起步→底账化→Agentic→引用可信→检索质量)完整闭环了。但一直用同一个模型跑所有问题,成本和质量的平衡没解决——闲聊用贵模型浪费、复杂推理用便宜模型降质。阶段四,我们做五路模型级联路由。
🌐源码与体验:Gitee(国内快)https://gitee.com/spaserby/laoxiaosi.git | GitHub https://github.com/spaserby/laoxiaosi.git
🖥 在线演示:https://laoxiaosi.noctisblue.com
本系列全套代码皆开源,觉得这篇有帮助,欢迎顺手点颗 ⭐