RAG 实战与踩坑:交通法规智能问答助手
从四份官方 PDF 到一个带引用溯源、敢拒答、支持多轮追问的智能问答系统------本文记录这条 RAG 链路的完整实现、全部踩坑与对应优化。语料为四部交通法规(《道路交通安全法》124 条、《实施条例》115 条、《事故处理程序规定》114 条、《记分管理办法》37 条),最终知识库 453 个结构化 chunk,基于 Spring AI 2.0.1 + pgvector + 自研混合检索管线构建。
文中所有坑均为实际踩中并修复的问题,标注了完整因果链与排查方法,可直接作为同类项目的避坑清单。
目录
- [1. 全流程总览](#1. 全流程总览 "#1-%E5%85%A8%E6%B5%81%E7%A8%8B%E6%80%BB%E8%A7%88")
- [2. 知识库构建:ETL 流水线](#2. 知识库构建:ETL 流水线 "#2-%E7%9F%A5%E8%AF%86%E5%BA%93%E6%9E%84%E5%BB%BAetl-%E6%B5%81%E6%B0%B4%E7%BA%BF")
- [2.1 结构探测](#2.1 结构探测 "#21-%E7%BB%93%E6%9E%84%E6%8E%A2%E6%B5%8B%E5%86%99%E8%A7%A3%E6%9E%90%E5%99%A8%E4%B9%8B%E5%89%8D%E5%85%88%E6%91%B8%E6%B8%85%E6%8E%92%E7%89%88")
- [2.2 文本抽取与清洗](#2.2 文本抽取与清洗 "#22-%E6%96%87%E6%9C%AC%E6%8A%BD%E5%8F%96%E4%B8%8E%E6%B8%85%E6%B4%97")
- [2.3 目录剥离](#2.3 目录剥离 "#23-%E7%9B%AE%E5%BD%95%E5%89%A5%E7%A6%BB")
- [2.4 章/节/条状态机解析](#2.4 章/节/条状态机解析 "#24-%E7%AB%A0%E8%8A%82%E6%9D%A1%E7%8A%B6%E6%80%81%E6%9C%BA%E8%A7%A3%E6%9E%90")
- [2.5 分块策略:本流水线的灵魂](#2.5 分块策略:本流水线的灵魂 "#25-%E5%88%86%E5%9D%97%E7%AD%96%E7%95%A5%E6%9C%AC%E6%B5%81%E6%B0%B4%E7%BA%BF%E7%9A%84%E7%81%B5%E9%AD%82")
- [2.6 元数据与 embedText 组装](#2.6 元数据与 embedText 组装 "#26-%E5%85%83%E6%95%B0%E6%8D%AE%E4%B8%8E-embedtext-%E7%BB%84%E8%A3%85")
- [2.7 校验:贯穿流水线,不是收尾一步](#2.7 校验:贯穿流水线,不是收尾一步 "#27-%E6%A0%A1%E9%AA%8C%E8%B4%AF%E7%A9%BF%E6%B5%81%E6%B0%B4%E7%BA%BF%E4%B8%8D%E6%98%AF%E6%94%B6%E5%B0%BE%E4%B8%80%E6%AD%A5")
- [2.8 合并与版本管理](#2.8 合并与版本管理 "#28-%E5%90%88%E5%B9%B6%E4%B8%8E%E7%89%88%E6%9C%AC%E7%AE%A1%E7%90%86%E6%B3%95%E5%BE%8B%E5%9C%BA%E6%99%AF%E7%89%B9%E6%9C%89")
- [3. 向量库摄取](#3. 向量库摄取 "#3-%E5%90%91%E9%87%8F%E5%BA%93%E6%91%84%E5%8F%96")
- [3.1 幂等设计](#3.1 幂等设计 "#31-%E5%B9%82%E7%AD%89%E8%AE%BE%E8%AE%A1")
- [4. 查询理解层:路由 + 多查询改写](#4. 查询理解层:路由 + 多查询改写 "#4-%E6%9F%A5%E8%AF%A2%E7%90%86%E8%A7%A3%E5%B1%82%E8%B7%AF%E7%94%B1--%E5%A4%9A%E6%9F%A5%E8%AF%A2%E6%94%B9%E5%86%99")
- [4.1 一次调用三合一](#4.1 一次调用三合一 "#41-%E4%B8%80%E6%AC%A1%E8%B0%83%E7%94%A8%E4%B8%89%E5%90%88%E4%B8%80")
- [4.2 核心 prompt](#4.2 核心 prompt "#42-%E6%A0%B8%E5%BF%83-prompt%E6%9C%AF%E8%AF%AD%E5%BD%92%E4%B8%80%E6%98%AF%E5%8F%AC%E5%9B%9E%E8%B4%A8%E9%87%8F%E7%9A%84%E5%85%B3%E9%94%AE")
- [4.3 踩坑实录:静默降级掩盖了配置错误](#4.3 踩坑实录:静默降级掩盖了配置错误 "#43-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95%E9%9D%99%E9%BB%98%E9%99%8D%E7%BA%A7%E6%8E%A9%E7%9B%96%E4%BA%86%E9%85%8D%E7%BD%AE%E9%94%99%E8%AF%AF")
- [5. 检索层:混合检索 → RRF → LLM 精排 → 剪枝 → 父子折叠](#5. 检索层:混合检索 → RRF → LLM 精排 → 剪枝 → 父子折叠 "#5-%E6%A3%80%E7%B4%A2%E5%B1%82%E6%B7%B7%E5%90%88%E6%A3%80%E7%B4%A2--rrf--llm-%E7%B2%BE%E6%8E%92--%E5%89%AA%E6%9E%9D--%E7%88%B6%E5%AD%90%E6%8A%98%E5%8F%A0")
- [5.1 为什么混合](#5.1 为什么混合 "#51-%E4%B8%BA%E4%BB%80%E4%B9%88%E6%B7%B7%E5%90%88%E4%B8%A4%E8%B7%AF%E5%90%84%E8%87%AA%E7%9A%84%E7%9B%B2%E5%8C%BA")
- [5.2 BM25 的实现选型](#5.2 BM25 的实现选型 "#52-bm25-%E7%9A%84%E5%AE%9E%E7%8E%B0%E9%80%89%E5%9E%8B")
- [5.3 RRF 融合与多查询稀释](#5.3 RRF 融合与多查询稀释 "#53-rrf-%E8%9E%8D%E5%90%88%E4%B8%8E%E5%A4%9A%E6%9F%A5%E8%AF%A2%E7%A8%80%E9%87%8A")
- [5.4 LLM 精排](#5.4 LLM 精排 "#54-llm-%E7%B2%BE%E6%8E%92")
- [5.5 精排后剪枝](#5.5 精排后剪枝 "#55-%E7%B2%BE%E6%8E%92%E5%90%8E%E5%89%AA%E6%9E%9D")
- [5.6 父子折叠](#5.6 父子折叠 "#56-%E7%88%B6%E5%AD%90%E6%8A%98%E5%8F%A0")
- [5.7 聚合问法直达](#5.7 聚合问法直达 "#57-%E8%81%9A%E5%90%88%E9%97%AE%E6%B3%95%E7%9B%B4%E8%BE%BE")
- [6. 生成层:法律场景的防幻觉铁律](#6. 生成层:法律场景的防幻觉铁律 "#6-%E7%94%9F%E6%88%90%E5%B1%82%E6%B3%95%E5%BE%8B%E5%9C%BA%E6%99%AF%E7%9A%84%E9%98%B2%E5%B9%BB%E8%A7%89%E9%93%81%E5%BE%8B")
- [7. 多轮会话记忆](#7. 多轮会话记忆 "#7-%E5%A4%9A%E8%BD%AE%E4%BC%9A%E8%AF%9D%E8%AE%B0%E5%BF%86")
- [8. 效果验证与分诊方法论](#8. 效果验证与分诊方法论 "#8-%E6%95%88%E6%9E%9C%E9%AA%8C%E8%AF%81%E4%B8%8E%E5%88%86%E8%AF%8A%E6%96%B9%E6%B3%95%E8%AE%BA")
- [9. 踩坑总表](#9. 踩坑总表 "#9-%E8%B8%A9%E5%9D%91%E6%80%BB%E8%A1%A8")
- [10. 方法论总结](#10. 方法论总结 "#10-%E6%96%B9%E6%B3%95%E8%AE%BA%E6%80%BB%E7%BB%93")
- [11. 已知局限与演进方向](#11. 已知局限与演进方向 "#11-%E5%B7%B2%E7%9F%A5%E5%B1%80%E9%99%90%E4%B8%8E%E6%BC%94%E8%BF%9B%E6%96%B9%E5%90%91")
- [12. 附录:文件清单与技术栈](#12. 附录:文件清单与技术栈 "#12-%E9%99%84%E5%BD%95%E6%96%87%E4%BB%B6%E6%B8%85%E5%8D%95%E4%B8%8E%E6%8A%80%E6%9C%AF%E6%A0%88")
1. 全流程总览
markdown
PDF ──► 结构探测 ──► 文本抽取与清洗 ──► 目录剥离 ──► 章/节/条状态机解析
──► 分块(普通条 + 列举条按项切分)──► 元数据与 embedText 组装
──► 校验 ──► 版本管理 ──► 向量库摄取(幂等)
│
查询管线 ──► 会话历史 ──► 查询理解(意图路由 + 多查询变体)
──► 短路链(闲聊 / FAQ 缓存 / 聚合问法直达)
──► 混合检索(向量 + BM25 → RRF → LLM 精排 → 剪枝 → 父子折叠)
──► 受约束生成(引用标注 + 拒答纪律)
──► 响应(answer + citations 溯源 + 会话记忆写回)
两条核心原则贯穿全程:
- 法条类文档"按结构切,不按 token 切"------章/节/条/项就是天然的语义边界,任何固定长度滑窗在这类语料上都是错的。
- 检索质量决定上限,生成只是守住下限------实践中的 RAG 效果问题,八成出在检索侧而非模型侧。
各场景的端到端成本(实测):
| 场景 | LLM 调用 | 检索 | 耗时 |
|---|---|---|---|
| 纯问候 | 0 | 0 | <100ms |
| 闲聊 | 1 次小模型 | 0 | ~1s |
| FAQ 命中 | 1 次小模型(路由) | 0 | ~1s |
| 聚合问法("记3分有哪些") | 1 小 + 1 大 | 零向量 | ~4s |
| 常规法规咨询 | 1 小 + 2 大 | 3 变体 × 2 路 | 6~12s |
2. 知识库构建:ETL 流水线
2.1 结构探测:写解析器之前,先摸清排版
对每份 PDF 先跑一遍探测脚本,统计页数、条号样式行数、章/节样式行数、页码格式、目录有无、列举项标记分布。统计数要与该法规的公知条数对得上,对不上就是解析有漏,不带病进入下一步。
这条纪律的必要性来自实战------同一个"第X条",在不同官方 PDF 里至少有三种排版:
| 变体 | 实例 | 后果 |
|---|---|---|
| 条号独立成行 | 道交法、实施条例、记分办法 | 最友好 |
| 条号与正文同行 | 事故处理规定:"第一条为了规范..." | 正则若只匹配整行,一条都切不出 |
| 同文档混排 | 事故处理规定第一百一十二条又独立成行 | 单一正则必漏 |
| 章标题从词中间劈开 | "第六章调" + "查"(两行) | 章名解析成"调" |
| 节标题混排 | "第一节一般规定"(合并)vs "第二节"(独立) | 解析逻辑需双形态兼容 |
| 页码样式 | -1-(全角)vs - 1 -(半角带空格) |
清洗正则不全则残留噪音 |
2.2 文本抽取与清洗
PyMuPDF 逐页抽文本 → 去空行 → 去页脚页码。四个要点:
- 先判断文本型还是扫描型 :政府公文常见扫描件,
get_text()抽出空内容即是信号,必须转 OCR + 版面分析路线; - 页码正则覆盖全角/半角/长破折号 :
^[-------]\s*\d+\s*[-------]$; - PDF 抽取会在数字与汉字间插空格 :抽出的是"一次记12 分"------所有涉及数值的正则都要写成
记\s*(\d+)\s*分。本项目记分值提取第一版就因这个空格全部失配; - 保留原始行序,不做任何"智能"合并------结构信息就藏在行与行的关系里。
2.3 目录剥离
目录会重复所有章/节标题甚至条号。不剥离的直接后果是检索时先召回目录而不是条文------"法律责任"四个字在目录页和正文标题各出现一次,目录页更靠前、更短、更"像答案"。
利用法条类文档的特性定位正文起点:目录只列标题、不列条号正文,因此首个行首"第X条"必在正文。从它向前回溯找到所属章标题行,即为正文起点。
2.4 章/节/条状态机解析
单遍扫描,维护 (当前章, 当前节, 当前条) 三个状态变量。四个关键防御,每个都对应一次真实事故:
- 章切换时必须重置节------否则第九十一条会带着第四章"高速公路的特别规定"的残留节信息跨到"法律责任"章。状态机最常见的 bug 就是"切换了父状态、忘了清子状态"。
- 条号严格连续性校验(1..N 不缺不重)------正文里有大量"依照本法第X条"式引用,行首出现时会被误判成新条目。序列校验(期望值 = 上一条 + 1)是唯一可靠的兜底。
- 章/节标题短行聚合------"第六章调"+"查"要拼回"调查",停止条件是遇到下一个章/节/条样式行或标题超长。
- 条号与正文同行时------条号取"第X条"前缀、其余为正文,与独立成行形态用同一正则统一处理。
一次真实事故复盘 :条号序列判断最初用了 records[-1](已入列的最后一条),而当前条还暂存在 cur 未入列------期望值恒差 1,124 条全部被当成"序列异常的引用"吞进第一条。教训:状态机的"当前对象"在哪个变量里,写完立刻用真实数据跑;序列校验失败会当场爆炸而不是静默出错------这正是校验的价值。
2.5 分块策略:本流水线的灵魂
普通条:一条 = 一个 chunk。 固定 512 token 滑窗会把条文拦腰截断("第九十一条"的酒驾处罚切两半)。按条切分后实测 chunk 长度 15~517 字符、平均约 120,天然落在 embedding 舒适区。
列举条:按"项"切分 + 前导句继承 + 父子双索引。 "下列...(一)(二)..."式列举条是检索黑洞(记分管理办法实测):
| 通道 | 被压制的原因(实测数据) |
|---|---|
| 向量 | 第十一条 648 字符塞 15 个项,多主题稀释------"避让行人"只占全文 0.9%,与查询的相似度被均值拉平 |
| BM25 | 648 ≈ 5×avgdl,b=0.75 的长度归一化把 tf 饱和项压掉约 5 倍 |
解法三要素,缺一不可:
- 一"项"一 chunk :
(三)驾驶机动车不按规定使用灯光的独立成 chunk(40~60 字符),向量不再稀释,BM25 反而因短于 avgdl 获得归一化加成; - 前导句继承 :项文本本身不含记分值,脱离前导句就是语义残片。item 的 embedText 必须拼上"...有下列交通违法行为之一,一次记1分"------否则"灯光扣几分"召回了也答不出分值;
- 父条保留(双索引):"记1分的情形有哪些"这种聚合问法的正确答案单元是整条,纯 items-only 的 small-to-big 只会召回单项。父条同时入索引,各管一类问法。
切分安全阀:项序号必须从"一"开始严格连续,不连续即判定为行内括号注释、整条保留不切------防止误切非列举结构。
切分效果的量化:记分管理办法 37 条 → 100 个 chunk(7 条列举条切出 63 个 item),全库 453 chunk。
2.6 元数据与 embedText 组装
metadata schema 决定知识库的检索上限:
json
{
"id": "rtpoints2021-art-0011-it-07",
"kb": "traffic-law",
"law": "道路交通安全违法行为记分管理办法",
"lawCode": "rtpoints2021", "lawAbbr": "记分管理办法",
"version": "公安部令第163号 2021-12-27发布 2022-04-01施行",
"chapter": "记分分值", "section": null,
"article": "第十一条", "articleNo": 11,
"chunkType": "item", "subItemNo": 7, "subItem": "(七)",
"parentArticleId": "rtpoints2021-art-0011",
"points": 3
}
设计要点:
- id 用稳定业务 id (
lawCode-art-XXXX[-it-YY]),修法重刷时按 id 幂等 upsert; - embedText 带上下文前缀(法名+版本+章节+条号+前导句):跨法规检索时 embedding 模型能感知"这是哪部法的哪一条";
- 条号跨法规不唯一(道交法 82 条 ≠ 实施条例 82 条)------精确查询必须带 lawCode;
- points(记分值)入 metadata:从前导句抽取,"扣1分有哪些"变成零向量开销的精确过滤;
- 隐蔽关联坑 :item 与父条共享
articleNo,按条号过滤时必须限定chunkType == 'article',否则 top-1 随机取到 item,返回半句残片。
2.7 校验:贯穿流水线,不是收尾一步
| 校验 | 抓什么 |
|---|---|
| 条号序列 == 1..N 连续 | 引用误判、解析漏条 |
| 章节归属抽查 | 章切换节残留 |
| 关键条目人工抽查 | 酒驾/逃逸/记分值等已知答案的条,元数据与原文比对 |
| id 全局唯一 | 跨法规 lawCode 冲突 |
| 长度分布 | min/max/avg 是否落在 embedding 舒适区 |
| 内容级断言 | 如"带分值的 item 其 embedText 必含'一次记X分'" |
原则:校验失败要当场爆炸(assert/异常)。静默容错等于把错误灌进向量库,事后无解。
2.8 合并与版本管理(法律场景特有)
- per-law 文件 + 全量合并 :
chunks_<lawcode>.jsonl各自独立,修法后单法重刷秒级完成; - 旧版本打 deprecated 标记、不物理删除------历史案件要查"当时有效的版本";
- 合并排序键
(lawCode, articleNo, subItemNo):父条在前、项紧随,人工审查时上下文连贯。
3. 向量库摄取
3.1 幂等设计
java
Document.builder()
.id(chunk.id) // ETL 稳定业务 id
.text(chunk.embedText) // 上下文前缀版文本,而非裸 content
.metadata(metadata) // 全量元数据,供过滤
.build();
- 幂等三层 :
rebuild=false(默认)启动时检测已有条数 >0 即跳过(避免每次重启全量重嵌入);rebuild=true按kb=='traffic-law'删旧重灌;批写失败重试一次,仍失败中止启动------宁可启动失败,不留半库静默上线; - 存储选型 :pgvector 为主库(元数据过滤强、可事务、与业务库同实例);法规与用户记忆分表 (
vector_store_law/vector_store)------HNSW 索引全表构建,混表时一类数据挤占另一类的召回名额,且 embedding 列维度建表即固定,共表会锁死两类数据的模型选型; - null 元数据不写入:pgvector 的 filter 表达式对 null 值行为不可预期。
4. 查询理解层:路由 + 多查询改写
4.1 一次调用三合一
用户查询的两大顽疾------问得烂 (口语化、关键词化)和多轮指代("那第二个方案呢")------在这一层统一解决。设计上一个 LLM 调用同时完成三件事:
css
输入:问题 + 最近 8 条历史(小模型,温度 0)
输出:{
intent: "chat" → 闲聊/寒暄:不进检索管线,顺带生成接待回复(0 次主模型调用)
intent: "search" → 1~3 个检索查询变体:
① 补全指代:"那酒驾呢" → "酒后驾驶机动车如何处罚"
② 术语归一:"直行道转弯" → "不按导向车道行驶"
③ 处罚聚焦:"...记分 罚款"
}
工程细节:
- 正则快速路径:纯问候("你好/谢谢/再见")连小模型调用都省掉,直接短路;
- 原问题始终兜底保留在变体列表首位,变体去重 + 上限 3 个(每变体 = 一次 embedding 调用);
- per-request 指定小模型 + 温度 0:路由是简单任务,用生成级大模型是浪费;温度 0 保证确定性。
4.2 核心 prompt(术语归一是召回质量的关键)
查询变体设计源于一个实测痛点:用户口语"在直行道转弯"与法条术语"不按导向车道行驶"之间存在词形鸿沟------直接拿口语检索,两路都打不中记分条款。术语归一变体让检索同时覆盖口语与法条两种表述空间。
4.3 踩坑实录:静默降级掩盖了配置错误
多查询方案上线后效果"毫无变化",翻日志才发现:
csharp
[路由] 路由失败(降级为 search+原问题): 400: rejected by an internal MaaS component
完整因果链:
sql
全局 default options 含 thinking: disabled(为降低生成延迟所设)
→ per-request options(model=flash 小模型)合并时未指定字段继承 default
→ 该 flash 模型不接受 thinking 参数 → 400
→ 路由降级为"原问题直检" → 多查询从未运行
修复双保险:换用兼容该参数的轻量模型 + 路由调用显式 .extraBody(Map.of()) 清空继承------参数集自包含,不再依赖全局配置有什么。
教训(本文最重要的一条) :降级设计保证可用性的同时会掩盖配置错误------这次 400 静默降级了三轮对话才被发现。生产要求:降级必须配告警(至少 ERROR 级日志 + 失败率指标),"静默可用"和"静默错误"只有一线之隔。
附带一坑:Spring AI 2.0 的 .options(...) 接收 Builder 本身 而非 build() 产物,与直觉相反,编译期才会暴露。
5. 检索层:混合检索 → RRF → LLM 精排 → 剪枝 → 父子折叠
5.1 为什么混合:两路各自的盲区
| 查询 | 向量路 | BM25 路 |
|---|---|---|
| "喝了杯啤酒开车怎么罚"(口语) | ✓ 语义泛化命中 | ✗ 字面打不中 |
| "不按导向车道行驶 记3分"(术语+数值) | ✗ 语义距离一般 | ✓ 精确命中 |
| "礼让行人" vs 原文"避让行人"(词形鸿沟) | ✓ 近义可容 | ✗ 完全打不中 |
法条类语料专有名词、条号、金额密集,纯向量区分度低;纯关键词又容不了口语。混合是标配,融合用 RRF。
5.2 BM25 的实现选型
PG 原生无 BM25,中文还需 zhparser 扩展。453 条规模直接应用内存索引 :bigram 分词("闯红灯"→[闯红,红灯],无词典中文检索的经典方案,查询与文档同分词器保证词表对齐),构建 <100ms、单查 <1ms,零环境依赖。
两个实战调参:
- b 参数 0.75 → 0.4:法条多为长列举段,强长度归一会系统性压低它们的排名------它们恰恰是高频查询的答案载体。子切分后此问题大幅缓解,但保留调低的 b 作为防御;
- 规模化路径:语料过万换 ES / ParadeDB,接口语义不变。
5.3 RRF 融合与多查询稀释
java
// 跨查询、跨路累计:score(d) = Σ 1/(60 + rank_i)
for (int i = 0; i < vectorHits.size(); i++)
rrf.merge(vectorHits.get(i).id(), 1.0 / (RRF_K + i + 1), Double::sum);
for (int i = 0; i < bmAll.size(); i++)
rrf.merge(bmAll.get(i).doc().id(), 1.0 / (RRF_K + i + 1), Double::sum);
向量路多变体并行调用(embedding HTTP 客户端线程安全),延迟 ≈ 单查询。
踩坑(多查询的副作用) :3 变体 × 2 路 = 6 个排名列表,RRF 名次被"多路稳定中上位"的噪声文档累积占据(如某个变体与道交法某条原文逐字相似 → 多路霸榜),真正相关的条款只被 1~2 个变体召回,排不进融合前 10,精排根本看不到它。
修复:精排候选 10 → 15,让精排 LLM 有机会纠正 RRF 的名次偏差。教训:多查询是"用检索成本换召回覆盖",融合层的名次稀释会抵消部分收益------扩候选 + 强精排是对冲手段。
5.4 LLM 精排
bi-encoder(向量召回)快但粗,精排逐对评估"该条文是否回答该问题"------这是 LLM 的强项,对"话题相关但答非所问"的片段(混合检索最典型的假阳性)区分度远高于字面/向量相似度。
text
你是法规检索结果重排器。根据【查询】评估每条【候选法条】的相关度(0-10 整数):
10 = 条文直接回答该问题 7-9 = 高度相关 4-6 = 部分相关 0-3 = 无关
(话题相近但答非所问也算无关)
三个关键设计:
- 以用户原始问题为基准(不是变体)------评估"是否回答用户的真实意图";
- 打分覆盖校验:LLM 漏给任何候选打分 → 整体判为不可信 → 降级 RRF 序,不部分信任;
- 失败降级:精排是增强不是依赖,降级时 RRF 序照常返回。
规模化路径:换专用 cross-encoder(bge-reranker / gte-rerank),延迟从秒级降到百毫秒级------精排器是独立组件,换实现不动管线。
5.5 精排后剪枝
topK 固定取满的隐患:精排 0% 的条文也被硬凑进上下文------纯费 token 还稀释模型注意力。
java
result = sorted.stream()
.filter(h -> h.score() >= RERANK_MIN_SCORE) // 0.4:精排 4 分以下视为无关
.limit(topK)
.toList();
实测某查询 6 条 → 3 条进生成,上下文 token 省 40%。全部候选低于阈值时返回空 → 上层拒答------这是防幻觉期望的行为:检索管线诚实地说"没有可靠依据",而不是拿低质条文硬凑。
5.6 父子折叠
ETL 侧按项切分(2.5 节)的检索侧收尾:
java
// 同条 ≥2 个 item 命中 → 整条语境问法 → 替换为父条(文本超集、语境完整)
// 单 item 命中 → 保留 item(引用粒度细到"第十一条第(七)项")
Map<String, Long> itemCountByParent = hits.stream()
.filter(h -> "item".equals(h.chunkType()) && h.parentArticleId() != null)
.collect(groupingBy(Hit::parentArticleId, counting()));
已知小瑕疵:折叠插入的父条与独立召回的父条可能并存(citations 同条两次),不影响回答,去重边界待微调。
5.7 聚合问法直达
"记3分的有哪些情形"的正确答案单元是父条整条,走混合检索既慢又可能召回零散 item。正则识别问法 → points 元数据内存过滤 → 直达父条,零向量开销:
java
private static final Pattern AGGREGATE_POINTS =
Pattern.compile("(?:一次)?记\s*(\d{1,2})\s*分[^。??]{0,12}?(?:情形|行为|违法|有哪些|哪几)");
分值无命中时(如"记5分"不存在)回落混合检索由条文支撑拒答,不硬编"没有记5分"这类答案。
6. 生成层:法律场景的防幻觉铁律
text
你是道路交通安全领域的法规问答助手。知识库包含四部法规(效力从高到低):
1.《中华人民共和国道路交通安全法》(2021修正)
2.《中华人民共和国道路交通安全法实施条例》(2017修订)
3.《道路交通事故处理程序规定》(公安部令第146号)
4.《道路交通安全违法行为记分管理办法》(公安部令第163号)
回答规则:
1. 只依据提供的条文原文回答,禁止编造或推测法条内容
2. 涉及处罚/义务的表述必须逐字引用条文,并在句末标注【法名简称 第X条】
3. 多部法规均有规定时,优先引用效力层级高的;程序性/细节性问题
(如事故处理流程、记分值)引用对应专门规定,并说明各法分工
4. 若提供的条文中没有对应依据,明确回答"知识库内无对应条文",不猜测、不外推
四个关键设计:
- 拒答话术与指令一字不差("知识库内无对应条文")------两处一致,模型行为才不漂移;
- 引用格式与 citations 数据对应:answer 内【法名简称 第X条】+ n 编号,前端渲染溯源卡片;
- 思考模式全局关闭:生成模型为思考型,实测关思考后中等复杂问题 19.9s → 4.7s(思考 token 877 → 0)。对话场景不需要深度推理;
- 生成超时 + 降级:90s 显式超时防线程占满(曾实测上游故障时请求挂 243 秒),超时返回友好提示而非裸 500。
零命中拒答发生在检索侧(源头防线):检索为空直接返回"知识库内无对应条文",没有依据就不生成。
7. 多轮会话记忆
- 复用主对话链路的整套热层设施(Redis、ChatMemory、20 条滑动窗口)------零新增中间件;
- 会话命名空间域隔离 :convId 派生加
law:前缀,法规会话与闲聊会话即使用户传相同标识也互不污染; - 手动管理读写时机而非挂官方 Advisor:检索必须发生在生成之前,而查询改写需要先读历史------Advisor 的固定时序满足不了"读历史→改写→检索→生成"的编排需求;
- 闲聊/拒答/缓存命中也写记忆:上下文连续性对后续追问的改写质量有价值。
8. 效果验证与分诊方法论
8.1 正向用例(全链路生效)
makefile
Q: 在高速上占用应急车道扣几分
A: 一次记6分。【记分管理办法 第十条】
(item 级命中,精排 1.0 满分,前导句继承使回答自带记分值,
并自动补充实施条例第八十二条的上位法禁止性规定)
8.2 知识边界用例(正确的拒答)
makefile
Q: 在直行道转弯扣几分
A: 该行为违反实施条例第五十一条(未按所需行进方向驶入导向车道);
记分办法中无对应记分条款,无法回答扣几分。
这不是检索失败------全部 453 个 chunk 中"导向车道"仅出现于实施条例(义务性规定),记分办法确实没有 普通道路不按导向车道的记分条款(2022 新规已取消该记分,仅保留高速公路/快速路场景)。系统定位了违法行为、诚实说明记分无据------比编一个"扣3分"正确得多。
8.3 分诊方法论
判断"效果差"之前先分三类,对策完全不同:
| 诊断 | 看什么 | 对策 |
|---|---|---|
| 检索没召回 | citations 为空或不含目标条文 | 查分块/embedding/查询变体 |
| 召回了但排序差 | 目标条文在候选里但被剪枝/排后 | 调精排候选数、剪枝阈值 |
| 知识边界外的正确拒答 | 库里确实没有对应条文 | ETL 补数据,不是检索问题 |
深层认知:否定性知识("某行为不记分")没有条款载体,RAG 永远检索不到"不存在的东西"。此类知识只能在 ETL 侧显式补充通则性说明 chunk。
8.4 评测集(所有决策的裁判)
手写 30~50 条 golden QA(问题 → 应命中的 lawCode+条号/项号),每次改分块策略、换 embedding 模型、调检索参数,跑一遍 recall@5。改动要可度量,不能凭感觉全局铺开------这也是本项目当前最大的待补短板。
9. 踩坑总表
ETL 侧(13 坑)
| # | 坑 | 症状 | 修复 |
|---|---|---|---|
| 1 | 条号与正文同行 | 条样式行只匹配到 1 个 | 正则捕获行内条号前缀 |
| 2 | 章标题词中劈开("第六章调"+"查") | 章名残缺 | 短行聚合 + 停止条件 |
| 3 | 章切换节残留 | 条带错误节信息 | 状态机切章时重置节 |
| 4 | "依照本法第X条"误判为新条 | 条数暴增 | 条号严格连续校验 |
| 5 | 状态机基准变量用错 | 124 条全吞进第一条 | 期望值基于当前暂存对象 |
| 6 | 目录未剥离 | 检索先召回目录页 | 首个行首条号定位正文起点 |
| 7 | PDF 空格("记12 分") | 数值正则全失配 | 记\s*(\d+)\s*分 |
| 8 | 长列举条向量稀释 | 具体行为查不到 | 按项切分 |
| 9 | 长列举条 BM25 归一化重罚 | 同上 | 同上 + b 参数调低 |
| 10 | item 脱离前导句成语义残片 | 召回了答不出分值 | embedText 继承前导句 |
| 11 | item 与父条共享 articleNo | 按条号过滤取到残片 | 过滤加 chunkType=article |
| 12 | 词形鸿沟(礼让/避让) | 关键词通道打不中 | 查询侧术语归一改写 |
| 13 | 切完不验证直接灌库 | 错误静默入向量库 | 全部校验 assert 化 |
应用侧(11 坑)
| # | 坑 | 症状 | 修复 |
|---|---|---|---|
| 14 | Boot 4 无 Jackson2 ObjectMapper bean | 启动即失败 | 本地实例化或迁 Jackson 3 |
| 15 | PgVectorStore 默认 idType=UUID | Invalid UUID string |
.idType(TEXT) + DROP 表重建 |
| 16 | embedding 批量上限 10 | batch size is invalid |
BATCH_SIZE=8 |
| 17 | flash 模型不接受 thinking 参数 | 路由 400 → 静默降级多轮未察觉 | 换兼容模型 + extraBody 显式清空 |
| 18 | options API 接收 Builder 而非产物 | 编译失败 | 直接传 Builder |
| 19 | 改代码/配置后未重启(IDE 未同步) | "改了没生效",反复排查 | 先确认 class 时间戳 > 源码时间 |
| 20 | 多查询 RRF 名次稀释 | 相关条款进不了精排候选 | 精排候选 10 → 15 |
| 21 | 精排 0% 条文硬凑进上下文 | token 浪费 + 注意力干扰 | 阈值剪枝,全剪空走拒答 |
| 22 | 上游网关故障请求挂 243s | 线程被无效等待占满 | 90s 显式超时 + 降级 |
| 23 | collapse 父条与独立召回并存 | citations 同条两次 | 待微调 |
10. 方法论总结
全文压缩成可迁移的十条:
- 按结构切,不按 token 切:法条/合同/规章类文档的章节条款项是天然语义边界;
- 列举条三件套:按项切分 + 前导句继承 + 父子双索引------缺一不可;
- metadata 是检索上限:稳定业务 id、上下文前缀 embedText、结构化字段(如记分值)入元数据;
- 校验 assert 化:静默容错等于把错误灌进向量库,事后无解;
- 混合检索是标配:语义路与关键词路各有盲区,RRF 融合;
- 查询理解决定召回:意图路由 + 多查询变体(补全指代/术语归一/处罚聚焦)一次调用完成;
- 精排 + 剪枝守住上下文质量:无关条文不进生成,宁少勿滥;
- 降级必须配告警:静默可用与静默错误只有一线之隔;
- 拒答是能力不是缺陷:法律场景宁可"知识库内无对应条文",不可编造条号分值;
- 效果分诊三分法:没召回 / 排序差 / 知识边界------先归类再开方。
11. 已知局限与演进方向
| 项 | 现状 | 方向 |
|---|---|---|
| 评测集 | 手工用例验证 | 30~50 条 golden QA + recall@5 回归(最高优先级) |
| 精排延迟 | LLM 精排 ~4s/次 | cross-encoder(bge-reranker / gte-rerank,百 ms 级) |
| 路由失败告警 | WARN 日志 | 失败率指标 + 告警 |
| FAQ 缓存 | 进程内存,法规重灌不失效 | 缓存键拼知识库版本号;多副本外置 Redis |
| 流式输出 | 无(首字 6~12s) | SSE 流式生成 |
| 引用后校验 | 无 | answer 引用标注与 citations 比对,错标告警 |
| 列举切分推广 | 仅记分办法(7 条) | 道交法 8 条、事故处理规定 20 条候选,由评测集裁决后再推广 |
| collapse 去重 | 同条可能重复出现 | 折叠时跳过已存在的父条 |
12. 附录:文件清单与技术栈
12.1 文件清单
| 文件 | 角色 |
|---|---|
etl_law_chunks.py |
ETL:多排版自适应 + 章/节/条状态机 + 列举条按项切分 |
rag_out/chunks.jsonl |
全量知识库(453 chunk,4 法) |
rag_out/chunks_<lawcode>.jsonl |
per-law 数据(单法重刷用) |
rag/LawRagIngest.java |
摄取:幂等三层 + 父子元数据透传 + BM25 索引刷新 |
rag/LawBm25Index.java |
内存 BM25(bigram)+ 按条/按 id/按分值精确查询 |
rag/LawQueryRouter.java |
查询理解:意图路由 + 多查询变体(小模型低温) |
rag/LawReranker.java |
LLM 精排(降级协议:Optional.empty → RRF 序) |
rag/LawRetrievalService.java |
检索编排:多查询混合 → RRF → 精排 → 剪枝 → 折叠 |
controller/LawChatController.java |
问答入口:短路链(闲聊/FAQ/聚合)+ 生成 + 记忆 |
config/VectorStoreConfig.java |
双库分表:vector_store(记忆)/ vector_store_law(法规) |
webapp/law.jsp |
前端:聊天 UI + 引用溯源卡片 |
12.2 技术栈
| 层 | 选型 | 说明 |
|---|---|---|
| 语言/框架 | Java 21 / Spring Boot 4.0.8 / Spring AI 2.0.1 | Boot 4 的 Jackson 3 迁移、starter 更名等断点见踩坑表 |
| 向量库 | PostgreSQL + pgvector(HNSW / COSINE / 1536 维) | 元数据过滤强、可事务;法规与记忆分表 |
| 关键词索引 | 自研内存 BM25(bigram 中文分词,k1=1.2 / b=0.4) | 453 条规模零依赖;规模化换 ES / ParadeDB |
| 融合与精排 | RRF(k=60)+ LLM 精排(0~10 打分,阈值 0.4 剪枝) | 精排未来可换 bge-reranker / gte-rerank |
| 会话存储 | Redis Stack(RedisJSON + RediSearch) | 20 条滑动窗口,law: 前缀域隔离 |
| 生成模型 | glm-5.2(思考模式关闭) | 受约束生成 + 引用标注 + 拒答纪律 |
| 路由/改写模型 | deepseek-v4.1-flash(温度 0) | 意图识别 + 多查询变体,一次调用三合一 |
| Embedding | DashScope text-embedding-v4(1536 维) | 与 pgvector 建表维度强绑定,换模型须全量重嵌 |
| ETL | Python + PyMuPDF | 多排版自适应状态机解析;扫描件需 OCR 路线 |
| 前端 | JSP + 原生 JS | 聊天式 UI + 引用溯源卡片(textContent 防 XSS) |
| 构建/IDE | Maven / IntelliJ IDEA | 注意 IDE 与 Maven 项目模型的同步(踩坑 #19) |