RAG 实战与踩坑:交通法规智能问答助手
从四份官方 PDF 到一个带引用溯源、敢拒答、支持多轮追问的智能问答系统——本文记录这条 RAG 链路的完整实现、全部踩坑与对应优化。语料为四部交通法规(《道路交通安全法》124 条、《实施条例》115 条、《事故处理程序规定》114 条、《记分管理办法》37 条),最终知识库 453 个结构化 chunk,基于 Spring AI 2.0.1 + pgvector + 自研混合检索管线构建。
文中所有坑均为实际踩中并修复的问题,标注了完整因果链与排查方法,可直接作为同类项目的避坑清单。
目录
- 1. 全流程总览
- 2. 知识库构建:ETL 流水线
- 3. 向量库摄取
- 4. 查询理解层:路由 + 多查询改写
- 5. 检索层:混合检索 → RRF → LLM 精排 → 剪枝 → 父子折叠
- 6. 生成层:法律场景的防幻觉铁律
- 7. 多轮会话记忆
- 8. 效果验证与分诊方法论
- 9. 踩坑总表
- 10. 方法论总结
- 11. 已知局限与演进方向
- 12. 附录:文件清单与技术栈
效果演示:
1. 全流程总览
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 决定知识库的检索上限:
{
"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 幂等设计
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 调用同时完成三件事:
输入:问题 + 最近 8 条历史(小模型,温度 0)
输出:{
intent: "chat" → 闲聊/寒暄:不进检索管线,顺带生成接待回复(0 次主模型调用)
intent: "search" → 1~3 个检索查询变体:
① 补全指代:"那酒驾呢" → "酒后驾驶机动车如何处罚"
② 术语归一:"直行道转弯" → "不按导向车道行驶"
③ 处罚聚焦:"…记分 罚款"
}
工程细节:
- 正则快速路径:纯问候("你好/谢谢/再见")连小模型调用都省掉,直接短路;
- 原问题始终兜底保留在变体列表首位,变体去重 + 上限 3 个(每变体 = 一次 embedding 调用);
- per-request 指定小模型 + 温度 0:路由是简单任务,用生成级大模型是浪费;温度 0 保证确定性。
4.2 核心 prompt(术语归一是召回质量的关键)
查询变体设计源于一个实测痛点:用户口语"在直行道转弯"与法条术语"不按导向车道行驶"之间存在词形鸿沟——直接拿口语检索,两路都打不中记分条款。术语归一变体让检索同时覆盖口语与法条两种表述空间。
4.3 踩坑实录:静默降级掩盖了配置错误
多查询方案上线后效果"毫无变化",翻日志才发现:
[路由] 路由失败(降级为 search+原问题): 400: rejected by an internal MaaS component
完整因果链:
全局 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 融合与多查询稀释
// 跨查询、跨路累计: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 的强项,对"话题相关但答非所问"的片段(混合检索最典型的假阳性)区分度远高于字面/向量相似度。
你是法规检索结果重排器。根据【查询】评估每条【候选法条】的相关度(0-10 整数):
10 = 条文直接回答该问题 7-9 = 高度相关 4-6 = 部分相关 0-3 = 无关
(话题相近但答非所问也算无关)
三个关键设计:
- 以用户原始问题为基准(不是变体)——评估"是否回答用户的真实意图";
- 打分覆盖校验:LLM 漏给任何候选打分 → 整体判为不可信 → 降级 RRF 序,不部分信任;
- 失败降级:精排是增强不是依赖,降级时 RRF 序照常返回。
规模化路径:换专用 cross-encoder(bge-reranker / gte-rerank),延迟从秒级降到百毫秒级——精排器是独立组件,换实现不动管线。
5.5 精排后剪枝
topK 固定取满的隐患:精排 0% 的条文也被硬凑进上下文——纯费 token 还稀释模型注意力。
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 节)的检索侧收尾:
// 同条 ≥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 元数据内存过滤 → 直达父条,零向量开销:
private static final Pattern AGGREGATE_POINTS =
Pattern.compile("(?:一次)?记\s*(\d{1,2})\s*分[^。??]{0,12}?(?:情形|行为|违法|有哪些|哪几)");
分值无命中时(如"记5分"不存在)回落混合检索由条文支撑拒答,不硬编"没有记5分"这类答案。
6. 生成层:法律场景的防幻觉铁律
你是道路交通安全领域的法规问答助手。知识库包含四部法规(效力从高到低):
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 正向用例(全链路生效)
Q: 在高速上占用应急车道扣几分
A: 一次记6分。【记分管理办法 第十条】
(item 级命中,精排 1.0 满分,前导句继承使回答自带记分值,
并自动补充实施条例第八十二条的上位法禁止性规定)
8.2 知识边界用例(正确的拒答)
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) |