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

问题有三个:

  1. 量纲不同。余弦相似度在 [0,1](归一化后),BM25 分数无上界,实测在 [0, 40+] 区间浮动。0.3 的权重等于把 BM25 放大了一个数量级。

  2. 分布不同。余弦相似度集中在 0.6–0.95 的窄带,BM25 是长尾分布。

  3. 不可跨 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_idstring唯一标识,引用溯源
parent_idstring父子块回填
doc_idstring文档级去重、聚合
title / doc_no / issuerstring引用展示、答案溯源
chapter / clausestring精确定位到"第X条第X款"
pageint原文 PDF 定位(用户点引用直接翻到那一页)
publish_date / valid_from / valid_tostring时效性判断、默认过滤
is_expiredbool检索期标量过滤(比语义判断可靠得多)
region / levelstring多地区/多层级政策隔离
doc_typestring办法/细则/通知/解读,不同类型回答口径不同

页码这一项值得单独说。 用户点开引用,最想要的是"原文第 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@1P95 延迟
纯向量检索71.0%93.5%100.0%0.817100.0%23 ms
纯 BM2564.5%87.1%93.5%0.75987.1%24 ms
向量 + BM25(等权 RRF)77.4%93.5%93.5%0.83796.8%24 ms
加权 RRF(1 : 0.5)74.2%93.5%93.5%0.82896.8%24 ms
混合检索 + 重排67.7%87.1%96.8%0.79496.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 字硬切3535.5%45.2%0.39587.1%
固定 100 字硬切6241.9%58.1%0.49090.3%
条款级切分(上下文头 + 元数据)10777.4%93.5%0.83796.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)小幅正收益
条款级切分 + RRF77.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 位重排打乱(正确答案本来是第一)

所以我们最后没有把重排放在"最终排序"的位置上。 它在系统里承担两件事:

  1. 证据门控:用它的最高分判断"这一批证据到底靠不靠谱"(5.7 节的数据证明这一项收益巨大);

  2. 脏候选压缩:在长文档、跨政策混合召回的场景下,用它的强项把"选错政策"的情况压掉。

而条款级的最终顺序,交回给 RRF + 元数据。这不是妥协,是把每个组件放在它真正擅长的那一层。

顺带说一句成本:这一列重排的 P95 延迟是 2096 ms(CPU,20 候选)。哪怕它效果更好,这个数字也决定了它上不了在线主链路。如果你在做一个没有 GPU 预算的政务问答,优先做切分和门控,别急着上重排。

5.6 实验四:分题型拆开看,才看得见融合的代价

平均值会掩盖问题。按题型拆开(R@5 / MRR):

检索方案政策名称类数字条件类模糊口语类时效陷阱
纯向量检索100% / 0.88100% / 0.95100% / 0.67100% / 0.33
纯 BM25100% / 0.82100% / 1.0080% / 0.48100% / 0.50
向量 + BM25(RRF)100% / 0.87100% / 1.0080% / 0.69100% / 0.33
混合检索 + 重排100% / 0.88100% / 1.0090% / 0.54100% / 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.160%6.5%
0.2100%9.7%
0.3100%9.7%
0.4100%9.7%
0.5100%19.4%
0.7100%32.3%
0.9100%35.5%

0.2 到 0.4 之间的结果完全一样——这是一个平台,不是拐点。原因就是重排分数的双峰分布:相关的都在 0.9 以上,不相关的都在 0.1 以下,中间那段几乎没有样本。

这张图有两个用法:

  1. 选阈值:取平台区中间(我们用 0.3),不要取边界。换模型、换语料时分布会整体平移,靠边界值很容易一夜之间全部误拒。

  2. 判断模型是否够用:如果扫出来的曲线是斜的(没有平台),说明重排分数不成双峰,判别力不够,这个重排模型在你的语料上不可信。

代价是 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 把"模型能回答"变成"系统能可靠完成业务流程"。两篇合起来是一条完整链路:检索决定模型看到什么,编排决定模型能做什么。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐