重排序
你手上有几千篇文档,要从中找出能回答某个问题的那一篇,怎么找?
通常分两步。先用一个很快的方法(比如关键词匹配)把几千篇砍到几十篇「看起来沾边」的候选,这一步叫快速检索。快速检索擅长缩小范围,却不擅长判断候选里哪一篇才真正回答了问题。这正是重排序要做的事:把候选逐篇拿来和问题直接比对,再把最好的那篇排到第一。
本文在百度 DuReader 检索数据集(C-MTEB 版 DuRetrieval)上把两步都跑了一遍:BM25 为 80 个真实搜索查询各从 4,085 段网页语料中取出 30 个候选,然后用象信对每个「查询–候选」对问一个 Noul,按概率重排。重排之后,排在第一位的段落就是正确答案的查询占比从 66.3% 升到 81.3%,nDCG@10 从 0.540 升到 0.730;2,400 次调用共 685,334 个输入 token,花费约 ¥0.029。
读完本文你会了解:
- 快速检索做了什么,为什么它不是全部答案;
- 重排序是什么,它怎样接在快速检索后面;
- 象信如何给一个「查询–候选」对打分,以及这能让结果好多少。
如何在几千篇文档里找到那一篇?
你有一堆文档和一个查询(描述你要找什么的一段文字),堆里有若干篇能回答它。
最直接的做法是把每篇文档都拿来和查询比一遍:一百万篇文档,每个查询就要比一百万次。两步法可以大大减少工作量:
- 用一个足够快、能跑遍全库的方法,把文档堆缩成一份短名单;
- 只在短名单上用一个更准、更贵的方法,找出真正的答案。
全部文档(4,085 段)
│ 快速检索:BM25,跑遍全库
▼
短名单(30 段)
│ 重排序:象信逐个判断「查询–候选」对
▼
重排后的短名单(同样 30 段,正确答案排到前面)什么是快速检索?
快速检索指能把查询和整个语料库比一遍、迅速返回一份排好序的短名单的方法。常见的有关键词检索(如 BM25)和稠密向量检索(按语义相似度比较段落),实际系统里两者经常混用。
本文的第一步只用 BM25:它按查询与段落共享的词给段落打分。中文没有空格,所以先用 jieba 分词。第一步刻意保持简单,是为了把注意力留给重排序——重排序只看得到进了短名单的段落,快速检索用哪种方法并不影响后面的做法。
什么是重排序?
重排序接过快速检索给出的短名单,把它重新排一遍。它不再拿查询去和整个语料库比,而是把查询和短名单上的每一个候选单独配对比较,然后按这个分数排序。
分数可以来自语言模型:把查询和一个候选一起交给模型,问它这个候选能不能回答这个查询。这样,即使正确段落的措辞和查询不太一样,重排序也能把它找出来。
用象信重排序
重排序需要给每个「查询–候选」对一个可比较的分数。通用大模型也能做,但你得先设计一套评分量表,再在提示词里要求模型对每个候选都用同一把尺子;同一个对子重复调用,分数还可能不一样(见 自洽性:Noul);而且为了一个数字去生成一段文本,时间和成本都花在了不需要的地方。
象信返回什么
用象信,打分请求可以就是一个是非题:
这个段落能否直接回答用户的问题?只回答「是 / 否」没法给 30 个候选排序。Noul 返回的是 0 到 1 之间的一个数,即象信估计的「答案为是」的概率。问题的 criteria 写清楚什么算「是」、什么算「否」,象信对每一个对子用同一套标准判断,直接返回这个概率,应用程序就按它排序。不需要为通用模型发明评分量表;象信一次前向就给出概率、不生成文本,输出 token 也不计费。
重排的全部逻辑只有两行:
nouls = {c: ask_xiangxin(query, c) for c in shortlist}
reranked = sorted(shortlist, key=lambda c: nouls[c], reverse=True) # 概率高的排前面查询 + 30 个候选 + 同一个 Noul(criteria 规定什么算「是」)
│ 每个候选一次请求,请求之间互不可见
├─ state {query, 候选 1} → noul
├─ state {query, 候选 2} → noul
├─ …
└─ state {query, 候选 30} → noul
▼
按 noul 从高到低排序 → 重排后的短名单一个重排序示例
环境准备
pip install xiangxin-sdk rank-bm25 jieba
# 只有重新从原始数据切语料(prepare_data.py)时才需要:
pip install pandas pyarrow
export XIANGXIN_API_KEY="sk-xx-..."import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import jieba
from rank_bm25 import BM25Okapi
from xiangxin import Noul, RetryPolicy, XiangxinClient
MODEL = "xiangxin-latest"
PRICE_PER_M_INPUT = 0.042 # 元 / 百万输入 token,输出免费
TOP_K = 30 # 快速检索交给重排的候选数
client = XiangxinClient(timeout=60.0, retry=RetryPolicy(max_retries=6, backoff_max=8.0))数据
数据来自 C-MTEB/DuRetrieval 及其 qrels,即百度 DuReader-retrieval 的开发集(DuReader 仓库声明采用 Apache-2.0 许可)。查询是真实的百度搜索词,段落来自网页,每个查询有人工标注的若干个正例段落。完整语料有 100,001 段、2,000 个查询。
为了和 TypeSafe 原文对 CLERC 的处理保持一致,prepare_data.py 这样切出一个小语料库:
- 随机抽 170 个查询;
- 把它们的全部正例段落,加上每个查询在 10 万段全量语料里 BM25 排名前 20 的非正例段落(困难负例)放进同一个共享语料库,每段截断到 600 字;
- 前 20 个抽中的查询只作为语料来源,从其余 150 个里再抽 80 个用于评测。
得到的语料库有 4,085 段,80 个评测查询平均每个有 5.42 个正例。几个查询样例:
红米1怎么用小米手环
信用卡套现1万要多少钱
养老保险交几年
孕妇能用打印机吗
寄生胎能活吗切好的子集(data/corpus.jsonl、data/queries.jsonl)已随代码一起提交,直接运行 main.py 即可,不必重新下载 10 万段原始语料。
第一步:BM25 快速检索
DATA = Path("data")
corpus = {r["id"]: r["text"] for r in map(json.loads, open(DATA / "corpus.jsonl"))}
rows = [json.loads(l) for l in open(DATA / "queries.jsonl")]
queries = {r["qid"]: r["query"] for r in rows}
golds = {r["qid"]: set(r["positives"]) for r in rows}
def tok(text: str) -> list[str]:
return [w for w in jieba.lcut(text.lower()) if w.strip()]
cids = list(corpus)
bm25 = BM25Okapi([tok(corpus[c]) for c in cids])
candidates = {}
for q, text in queries.items():
s = bm25.get_scores(tok(text))
candidates[q] = [cids[i] for i in sorted(range(len(cids)), key=lambda i: -s[i])[:TOP_K]]这一步还没有用到象信。短名单的覆盖情况:
| 指标 | 值 |
|---|---|
| 短名单里至少有一个正例的查询 | 78 / 80(97.5%) |
| 全部正例中进入短名单的比例 | 68.2% |
| 第一名就是正例的查询(BM25) | 66.3% |
和 TypeSafe 原文的 CLERC(BM25 首位命中率只有 5%)相比,DuReader 对 BM25 友好得多:搜索词短,正确段落往往就包含查询里的关键词。所以这里留给重排的空间更小,但短名单里依然混着大量「关键词对、答案不对」的段落。重排序只能调整短名单内部的顺序,不能把快速检索漏掉的段落找回来——有 2 个查询的短名单里一个正例都没有,重排对它们无能为力。
第二步:用象信重排
对每个短名单上的每个候选问同一个 Noul:80 个查询 × 30 个候选 = 2,400 次调用,用线程池并发发出。
answers_query = Noul(
instructions=(
"`query` 是用户在搜索引擎里输入的问题,`passage` 是检索系统返回的一个候选网页段落。"
"这个段落能否直接回答用户的问题?"
),
criteria={
"true": "段落给出了问题所要的具体答案或关键信息,用户读完这段就能得到答案。",
"false": "段落只是话题相近、出现了相同的关键词,或讲的是另一个问题,读完仍得不到答案。",
},
)
def score(pair):
q, c = pair
r = client.system_one(
state={"query": queries[q], "passage": corpus[c]},
questions={"answers_query": answers_query},
model=MODEL,
)
return r.answers["answers_query"].noul, r.usage.input_tokens
pairs = [(q, c) for q in queries for c in candidates[q]]
with ThreadPoolExecutor(4) as pool:
results = dict(zip(pairs, pool.map(score, pairs)))
reranked = {
q: sorted(candidates[q], key=lambda c: -results[(q, c)][0]) for q in queries
}仓库里的 main.py 在此基础上加了本地缓存(重复运行直接回放,不再计费)和失败重试,并计算下面的指标。
结果
2400 次象信调用,输入 685,334 token,输出 40,614 token,费用 ¥0.0288| 指标(80 个查询) | BM25 | BM25 + 象信重排 |
|---|---|---|
| 第 1 名是正例 | 66.3% | 81.3% |
| 前 5 名有正例 | 88.8% | 97.5% |
| 前 10 名有正例 | 93.8% | 97.5% |
| MRR@30 | 0.773 | 0.884 |
| nDCG@10 | 0.540 | 0.730 |
前 5、前 10 名的 97.5% 已经等于短名单覆盖率的上限(78/80):凡是短名单里有正例的查询,重排后前 5 名里都至少有一个。nDCG@10 的提升最明显(+0.19),因为它看的是所有正例的位置,而不只是第一个——重排不仅把一个正例提上来,还把同一查询的多个正例一起往前挪。
按「第一个正例的名次」逐个查询看:23 个查询变好,47 个不变(其中 45 个本来就排第一),8 个变差。
象信给出的概率本身也能说明问题:
| 对子数 | noul 中位数 | noul ≥ 0.5 的比例 | |
|---|---|---|---|
| 标注为正例 | 296 | 0.79 | 89.9% |
| 非正例 | 2,104 | 0.13 | 9.9% |
看一个例子
查询「杜甫描写春雨的诗句」,BM25 的前 6 名和象信给的概率:
| BM25 名次 | 重排后名次 | 正例 | noul | 段落开头 |
|---|---|---|---|---|
| 1 | 25 | 0.03 | 描写松树的诗句有哪些?大雪压青松,青松挺且直…… | |
| 2 | 2 | ✓ | 0.87 | ?题目杜甫写的关于春雨的诗都要…… |
| 3 | 7 | 0.33 | 月出惊山鸟,时鸣春涧中。(王维《鸟鸣涧》)感时花溅泪…… | |
| 4 | 15 | 0.12 | 你所知道的杜甫诗歌名句有:子美少陵野老“诗圣”…… | |
| 5 | 1 | ✓ | 0.89 | 春夜喜雨杜甫好雨知时节,当春乃发生.随风潜入夜…… |
| 6 | 22 | 0.05 | 描写浮岸的诗,有关浮岸的诗句古诗诗词…… |
BM25 把「描写……的诗句」这几个高频词匹配得最好的段落排在第一,可它讲的是松树;象信给它 0.03,把《春夜喜雨》原文排到了第一。
变差的 8 个查询里,有一部分是标注本身不完整。例如「红米1怎么用小米手环」,重排后排第一的段落(noul 0.93)是「1、组装并戴上手环……2、在手机上安装小米手环 App……」,从内容看确实在回答问题,但它不在人工标注的正例里。DuReader 的每个查询只标注了一部分相关段落,这类「未标注的正例」会让重排的得分略被低估。
讨论
为什么有效。 BM25 只数共享的词,「杜甫」「诗句」「春」出现得多就排得高;象信读的是查询和段落放在一起的语义,判断的是「读完这段能不能得到答案」。两者的错误方向不同,所以先用 BM25 圈范围、再用象信精排,效果比单用任何一个都好。
成本。 2,400 次调用平均每次约 286 个输入 token,总计不到三分钱。单次调用的中位耗时是 946 毫秒(在其他任务同时占用 API 的情况下测得,并发 4 路)。
局限。
- 重排只能在短名单里调整顺序,召回率由第一步决定。本例有 2 个查询在 BM25 前 30 名里没有正例;要提高上限,就要换更好的快速检索(例如向量检索或混合检索)或者放大
TOP_K(调用数随之线性增长)。 - DuReader 对 BM25 本来就友好,提升空间比 TypeSafe 原文的 CLERC 法律引文任务小。在查询和答案措辞差异更大的场景(法律引文、专业问答),BM25 的首位命中率会低得多,重排的相对收益通常更大;但象信一号 1.0 是 9B 模型,对需要深度专业推理的判断仍会出错,见 象信一号 1.0 的能力边界。
- 评测只有 80 个查询,上面的百分比每差一个查询就是 1.25 个百分点,看趋势比看小数点更有意义。
什么时候不该这么用。 如果候选成千上万、又要求毫秒级响应,逐对打分的调用量会太大,应该先把短名单压得更短。如果你要的是「这批候选里哪个最好」的单一答案而不是完整排序,而且候选不多,可以考虑把候选作为 Choice 的选项一次问完。
这里为了清楚,每个对子只问了一个问题。实际应用里,同一个对子往往要问好几件事(是否回答了问题、是否过时、是否来自可信来源……),可以放进同一次请求,见 并行提问 和 推测式扇出。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/rerank
prepare_data.py:从 DuRetrieval 原始文件切出本文的小语料;main.py:BM25 检索、象信重排、指标计算;results/:本次真实运行的全部输出(metrics.json、逐对打分pairs.csv、调用缓存cache.json)。

