从 0 到 1 搭建政务政策 RAG:混合检索、重排与引用溯源的工程实践
0. 先说一个具体的失败
系统刚上线时,用户问:
"专精特新'小巨人'企业能拿多少奖励?"
RAG 给出的答案是:
"根据《东江市高层次人才安家补贴实施办法》,A 类人才可获得安家补贴 200 万元……"
这个回答在语义上"很像"——都是在说"给钱、给企业、给人才"。但它把一个企业资质奖励答成了个人安家补贴,金额差了 20 倍。
更麻烦的是,这个错误很难被用户发现:政策名、文号、金额、条款号一应俱全,格式完美,看起来极其可信。
这就是政务 RAG 的真正难点:它不是"答不出来"的问题,而是"答得很像但答错了"的问题。
而"很像但错了",恰恰是纯向量检索最擅长制造的错误。
需要说明的是:这个错误发生在千级文档、跨区同名政策的真实知识库里。本文第 5 节用的是一个 24 份政策的小语料,在那个规模下稠密检索的政策级命中是 100%——规模越大、同构政策越多,"像但错"的概率越高。这个差异本身也值得记一笔(见 1.2 节)。
1. 为什么普通的向量检索不够用
1.1 政策文档里有四类"硬信息"
一份政策文件,真正被用户需要的往往是这四类信息,它们有个共同特点:必须精确,不能近似。
| 信息类型 | 例子 | 容错性 |
|---|---|---|
| 政策名称 / 文号 | 《东江市专精特新中小企业奖励办法》,东工信发〔2024〕11号 | 零容错,名称错一个词就是另一份文件 |
| 金额 / 比例 | 一次性奖励 100 万元;贴息比例 50% | 零容错,差一位数就是另一个政策 |
| 条件门槛 | 营业收入不低于 2000 万元;职工总数 30 人以下 | 零容错,理解反了就是"不符合条件却去申报" |
| 有效期 | 2024-04-01 至 2027-03-31 | 零容错,过期政策等于错误答案 |
向量检索的设计目标是"语义相似",它对这四类信息的处理方式,本质上都是模糊化。
1.2 向量检索的三类失效(其中一条被我们自己的数据修正了)
① 真正的问题不是"召回不到",而是"排不对"
写这篇文章时我最初的假设是:向量检索会在政策名称上翻车,把 A 政策答成 B 政策。后来在自己造的 24 份政策、107 条条款的测试集上跑完,结论被修正了:
稠密检索的政策级 Recall@1 是 100%——它总能把对的那份文件找出来。 但条款级 Recall@1 只有 71%——它经常在同一条政策的相邻条款里挑错。
这个区别决定了后面所有的方案取舍。它意味着:换更大的 embedding 模型、加更多召回条数,都对"条款级排第一"帮助有限;真正有效的是切分方式、元数据,以及把"政策定位"和"条款定位"当成两件事分别处理。
② 数字漂移
"奖励 100 万元" 和 "补贴 200 万元" 在语义上是邻居,在业务上是两个世界。Embedding 模型把数字当成"量级相近的 token",不保证区分。
更隐蔽的是:同一份知识库里,"30 万元"、"100 万元"、"200 万元"可能分别出现在 5 份政策里。向量检索返回的 top-3 里全都有"奖励额度"的语义,但只有一个是对的。
实测数据也印证了这一点:在"数字条件"这一类题上,BM25 的 MRR 是 1.000(10 道题全部排第一),稠密检索是 0.95——差距不大,但方向很明确:数字要靠字面匹配兜底。
③ 条件与否定
"在职职工 30 人以下的企业暂免征收" 和 "安排残疾人就业比例达到 1.5% 以上的减半征收"——这两条讲的都是"减免",语义高度相似,但适用条件完全不同。
实测里有个更细的观察:这类差异如果发生在不同政策之间,稠密检索基本不会错;但发生在同一政策的相邻条款之间时(比如"见习补贴标准"和"留用奖励可上浮 20%"),它和人一样容易看串。
1.3 结论:两路召回必须并存
把失效模式摊开看,正好是两类算法的能力边界:
| 能力 | 向量检索 | BM25(关键词) |
|---|---|---|
| 口语化提问("政府给的安家费"→安家补贴) | 强 | 弱(字面不匹配就召回不到) |
| 政策级定位(是哪份文件) | 强(实测 R@1 100%) | 中(文号、专名命中时很强) |
| 条款级细粒度排序(是哪一条) | 弱(实测 R@1 71%) | 中(数字/门槛类题很强) |
| 金额、比例、人数门槛 | 中(MRR 0.95) | 强(MRR 1.00) |
| 同政策相邻条款的区分 | 弱 | 弱(两路都弱,见第 5 节) |
注意最后一行:有一类问题两路都解决不了。这类问题不靠"更强的召回"解决,而靠切分粒度和元数据(第 3 节),以及我们后来加上的"父子块回填"。
而前三行的互补是真实的。但互补的结论不是"两个都加上就自动更好"——融合本身会引入新的损失(第 5 节会看到 RRF 把某一类题的 Recall@5 从 100% 拖到 80%)。这一点很多"混合检索教程"不会告诉你。
所以架构必须是并行的混合检索,而不是"先向量、不行再关键词"的串行降级——串行降级的问题在于,你无法判断向量结果"行不行"。它返回的永远是最相似的 k 条,从不报错。
2. 如何设计混合检索架构
2.1 整体链路
对比常见的 Hybrid RAG 架构,这张图多了三个东西:Query 预处理(政务场景下的有效期判定非常关键)、父子块回填(检索粒度和生成粒度解耦)、引用校验(生成后的最后一道闸门)。这三个才是把 demo 变成可用系统的部分。
2.2 向量路:Embedding 与 Milvus
模型选择。 中文政策文本我们实测下来,BGE-M3 和 bge-large-zh-v1.5 都在可用区间。BGE-M3 的优势是原生支持长文本(8192)和多粒度,缺点是同维度下检索速度慢一些。如果政策条款切得比较细(200 字以内),bge-large-zh-v1.5 性价比更高。
一个容易忽略的细节:BGE 系列在 query 侧需要加指令前缀。
from langchain_huggingface import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
encode_kwargs={"normalize_embeddings": True}, # 归一化后内积 = 余弦
)
# 注意:query 侧加指令,document 侧不加
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
q_vec = embeddings.embed_query(QUERY_INSTRUCTION + user_question)
doc_vecs = embeddings.embed_documents([c.page_content for c in chunks])
不加前缀、或者两边都加,召回率会掉几个点。这类细节在 paper 里写了,在很多人抄的教程里没写。
Milvus 侧的配置。
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://milvus:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field("chunk_id", DataType.VARCHAR, is_primary=True, max_length=64)
schema.add_field("doc_id", DataType.VARCHAR, max_length=32)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=1024)
schema.add_field("text", DataType.VARCHAR, max_length=4096)
schema.add_field("title", DataType.VARCHAR, max_length=256)
schema.add_field("clause", DataType.VARCHAR, max_length=32)
schema.add_field("valid_to", DataType.VARCHAR, max_length=10) # "2027-03-31"
schema.add_field("is_expired", DataType.BOOL)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector", index_type="HNSW",
metric_type="IP", # 向量已归一化,内积等价余弦
params={"M": 16, "efConstruction": 200},
)
client.create_collection("gov_policy_v1", schema=schema, index_params=index_params)
关键点:把"有效期"做成标量字段而不是语义。 用户问"现在还能领新能源车补贴吗",正确答案需要的是"该政策 2022-12-31 已到期"这个元数据事实,而不是语义最相似的那段补贴标准。所以:
res = client.search(
collection_name="gov_policy_v1",
data=[q_vec],
anns_field="vector",
limit=50,
filter='is_expired == false', # 默认只检索现行有效政策
output_fields=["chunk_id", "text", "title", "clause", "valid_to"],
search_params={"metric_type": "IP", "params": {"ef": 64}},
)
过期政策不是删掉,而是 is_expired=true 单独保留——因为用户会问"去年那个补贴还有吗",这时候能回答"已于 X 年 X 月到期"比"我不知道"有价值得多。
2.3 BM25 路:中文分词才是主要工作量
BM25 的公式是标准的,坑全在分词上。
import jieba
from rank_bm25 import BM25Okapi
# 领域词典:政策名、机构名、专有名词
for w in ["专精特新", "小巨人", "专精特新小巨人", "揭榜挂帅", "免申即享",
"首台套", "留抵退税", "加计扣除", "稳岗返还", "灵活就业"]:
jieba.add_word(w)
STOP = set("的 了 在 是 和 与 及 对 为 按 由 该 本 我 你 吗 呢 有 没有 多少 怎么 可以".split())
def tokenize(text: str) -> list[str]:
return [w for w in jieba.lcut_for_search(text.lower())
if w.strip() and w not in STOP and not w.isdigit() or len(w) > 1]
几个实践要点:
-
用
lcut_for_search:它会把长词再切出子词("专精特新小巨人" → 也产出"专精特新"、"小巨人"),对短 query 的召回很有帮助。 -
领域词典必须建:不建词典时,"专精特新"会被切成"专精/特新","小巨人"被切成"小/巨人",BM25 直接失效。
-
不要把数字过滤掉:数字恰恰是政务场景最有区分度的 token。上面那行
not w.isdigit()的写法要小心优先级,我的建议是保留所有数字,只在停用词表里处理。
BM25 参数 k1=1.5, b=0.75 是通用默认值。政策条款长度差异不大(多为 50–200 字),b 可以略调低到 0.6 左右,弱化长度归一化。
2.4 RRF:为什么不能直接加权原始分数
这是混合检索里最容易做错的一步。
错误做法:
# ❌ 量纲完全不同的两个分数直接相加 final_score = 0.7 * cosine_sim + 0.3 * bm25_score
问题有三个:
-
量纲不同。余弦相似度在
[0,1](归一化后),BM25 分数无上界,实测在[0, 40+]区间浮动。0.3 的权重等于把 BM25 放大了一个数量级。 -
分布不同。余弦相似度集中在 0.6–0.95 的窄带,BM25 是长尾分布。
-
不可跨 query 比较。同一个 BM25 分数,在长 query 和短 query 下的意义完全不同。你没法用一个固定的归一化把两个分布对齐——因为归一化参数本身依赖于候选集。
正确做法:RRF(Reciprocal Rank Fusion)——只用排名,不用分数。
def rrf_fuse(rank_lists: list[list[str]], weights=None, k: int = 60) -> list[tuple[str, float]]:
"""rank_lists: 每一路检索的 chunk_id 有序列表(已按相关度降序)"""
weights = weights or [1.0] * len(rank_lists)
score: dict[str, float] = {}
for w, ranks in zip(weights, rank_lists):
for pos, cid in enumerate(ranks):
score[cid] = score.get(cid, 0.0) + w / (k + pos + 1)
return sorted(score.items(), key=lambda x: -x[1])
RRF 的三个好处,恰好对应上面三个问题:
-
只看排名,天然免疫量纲差异;
-
k=60让排名靠前的分数衰减平缓,避免"某一路排第一就必然胜出"; -
融合结果对单路的绝对分数变化不敏感,跨 query 稳定。
k 的作用值得单独理解:k 越大,头部和尾部的差距越小,融合越"民主";k 越小,越偏向单路的高排名结果。政策场景我们取 60(原论文默认),如果发现 BM25 经常"抢跑"(比如用户直接贴了政策名),可以把向量路权重降到 0.7。
2.5 Reranker:它到底解决什么,以及我们踩到的那个坑
RRF 解决的是"两路怎么合并",重排解决的是"合并完怎么精排"。
本质区别:
-
双塔(Embedding):query 和 doc 分别编码成向量,再算相似度。两者在编码时互不可见。速度快,但丢失了细粒度的交互信息。
-
Cross-Encoder:把
[query, doc]拼在一起送进模型,让每一层 attention 都能看到 query 和 doc 的对应关系。慢,但理论上精度更高。
所以工程上的标准做法是:双塔负责召回(50~100 条),Cross-Encoder 负责精排(20~50 → 5)。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)
def rerank(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]:
pairs = [(query, c["index_text"]) for c in candidates] # 注意用的是带上下文头的检索文本
scores = reranker.predict(pairs, batch_size=16)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
return sorted(candidates, key=lambda x: -x["rerank_score"])[:top_n]
这里有个反直觉的事实,是我跑完实验才看清的。
打开重排分数的分布看,它是极度两极的(均值 0.25、中位数 0.03):
| 场景 | 典型分数 | 说明 |
|---|---|---|
| 完全无关的政策 | 0.01 ~ 0.30 | 极易区分 |
| 同一政策的相邻条款 | 0.9985 vs 0.9987 | 几乎不可区分 |
举个真实例子。query 是"就业见习补贴每月发多少钱",正确答案在 P04 第三条,而 P04 第四条讲的是"留用见习人员比例达 50% 以上,补贴标准可上浮 20%"——重排给第三条 0.9985、给第四条 0.9987。它俩的差距是 0.0002,但排序结果就是错的。
这就解释了为什么重排在"选对政策"上无比强悍,在"选对条款"上却会帮倒忙:
-
如果你拿它做跨政策的候选消歧,它几乎不会失手(0.99 对 0.03,差了 30 倍);
-
如果你拿它做同政策内的条款排序,它的分辨力已经到极限了,微小的分数抖动就会把排名打乱。
理解这一点之后,我们对重排的使用方式就变了:它不再负责"最终顺序",而是负责"证据是否可信"(门控)和"跨政策层级的候选压缩"。 第 5 节有完整的数据支撑这个决定。
代价是延迟,而且比想象中贵。 我们实测(CPU,20 候选,XLM-R base 结构)单次重排约 1.7 秒,p95 2.1 秒——这个数字直接决定了它没法放在在线主链路上。GPU 上通常能快一个数量级,但如果你没有 GPU 预算,"50 候选重排"这个标配方案在政务问答这种并发场景里基本不可行。
2.6 一个容易漏掉的工程细节:去重
两路召回会有大量重叠(同一 chunk 被两路同时命中)。RRF 的加法天然处理了重叠(分数累加 = 双路都认可 = 排名更靠前),这其实是 RRF 的优点而不是需要去重的问题。
但父子块架构下有一个真正的重复问题:多个子块命中同一个父条款。这时候要按 parent_id 去重,保留分数最高的子块,否则 5 个证据位会被同一个条款占满,答案会变得重复而单薄。
3. 政策文档如何切分
3.1 固定长度切分的三个具体坏处
我见过最多的做法是 RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)。在政策文本上,它会稳定地制造三种错误:
① 条款腰斩
chunk_17: "...D级人才给予安家补贴10万元。补贴分5年等额发放,每年发放20%。第四条 申请人须与用人单位签订3年以上劳动" chunk_18: "(聘用)合同,且在东江市连续缴纳社会保险满6个月..."
"3年以上劳动(聘用)合同"被切成两半。用户问"签多久合同才能申请",chunk_17 里有"3年以上劳动",chunk_18 里有"合同"——两段单独看都不足以回答,一起看才能。而检索只会返回其中一段。
② 上下文丢失
切出来的 chunk 是"补贴分5年等额发放"。它属于哪份政策?哪一条?哪个机关发的?有效期到什么时候?全丢了。 用户拿到这个答案,无法核实,也无法判断是否过期。
③ 元数据无处附着
政务问答必须能溯源到"《XX办法》第X条"。固定长度切分产生的 chunk 边界和政策条款边界不对齐,你就没法给 chunk 打上准确的 clause 标签,引用溯源从根上就断了。
3.2 政策文本的真实结构
政策文件的层级是固定的:章 → 节 → 条 → 款 → 项。
《东江市高层次人才安家补贴实施办法》 ├─ 第一章 总则 │ ├─ 第一条 为吸引和留住高层次人才…… │ └─ 第二条 本办法所称高层次人才,分为 A、B、C、D 四类…… ├─ 第二章 补贴标准 │ ├─ 第三条 A类200万元,B类80万元,C类30万元,D类10万元…… │ └─ 第四条 申请人须签订3年以上合同…… └─ 第三章 申报与监督
"条"是天然的最小语义单元:它通常只讲一件事,长度在 50–300 字之间,正好是 embedding 和 BM25 都舒服的粒度。所以切分的核心原则是:按"条"切,不要按字数切。
3.3 父子块切分:让检索粒度和生成粒度解耦
但"条"也有问题:长条款(300 字以上,包含多个"款")会让向量表示被稀释——一条里讲了补贴标准、申请条件、发放方式三件事,embedding 变成一个"平均语义",对任何单一问题的匹配度都下降。
所以用父子块(Parent-Child):
-
检索用子块:粒度细,语义聚焦,向量和 BM25 的精度都更高;
-
生成用父块:命中任一子块,就把整个父条款(甚至父章节)回填给 LLM,保证上下文完整。
from langchain_text_splitters import RecursiveCharacterTextSplitter
def build_parent_child(doc: dict) -> list[dict]:
"""doc: 已解析出章节结构 + 页码的政策文档"""
out = []
for chapter in doc["chapters"]:
for clause in chapter["clauses"]:
parent_id = f'{doc["doc_id"]}-{clause["no"]}'
header = (
f'《{doc["title"]}》({doc["doc_no"]},{doc["issuer"]})'
f'{chapter["chapter"]} {clause["no"]}'
)
# 子块:按句号/分号切,控制 80-150 字
splitter = RecursiveCharacterTextSplitter(
separators=["。", ";", ",", "\n"],
chunk_size=150, chunk_overlap=0,
keep_separator="end",
)
children = splitter.split_text(clause["text"])
for i, child in enumerate(children):
out.append({
"chunk_id": f"{parent_id}-c{i}",
"parent_id": parent_id,
# 关键:子块前面拼上"上下文头",把政策名/文号/条款号注入到检索文本里
"index_text": f"{header} {child}",
"text": child,
"parent_text": clause["text"],
"meta": {
"doc_id": doc["doc_id"], "title": doc["title"],
"doc_no": doc["doc_no"], "issuer": doc["issuer"],
"chapter": chapter["chapter"], "clause": clause["no"],
"page": clause.get("page"), "publish_date": doc["publish_date"],
"valid_from": doc["valid_from"], "valid_to": doc["valid_to"],
},
})
return out
3.4 上下文头(Contextual Header):一个低成本高收益的技巧
注意上面 index_text 那一行:子块的检索文本前面拼了 《政策名》(文号,机关)章节 条款号。
这一步解决的是"政策名称类问题"的召回:
-
用户问:"专精特新奖励办法里对小巨人企业奖励多少"
-
如果不注入头,chunk 内容是"国家级专精特新'小巨人'企业一次性奖励100万元"——它本身不含"奖励办法"这四个字,BM25 命中不了。
-
注入头之后,
index_text变成《东江市专精特新中小企业奖励办法》(东工信发〔2024〕11号,东江市工业和信息化局)二、奖励标准 第三条 国家级专精特新"小巨人"企业一次性奖励100万元——BM25 立刻命中,向量也会被政策名"锚定"。
代价是 chunk 变长(多 40–60 字)。我们的做法是:index_text 用于检索,text 用于生成和展示,两个字段分开存。这样既不污染给 LLM 的证据文本,又能拿到上下文锚定的收益。
3.5 元数据表
这是我们的实际字段设计,每一项都有明确用途:
| 字段 | 类型 | 用途 |
|---|---|---|
chunk_id | string | 唯一标识,引用溯源 |
parent_id | string | 父子块回填 |
doc_id | string | 文档级去重、聚合 |
title / doc_no / issuer | string | 引用展示、答案溯源 |
chapter / clause | string | 精确定位到"第X条第X款" |
page | int | 原文 PDF 定位(用户点引用直接翻到那一页) |
publish_date / valid_from / valid_to | string | 时效性判断、默认过滤 |
is_expired | bool | 检索期标量过滤(比语义判断可靠得多) |
region / level | string | 多地区/多层级政策隔离 |
doc_type | string | 办法/细则/通知/解读,不同类型回答口径不同 |
页码这一项值得单独说。 用户点开引用,最想要的是"原文第 7 页第 3 条"。如果只能定位到文档级别,用户还得自己翻——这时候引用就退化成了"装饰"。我们在 PDF 解析阶段就把条款和页码绑定(通过正则匹配"第X条"在页面文本中的位置),成本不高,体验差异巨大。
3.6 不要切断条款,也不要把整章当一块
两个极端都要避免:
-
切太细(按句切到 50 字以下):"每年发放20%"这种子块,脱离父条款完全无法理解,向量表示也没有信息量。
-
切太粗(整章 2000 字):一条 chunk 里包含 8 个条款,embedding 被平均掉,召回精度下降;而且塞给 LLM 时 token 浪费严重。
父子块的本质是用"细粒度检索 + 粗粒度生成"同时拿到两边的收益。 这也是我后面实验里专门做了一次切分消融的原因——切分策略对最终效果的影响,比换 embedding 模型更大。
4. 如何降低大模型幻觉
RAG 的幻觉分两类:
-
检索侧幻觉:证据里根本没有答案,模型硬答("编")。
-
生成侧幻觉:证据里有答案,模型答错了(数字抄错、条款张冠李戴、引用编号编造)。
政务场景两类都必须治。我们设了四道闸门。
4.1 第一道:min_score 过滤
重排分数是最可靠的"证据可信度"信号。设一个阈值,低于就丢弃:
MIN_RERANK_SCORE = 0.3 # bge-reranker 的 sigmoid 输出;实测 0.2~0.4 是同一平台 def gate(candidates: list[dict]) -> list[dict]: return [c for c in candidates if c["rerank_score"] >= MIN_RERANK_SCORE]
阈值不能拍脑袋,要在带"无答案问题"的测试集上扫出来。而且它通常不是一个"最优点",是一个平台区——因为我们前面说过,重排分数是双峰分布(相关的 0.9+,不相关的 0.1-),中间那段几乎没有样本,所以阈值落在哪都能得到一样的效果。
我们实测的平台区是 0.2 ~ 0.4(无答案拦截 100%,有答案误拒 9.7%);一旦推到 0.5,误拒率跳到 19.4%。取平台区的中间值比取边界值安全——因为换模型、换语料之后,分布会整体平移,靠边界值容易一夜之间全部误拒。
第 5 节我给了阈值扫描曲线,可以直接看到"拦截率"和"误拒率"的权衡。
4.2 第二道:无证据就拒答
这是政务 RAG 最重要的一条产品原则:宁可说"没有政策依据",也不能给一个像模像样的错答案。
REFUSAL_TEMPLATE = (
"抱歉,我在本市现行有效的政策文件中没有找到与「{q}」直接相关的条款。\n"
"建议您:\n"
"1) 换一种说法描述(例如补充事项类型、企业规模、所属行业);\n"
"2) 拨打 12345 政务服务热线转对应主管部门咨询;\n"
"3) 如您认为应有相关政策,可提交「政策找上门」需求,由专员跟进。"
)
三个细节:
-
不要只说"我不知道",要给下一步动作(热线、换说法、转人工);
-
不要说"根据我的知识",政务场景里模型自身的知识是负资产(可能引用外省政策、过期政策);
-
拒答要可观测:把拒答率当作核心运营指标。拒答率突然升高,通常是知识库更新出了问题。
4.3 第三道:提示词层面的硬约束
提示词不是"求模型别乱说",而是把所有可验证的约束前置:
你是东江市政务服务政策助手。请严格基于【参考资料】回答,遵守以下规则: 1. 只能使用【参考资料】中的信息作答。资料中没有的内容,一律回答"资料中未提及",禁止基于常识、经验或其他地区政策补充。 2. 每一个结论句后面必须标注来源编号,格式为 [1]、[2],编号必须来自【参考资料】。 3. 涉及金额、比例、期限、人数门槛时,必须原文照抄数字,不得换算、不得四舍五入、不得省略单位。 4. 涉及"是否适用"时,必须同时给出适用条件和排除情形;条件不满足时明确说明"不适用"。 5. 引用政策时须写明《政策名称》+ 条款号;若资料显示政策已过期,必须首先提示"该政策已于 YYYY-MM-DD 到期"。 6. 回答结构固定为:结论 → 依据(含数字)→ 办理方式(如有)→ 来源编号。 【参考资料】 [1] 《东江市专精特新中小企业奖励办法》(东工信发〔2024〕11号)二、奖励标准 第三条 国家级专精特新"小巨人"企业一次性奖励100万元;省级专精特新中小企业一次性奖励30万元。 (有效期:2024-04-01 至 2027-03-31) [2] ...
四个设计要点:
-
规则 3 是政务场景的关键:"原文照抄数字"把最危险的一类幻觉(金额算错、单位漏掉)变成了可校验的约束。
-
规则 5 让有效期成为强制提示,而不是等用户问。
-
规则 6 固定输出结构,让后续的自动校验(引用覆盖率、数字一致性)变得可实现。
-
资料里带上有效期和文号,模型才有条件执行规则 5。
4.4 第四道:引用校验(生成后)
这是最容易被跳过、但收益最大的一步。分三个检查:
import re
def verify_citations(answer: str, evidences: list[dict]) -> dict:
n = len(evidences)
cited = [int(x) for x in re.findall(r"\[(\d+)\]", answer)]
# ① 编号存在性:不能引用不存在的编号
invalid = [c for c in cited if c < 1 or c > n]
# ② 引用覆盖率:结论句是否都有引用
sentences = [s for s in re.split(r"(?<=[。;\n])", answer)
if len(s.strip()) > 12 and not s.strip().startswith(("建议", "如您", "抱歉"))]
covered = [s for s in sentences if re.search(r"\[\d+\]", s)]
coverage = len(covered) / max(1, len(sentences))
# ③ 数字一致性:答案里的数字必须出现在被引用的证据里
ev_text = "".join(e["text"] for e in evidences)
ans_nums = set(re.findall(r"\d+(?:\.\d+)?", answer))
ev_nums = set(re.findall(r"\d+(?:\.\d+)?", ev_text))
# 过滤掉引用编号、年份等噪声
ans_nums = {x for x in ans_nums if x not in {str(i) for i in range(1, n + 1)}}
num_ok = ans_nums.issubset(ev_nums)
return {
"invalid_citation": invalid,
"coverage": round(coverage, 2),
"num_consistent": num_ok,
"missing_nums": sorted(ans_nums - ev_nums),
"pass": (not invalid) and coverage >= 0.8 and num_ok,
}
校验不通过怎么办?我们不用"重新生成"(成本高且可能再次失败),而是降级:
-
编号不存在 → 删掉该句(而不是删掉整个答案)
-
覆盖率不足 → 在答案后追加"以上内容部分为归纳总结,具体以原文为准",并展示完整证据列表
-
数字不一致 → 直接拦截,降级为"仅展示检索到的原文条款 + 提示人工确认"
数字不一致必须是硬拦截。 这是政务场景唯一的"零容忍"项。
4.5 评估指标怎么定义
光有闸门不够,得有度量。我们用的四个指标:
| 指标 | 定义 | 怎么算 |
|---|---|---|
| 引用覆盖率 | 有引用的结论句 / 全部结论句 | 正则统计,见 4.4 |
| 引用准确率 | 引用编号确实支撑该句的比例 / 总引用数 | 人工抽检 or LLM-as-judge |
| 忠实度(Faithfulness) | 答案中的每个 claim 都能在证据中找到依据 | RAGAS 风格:拆 claim → NLI 判定 |
| 幻觉率 | 出现"证据中不存在的事实/数字"的答案数 / 总答案数 | 人工标注 + 数字一致性自动检测 |
其中数字一致性可以做全自动,这在政务场景性价比极高:绝大部分严重幻觉都表现为"数字错了"。
5. 如何验证系统真的有效
前面讲的全是"设计意图"。设计意图不值钱,能跑出数据的才值钱。
5.1 测试集怎么造
我们造了一个 36 题的测试集,覆盖 5 类问题。关键不是题量,而是每类题都在针对一种特定的失效模式:
| 类型 | 题量 | 设计目的 | 示例 |
|---|---|---|---|
| 政策名称类 | 10 | 考政策名/术语定位,全称与简称口语各占一半 | "C 类人才能拿多少安家费?" / "会展业发展扶持资金管理办法里 UFI 认证的展会额外奖励多少?" |
| 数字条件类 | 10 | 考金额、比例、门槛的精确匹配 | "加装电梯需要多少比例的业主同意?" |
| 模糊口语类 | 10 | 考语义泛化(用户不会说政策术语) | "老房子没电梯,楼上老人爬不动,装电梯政府补多少钱?" |
| 时效陷阱 | 1 | 考过期政策的识别 | "现在买新能源车还能领一万块补贴吗?" |
| 无答案类 | 5 | 考拒答能力(知识库里确实没有) | "外地车牌在工作日早高峰可以进市区吗?" |
每条题都标注了 gold 答案所在的 (doc_id, 条款号),这样才能算 Recall@K 和 MRR。
语料是 24 份政策文件、共 107 个条款。规模不大,但足够做相对比较——本文所有结论都是"同条件下 A 比 B 好",不是"绝对效果达到 X%"。
说明:为了避免泄露真实项目数据,下面的实验在脱敏的小型语料上复现,使用了
BAAI/bge-small-zh-v1.5(向量)和BAAI/bge-reranker-base(重排),CPU 环境。绝对数值会随模型和语料变化,但相对趋势是可迁移的。完整脚本我放在文末。
5.2 指标定义
-
条款级 Recall@K:gold 条款(
doc_id+ 条款号)出现在前 K 条结果里的题目占比 -
条款级 MRR:gold 条款排名倒数的平均值(排第 1 记 1.0,排第 3 记 0.33)
-
政策级 Recall@1:结果里第一条所属的政策是不是正确的那份(忽略条款号)。这个指标单列,是因为"答错政策"和"答错条款"在产品上的严重程度完全不同,混在一起看会失真
-
拒答准确率:无答案问题被正确拒答的比例
-
误拒率:有答案问题被错误拒答的比例
-
P95 延迟:单次检索链路耗时(含 query 编码,不含 LLM 生成)
5.3 实验一:四条检索链路的对比
固定条款级切分,只换检索链路:
| 检索方案 | 条款 R@1 | 条款 R@3 | 条款 R@5 | 条款 MRR | 政策级 R@1 | P95 延迟 |
|---|---|---|---|---|---|---|
| 纯向量检索 | 71.0% | 93.5% | 100.0% | 0.817 | 100.0% | 23 ms |
| 纯 BM25 | 64.5% | 87.1% | 93.5% | 0.759 | 87.1% | 24 ms |
| 向量 + BM25(等权 RRF) | 77.4% | 93.5% | 93.5% | 0.837 | 96.8% | 24 ms |
| 加权 RRF(1 : 0.5) | 74.2% | 93.5% | 93.5% | 0.828 | 96.8% | 24 ms |
| 混合检索 + 重排 | 67.7% | 87.1% | 96.8% | 0.794 | 96.8% | 2096 ms |
这张图里有四个值得说的点,其中两个和我的预期相反。
① 稠密检索不是"召回不到",是"排不对"。
它的条款级 Recall@5 是 100%,政策级 Recall@1 也是 100%——31 道题,它全部把正确的政策排在了第一位,并且正确答案一定在前 5 条里。
但条款级 Recall@1 只有 71%。也就是说,近三成的问题,它把正确答案放在了第 2~5 位。
这个差距在 RAG 里是致命的,因为下游 LLM 只拿到 3~5 条证据,而且模型天然更相信排在前面的证据。"在前 5 条里"和"排第 1"对最终答案的影响,比 Recall@5 这个指标显示的大得多。
② BM25 整体更差,但在"数字条件"这一类题上是碾压的。
BM25 的 MRR 是 0.759,比稠密检索低 —— 但它在一类题上做到了 MRR = 1.000(10 道数字/门槛题全部排第一),稠密检索是 0.95。
这就是混合检索的立足点:不是"平均更好",而是"在某一类问题上不可替代"。
③ 融合不是免费的:RRF 把 Recall@5 从 100% 拖到了 93.5%。
这是我最想强调的一点。等权 RRF 的 R@1 提升了 6.4 个百分点(71.0% → 77.4%),但 R@5 掉了 6.5 个百分点。原因是它把 BM25 的漏召回也继承了下来。看这道题就明白了:
Q28:"外地来打工的年轻人租房太贵了,有没有便宜点的房子?"(正确答案:保障性租赁住房租金政策)
稠密检索:第 4 位找到 ✅ BM25:前 50 位都没有(因为"租房贵""便宜点的房子"和条款原文"保障性租赁住房租金按照同地段同品质市场租赁住房租金的 90% 确定"几乎没有词面重叠) RRF 融合后:掉到第 34 位 ❌
融合会把一路的短板变成两路的短板。 这就是为什么"混合检索一定比单路好"这句话是错的——它取决于你的语料里,两路的能力是不是真的互补。
④ 加权 RRF 反而更差,说明 BM25 不是噪音。
直觉上"稠密更强,那就给 BM25 降权"。我们试了 1 : 0.5,R@1 从 77.4% 掉到 74.2%。
原因很直接:被 BM25 救回来的那些题(Q11 数字门槛、Q23 口语+数字混排),恰恰是稠密检索排第 2 的题,降权之后它们又掉回去了。权重不是靠直觉调的,得靠分类型的数据调。(正确的做法见 5.6。)
5.4 实验二:切分策略是最大的那个杠杆
同一条 RRF 链路,只换切分方式:
| 切分方式 | Chunk 数 | 条款 R@1 | 条款 R@5 | 条款 MRR | 政策级 R@1 |
|---|---|---|---|---|---|
| 固定 200 字硬切 | 35 | 35.5% | 45.2% | 0.395 | 87.1% |
| 固定 100 字硬切 | 62 | 41.9% | 58.1% | 0.490 | 90.3% |
| 条款级切分(上下文头 + 元数据) | 107 | 77.4% | 93.5% | 0.837 | 96.8% |
从固定 200 字换到条款级,条款 R@1 提升了 41.9 个百分点,MRR 翻了一倍多。
这个提升幅度,比"换一个更强的 embedding 模型"或"把召回条数从 50 加到 200"大一个数量级。原因在 3.1 节讲过,这里用数据再确认一遍:
-
固定 200 字的块里平均塞了 2~3 个条款,一个块同时讲"补贴标准"和"申报条件",它的向量是一个平均语义,对任何单一问题的匹配度都不高;
-
政策名、文号、有效期这些"锚点信息"在固定切分里被随机分配到某个块,很多块里根本没有;
-
条款被切断后,gold 条款的文本跨越两个块,任一单块都无法算作"命中"。
而条款级切分加上"上下文头"(《政策名》(文号,机关)章节 条款号 + 正文),等于给每个块免费注入了 BM25 最需要的精确词、和 embedding 最需要的主题锚点。
这也是我建议所有政务/法务/医疗 RAG 项目优先做的事:先花两天把切分做对,比花两周调模型有用。
5.5 实验三:重排到底值不值?答案取决于候选有多脏
这是整篇文章里最反直觉的一组数据。同一个重排模型,放在三种不同质量的候选池上:
| 前序链路 | 条款 R@1(重排前 → 后) | 政策级 R@1(重排前 → 后) | 结论 |
|---|---|---|---|
| 固定 200 字切分 | 35.5% → 45.2%(+9.7pt) | 87.1% → 96.8%(+9.7pt) | 收益明显 |
| 固定 100 字切分 | 41.9% → 45.2%(+3.3pt) | 90.3% → 93.5%(+3.2pt) | 小幅正收益 |
| 条款级切分 + RRF | 77.4% → 67.7%(-9.7pt) | 96.8% → 96.8%(持平) | 负收益 |
规律非常清楚:
候选越脏,重排收益越大;候选越干净,重排越容易把好排序打乱。
为什么会这样?因为重排的判别力是有粒度的。前面 2.5 节说过,它区分"不同政策"的能力极强(0.99 对 0.03),区分"同政策相邻条款"的能力几乎为零(0.9985 对 0.9987)。
-
固定 200 字切分时,候选池里混着大量"看起来相关但根本不是同一份政策"的块,重排的强项正好用上,所以 R@1 涨 9.7 个点;
-
条款级切分 + RRF 之后,前 20 条候选里往往有多条来自同一份政策,剩下的差异全在"第几条",正好踩在重排的盲区上,于是它开始随机抖动。
两个具体案例(同一次实验里):
| 题号 | 问题 | RRF 排名 | 重排后 | 性质 |
|---|---|---|---|---|
| Q30 | "水费涨了没有?我家一年用两百多吨水要交多少钱一吨?" | 第 13 位 | 第 2 位 | 重排救回(BM25 把它排到了第 41 位,重排纠正了词面噪声) |
| Q24 | "我是自由职业者自己交社保,压力有点大,有补贴吗?" | 第 1 位 | 第 4 位 | 重排打乱(正确答案本来是第一) |
所以我们最后没有把重排放在"最终排序"的位置上。 它在系统里承担两件事:
-
证据门控:用它的最高分判断"这一批证据到底靠不靠谱"(5.7 节的数据证明这一项收益巨大);
-
脏候选压缩:在长文档、跨政策混合召回的场景下,用它的强项把"选错政策"的情况压掉。
而条款级的最终顺序,交回给 RRF + 元数据。这不是妥协,是把每个组件放在它真正擅长的那一层。
顺带说一句成本:这一列重排的 P95 延迟是 2096 ms(CPU,20 候选)。哪怕它效果更好,这个数字也决定了它上不了在线主链路。如果你在做一个没有 GPU 预算的政务问答,优先做切分和门控,别急着上重排。
5.6 实验四:分题型拆开看,才看得见融合的代价
平均值会掩盖问题。按题型拆开(R@5 / MRR):
| 检索方案 | 政策名称类 | 数字条件类 | 模糊口语类 | 时效陷阱 |
|---|---|---|---|---|
| 纯向量检索 | 100% / 0.88 | 100% / 0.95 | 100% / 0.67 | 100% / 0.33 |
| 纯 BM25 | 100% / 0.82 | 100% / 1.00 | 80% / 0.48 | 100% / 0.50 |
| 向量 + BM25(RRF) | 100% / 0.87 | 100% / 1.00 | 80% / 0.69 | 100% / 0.33 |
| 混合检索 + 重排 | 100% / 0.88 | 100% / 1.00 | 90% / 0.54 | 100% / 0.50 |
这张表把 5.3 节那句"融合会继承短板"量化了:
-
数字条件类:RRF 完整继承了 BM25 的 MRR = 1.00,这是融合最干净的收益——两路都强,融合后不丢分。
-
模糊口语类:稠密检索的 R@5 是 100%,RRF 之后变成 80%。融合在这里是净损失。 原因就是 Q28 那类题——BM25 一条都没召回,RRF 用它的一票否决权把稠密的好结果压了下去。
-
模糊口语类的 MRR:稠密 0.67 → RRF 0.69 → 重排 0.54。重排在这里也是负收益,因为口语题的正确条款往往"意思对但字面不像",cross-encoder 更偏好字面重合的段落。
-
时效陷阱("现在买新能源车还能领一万块补贴吗"):四种方案最好的也只是第 2 位。这道题的正确答案是"该政策已于 2022-12-31 到期",它需要的不是更好的排序,而是元数据过滤——这也是为什么我们在 3.5 节坚持把
is_expired做成标量字段。
结论:不应该用一个固定的融合权重服务所有 query。 正确的方向是按 query 特征路由:出现具体数字、文号、"多少人/多少万元"的,提高 BM25 权重;纯口语描述、没有任何术语的,降低 BM25 权重甚至直接走单路。这是一个 20 行代码的规则,比调 k 值有效得多。
5.7 实验五:拒答门控的阈值,是一个平台而不是一个点
用重排最高分作为"证据置信度",扫一遍阈值(5 道无答案题 / 31 道有答案题):
| 阈值 | 无答案问题正确拦截率 | 有答案问题误拒率 |
|---|---|---|
| 0.1 | 60% | 6.5% |
| 0.2 | 100% | 9.7% |
| 0.3 | 100% | 9.7% |
| 0.4 | 100% | 9.7% |
| 0.5 | 100% | 19.4% |
| 0.7 | 100% | 32.3% |
| 0.9 | 100% | 35.5% |
0.2 到 0.4 之间的结果完全一样——这是一个平台,不是拐点。原因就是重排分数的双峰分布:相关的都在 0.9 以上,不相关的都在 0.1 以下,中间那段几乎没有样本。
这张图有两个用法:
-
选阈值:取平台区中间(我们用 0.3),不要取边界。换模型、换语料时分布会整体平移,靠边界值很容易一夜之间全部误拒。
-
判断模型是否够用:如果扫出来的曲线是斜的(没有平台),说明重排分数不成双峰,判别力不够,这个重排模型在你的语料上不可信。
代价是 9.7% 的误拒(31 道有答案题里有 3 道被拦)。抽出来看,这 3 道都是"知识库里有、但用户表述和条款措辞差异很大"的口语题。对政务场景来说,这个交换是划算的——误拒的代价是用户多问一次,误答的代价是政策被错误解读。
但要补一层降级:门控命中时不要直接说"查不到",而是降级为"以下是我找到的原文条款,供您参考" + 展示证据 + 给人工入口。 拒答的正确形态是"交还控制权",不是"关上门"。
5.8 失败案例分析
混合检索 + RRF 之后仍然没进前 5 的题,只有一道:
| 题号 | 类型 | 问题 | 正确条款 | 被召回的第一名 |
|---|---|---|---|---|
| Q28 | 模糊口语 | 外地来打工的年轻人租房太贵了,有没有便宜点的房子? | P05 第三条(保租房租金) | P01 第一条(人才安家补贴总则) |
它失败的原因很典型:用户的口语表达和政务文本之间没有共享词汇。"租房贵""便宜点的房子" ↔ "保障性租赁住房租金按照同地段同品质市场租赁住房租金的 90% 确定"。稠密检索能模糊地对上(排第 4),但它同时被"人才引进"这类高频政务语义吸引(P01 第一条),BM25 完全无能为力。
这类问题不是检索能解决的,需要的是第四层能力:Query 改写 / 同义扩展。把"租房贵"扩展成"保障性租赁住房 租金 减免 新市民",检索就稳定了。这也是我们下一步要补的一块。
另外两道值得单独看:
| 题号 | 问题 | 现象 | 说明 |
|---|---|---|---|
| Q09 | "拿专利去银行质押贷款,利息有补贴吗?" | 稠密 3、BM25 2、RRF 3、重排 4 | 正确条款(贴息 50%、上限 50 万)和"贷款利息"的通用描述在多份政策里都有,属于跨政策相似条款,是真实的难例 |
| Q34 | "现在买新能源车还能领一万块补贴吗?" | 稠密 3、BM25 2、RRF 3 | 正确答案是"已到期",但检索只按语义找"补贴标准",找不到"有效期终止"这件事——必须靠元数据 |
5.9 这五组实验说明了什么
一句话总结:
在政务 RAG 里,决定效果的不是"用了几个模型",而是"在哪一层解决了哪个问题"。
切分解决"条款被切断"(+41.9pt),元数据解决"过期/跨区/层级"(定性正确),混合检索解决"数字与口语的两极分化"(+6.4pt),重排解决"跨政策候选的压缩与置信度"(候选脏时 +9.7pt,候选干净时 -9.7pt),门控解决"没证据时不瞎答"(100% 拦截 / 9.7% 误拒)。
如果只让保留一件事:把切分和元数据做对。 它是唯一一个提升幅度超过 40 个百分点的单项改动,而且它不需要 GPU、不需要新模型、不需要调参。
6. 这套方案还存在的问题
写了这么多"我们做对了什么",也得说说没解决的。
① 跨文件推理仍然很弱。 用户问"我既符合 A 政策又符合 B 政策,能同时享受吗",正确答案往往藏在两份文件的"不得重复享受"条款里。当前的 top-5 证据很容易只覆盖其中一份。这类问题需要条款级的关系抽取("不得重复享受"这种互斥关系),而不是靠检索凑巧召回。
② 修订版政策处理得不好。 政策修订后,新旧版本的条款高度相似(可能只改了金额),检索时两个版本会互相干扰。我们目前靠 is_expired 硬过滤,但对于"修订前后对比"这类问题完全无能为力。
③ 表格和附件是盲区。 政策里的申报材料清单、补贴分档表往往是表格。PDF 解析成纯文本后,表格的行列关系丢失,模型会读串行。这块需要专门的表格结构化,我们还没做透。
④ 评测集还是太小。 31 道有答案题,一条题就占 3.2 个百分点。文章里"77.4% vs 71.0%"这种 6 个点的差距,其实是两道题的差距——方向可信,幅度不可全信。真正上线前至少需要 200+ 题,并按业务线分层(人才、企业、民生各一档),才能支撑更细的决策。
⑤ Reranker 是延迟大头,而且比教程里写的贵得多。 我们实测 CPU 上 20 候选的重排是 1.7 秒(P95 2.1 秒),不是"几百毫秒"。这个量级注定它只能放在离线或异步路径上。要么上 GPU,要么把候选压到 5~10 条(但那样它就退化成"验票"而不是"排序"了)。
⑥ 门控会误伤 9.7%。 这部分用户的问题其实知识库里有答案,只是表述差异大。目前的降级策略是"展示原文条款 + 引导人工",体验上仍然不如直接答对。根治要靠 Query 改写,不是调阈值。
7. 小结
把这篇的工程决策压缩成一张表:
| 问题 | 做法 | 为什么 | 实测收益 |
|---|---|---|---|
| 条款被切断、锚点信息丢失 | 条款级切分 + 上下文头 + 元数据 | 检索细粒度,生成粗粒度 | R@1 +41.9pt |
| 政策名/数字召回与排序不准 | 向量 + BM25 并行召回 | 两类算法的失效机制互补 | R@1 +6.4pt,数字类 MRR 1.00 |
| 两路分数不可比 | RRF 融合(只看排名) | 免疫量纲、分布、跨 query 差异 | 免去归一化调参 |
| 候选池噪声大(长文档/跨政策) | Cross-Encoder 重排做压缩与置信度 | 强于跨政策区分,弱于同政策条款区分 | 脏候选 +9.7pt |
| 政策过期 | 有效期做标量字段过滤 | 元数据事实 > 语义推断 | 定性正确(排序解决不了) |
| 检索侧幻觉 | min_score 门控 + 无证据拒答 | 宁可说没有,不能编 | 拦截 100%,误拒 9.7% |
| 生成侧幻觉 | 提示词硬约束 + 引用/数字校验 | 把约束变成可自动验证的检查 | 数字类可全自动校验 |
如果只让我留一句话:
RAG 的可靠性不来自"更大的模型",而来自"更多的确定性"。 检索里每一处模糊,都会在答案里变成一次风险;反过来,每一处可以被程序校验的约束,都是一次免费的保险。
再加一句这次实验教会我的:
每一个组件都有它的适用层。 重排不是"加了就更准"的万能模块,它在候选脏的时候是英雄,在候选干净的时候是噪音。判断一个模块该不该用,不看它是不是 SOTA,看它解决的是不是你当前那一层的瓶颈。
附:复现脚本说明
实验代码结构(约 500 行):
lab/ ├── corpus.py # 24 份脱敏政策 + 36 题标注测试集 ├── bench.py # BM25 / BGE 向量 / RRF / CrossEncoder 重排 + 指标计算 ├── exp_rerank.py # 重排输入字段 × 候选数 的对照 ├── diag.py # 重排分数分布诊断 ├── cases.py # 逐题名次对比(失败分析) ├── report.py # 生成表格与图表 └── results/ # metrics.json / run.log / cases.txt / fig_*.png
跑法:
pip install transformers sentencepiece jieba numpy matplotlib python lab/bench.py # CPU 约 4 分钟(含重排)
脚本里包含五组对照,对应第 5 节的五张表:检索策略消融(dense / bm25 / rrf / 加权 rrf / rrf+rerank)、切分策略消融(100 字 / 200 字 / 条款级)、重排作用域(同一重排 × 三种候选质量)、分题型、拒答门控阈值扫描。
本文是 AI 工程实践系列第一篇。第二篇讲 LangGraph 如何用 StateGraph + interrupt + Checkpointer 把"模型能回答"变成"系统能可靠完成业务流程"。两篇合起来是一条完整链路:检索决定模型看到什么,编排决定模型能做什么。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)