RAG 实战与踩坑:交通法规智能问答助手

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 溯源 + 会话记忆写回)

两条核心原则贯穿全程:

  1. 法条类文档"按结构切,不按 token 切"------章/节/条/项就是天然的语义边界,任何固定长度滑窗在这类语料上都是错的。
  2. 检索质量决定上限,生成只是守住下限------实践中的 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 章/节/条状态机解析

单遍扫描,维护 (当前章, 当前节, 当前条) 三个状态变量。四个关键防御,每个都对应一次真实事故:

  1. 章切换时必须重置节------否则第九十一条会带着第四章"高速公路的特别规定"的残留节信息跨到"法律责任"章。状态机最常见的 bug 就是"切换了父状态、忘了清子状态"。
  2. 条号严格连续性校验(1..N 不缺不重)------正文里有大量"依照本法第X条"式引用,行首出现时会被误判成新条目。序列校验(期望值 = 上一条 + 1)是唯一可靠的兜底。
  3. 章/节标题短行聚合------"第六章调"+"查"要拼回"调查",停止条件是遇到下一个章/节/条样式行或标题超长。
  4. 条号与正文同行时------条号取"第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 倍

解法三要素,缺一不可:

  1. 一"项"一 chunk :(三)驾驶机动车不按规定使用灯光的 独立成 chunk(40~60 字符),向量不再稀释,BM25 反而因短于 avgdl 获得归一化加成;
  2. 前导句继承 :项文本本身不含记分值,脱离前导句就是语义残片。item 的 embedText 必须拼上"...有下列交通违法行为之一,一次记1分"------否则"灯光扣几分"召回了也答不出分值;
  3. 父条保留(双索引):"记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. 方法论总结

全文压缩成可迁移的十条:

  1. 按结构切,不按 token 切:法条/合同/规章类文档的章节条款项是天然语义边界;
  2. 列举条三件套:按项切分 + 前导句继承 + 父子双索引------缺一不可;
  3. metadata 是检索上限:稳定业务 id、上下文前缀 embedText、结构化字段(如记分值)入元数据;
  4. 校验 assert 化:静默容错等于把错误灌进向量库,事后无解;
  5. 混合检索是标配:语义路与关键词路各有盲区,RRF 融合;
  6. 查询理解决定召回:意图路由 + 多查询变体(补全指代/术语归一/处罚聚焦)一次调用完成;
  7. 精排 + 剪枝守住上下文质量:无关条文不进生成,宁少勿滥;
  8. 降级必须配告警:静默可用与静默错误只有一线之隔;
  9. 拒答是能力不是缺陷:法律场景宁可"知识库内无对应条文",不可编造条号分值;
  10. 效果分诊三分法:没召回 / 排序差 / 知识边界------先归类再开方。

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)
复制代码
相关推荐
柠檬味拥抱1 小时前
基于 YOLO11 的六类生活垃圾视觉分类系统 | 用于社区投放点智能督导、分拣线品类分流、投错率统计分析 | 附完整源码与效果演示
后端
xsd202411181 小时前
VoxCPM:砍掉语音分词器的开源TTS,连续潜空间实现3秒克隆与48kHz多语种
人工智能
2601_960356381 小时前
2027金融科技专业求职贷后管理岗:Excel、SQL如何对应业务任务?
人工智能
AI排障笔记1 小时前
TileLang 同步修复:一个 block 为什么上报了两次
人工智能
久曲健的测试窝1 小时前
Testin云测探索脚本自愈落地路径
运维·人工智能·科技·aigc
CHEEVEN_QY1 小时前
热仿真在液冷板设计中的应用价值与局限性客观评估
人工智能·算法
通信大模型1 小时前
IEEE JSTSP| FlashSAM:低空无线网络中多无人机Token通信的轻量级视觉模型
人工智能·深度学习·无人机
今天不在线1 小时前
旧 Java 项目接入 AI 实战系列(三):把企业文档装进知识库,多格式解析 + 向量化入库
人工智能
熊猫钓鱼>_>1 小时前
告别手工贴发票:增值税发票识别+核验,搭一条报销自动化流水线
人工智能·ai·职场和发展·自动化·大模型·llm·agent