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

98 阅读26分钟

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

从四份官方 PDF 到一个带引用溯源、敢拒答、支持多轮追问的智能问答系统——本文记录这条 RAG 链路的完整实现、全部踩坑与对应优化。语料为四部交通法规(《道路交通安全法》124 条、《实施条例》115 条、《事故处理程序规定》114 条、《记分管理办法》37 条),最终知识库 453 个结构化 chunk,基于 Spring AI 2.0.1 + pgvector + 自研混合检索管线构建。

文中所有坑均为实际踩中并修复的问题,标注了完整因果链与排查方法,可直接作为同类项目的避坑清单。


目录


效果演示:

微信图片_20261014113854_84_2.png

微信图片_20261014113914_85_2.png

微信图片_20261014113926_86_2.png

1. 全流程总览

PDF ──► 结构探测 ──► 文本抽取与清洗 ──► 目录剥离 ──► 章/节/条状态机解析
     ──► 分块(普通条 + 列举条按项切分)──► 元数据与 embedText 组装
     ──► 校验 ──► 版本管理 ──► 向量库摄取(幂等)
                                    │
查询管线 ──► 会话历史 ──► 查询理解(意图路由 + 多查询变体)
         ──► 短路链(闲聊 / FAQ 缓存 / 聚合问法直达)
         ──► 混合检索(向量 + BM25 → RRF → LLM 精排 → 剪枝 → 父子折叠)
         ──► 受约束生成(引用标注 + 拒答纪律)
         ──► 响应(answer + citations 溯源 + 会话记忆写回)

两条核心原则贯穿全程:

  1. 法条类文档"按结构切,不按 token 切"——章/节/条/项就是天然的语义边界,任何固定长度滑窗在这类语料上都是错的。
  2. 检索质量决定上限,生成只是守住下限——实践中的 RAG 效果问题,八成出在检索侧而非模型侧。

各场景的端到端成本(实测):

场景LLM 调用检索耗时
纯问候00<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%,与查询的相似度被均值拉平
BM25648 ≈ 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 决定知识库的检索上限:

{
  "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目录未剥离检索先召回目录页首个行首条号定位正文起点
7PDF 空格("记12 分")数值正则全失配记\s*(\d+)\s*分
8长列举条向量稀释具体行为查不到按项切分
9长列举条 BM25 归一化重罚同上同上 + b 参数调低
10item 脱离前导句成语义残片召回了答不出分值embedText 继承前导句
11item 与父条共享 articleNo按条号过滤取到残片过滤加 chunkType=article
12词形鸿沟(礼让/避让)关键词通道打不中查询侧术语归一改写
13切完不验证直接灌库错误静默入向量库全部校验 assert 化

应用侧(11 坑)

#坑症状修复
14Boot 4 无 Jackson2 ObjectMapper bean启动即失败本地实例化或迁 Jackson 3
15PgVectorStore 默认 idType=UUIDInvalid UUID string.idType(TEXT) + DROP 表重建
16embedding 批量上限 10batch size is invalidBATCH_SIZE=8
17flash 模型不接受 thinking 参数路由 400 → 静默降级多轮未察觉换兼容模型 + extraBody 显式清空
18options API 接收 Builder 而非产物编译失败直接传 Builder
19改代码/配置后未重启(IDE 未同步)"改了没生效",反复排查先确认 class 时间戳 > 源码时间
20多查询 RRF 名次稀释相关条款进不了精排候选精排候选 10 → 15
21精排 0% 条文硬凑进上下文token 浪费 + 注意力干扰阈值剪枝,全剪空走拒答
22上游网关故障请求挂 243s线程被无效等待占满90s 显式超时 + 降级
23collapse 父条与独立召回并存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.pyETL:多排版自适应 + 章/节/条状态机 + 列举条按项切分
rag_out/chunks.jsonl全量知识库(453 chunk,4 法)
rag_out/chunks_<lawcode>.jsonlper-law 数据(单法重刷用)
rag/LawRagIngest.java摄取:幂等三层 + 父子元数据透传 + BM25 索引刷新
rag/LawBm25Index.java内存 BM25(bigram)+ 按条/按 id/按分值精确查询
rag/LawQueryRouter.java查询理解:意图路由 + 多查询变体(小模型低温)
rag/LawReranker.javaLLM 精排(降级协议: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.1Boot 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)意图识别 + 多查询变体,一次调用三合一
EmbeddingDashScope text-embedding-v4(1536 维)与 pgvector 建表维度强绑定,换模型须全量重嵌
ETLPython + PyMuPDF多排版自适应状态机解析;扫描件需 OCR 路线
前端JSP + 原生 JS聊天式 UI + 引用溯源卡片(textContent 防 XSS)
构建/IDEMaven / IntelliJ IDEA注意 IDE 与 Maven 项目模型的同步(踩坑 #19)