逐行检索
手里有一份服务条款,用户用大白话提问:「销户之后还能拿回我的数据吗?」你要找出回答这个问题的那一行,还要能判断出文档里根本没写的情况。关键词检索做不到后一点,而且用户的说法和条款原文常常对不上(「踢出平台」对「暂停或终止您的访问」)。
本文用 GitHub 服务条款的官方中文版(212 行)做一个 find():给每一行编号,用一个 Choice 把行号当选项,给每一行打相关度;同一个请求里再问一个 Noul:文档里有没有答案。在 35 个手工标注的查询上,文档里有答案的 24 个查询中,象信把正确行排在第一的占 70.8%,排进前 3 名的占 87.5%。jieba + BM25 基线分别是 25.0% 和 62.5%。exists 概率把「有答案」和「没答案」完全分开(AUROC 1.0)。
同一批查询交给大模型(自部署 DeepSeek v4.1 flash,关闭思考),第一名命中 95.8%,三分类判断也准得多。象信的优势在别处:每次请求模型耗时约 3.7 秒、约 ¥0.00045,返回的是全部 212 行的概率和一个可以设阈值的 exists,而不是一段需要解析的文本。
整个做法分三步:
- 给每一行加上行号,让模型可以「指」向某一行。
- 用
Choice在行号之间分配概率。Choice 的概率总和为 1,所以即使没有任何一行回答了问题,也总有一行排第一。 - 在同一个请求里用
Noul判断文档有没有回答这个问题。它的概率不受其他选项影响,没有答案时可以接近 0。
环境准备
pip install xiangxin-sdk rank-bm25 jieba
export XIANGXIN_API_KEY="sk-xx-..."rank-bm25 和 jieba 只用于对比基线。大模型基线另需 openai 包和一个 OpenAI 兼容接口。
数据
- 文档:GitHub 服务条款(中文),生效日期 2026 年 4 月 27 日,2026-09-25 抓取。GitHub 的站点政策以 CC0-1.0 发布(条款第 G.3 节),docs.github.com 的文档内容以 CC-BY-4.0 发布。
- 切行:
prepare_data.py把原始 Markdown 按段落或列表项拆成一行一条:去掉开头的目录表,链接只留文字。结果是 212 行,加上行号共 15,316 个字符,一次请求约 10,650 个输入 token。 - 查询与标注:35 个中文口语化查询,由本文作者手工编写并标注(
data/queries.json):answered(24 个):文档里有直接回答的行,标出 1–2 个正确行号;partial(5 个):文档只涉及相关规定,没有正面回答(例如「未成年人经家长同意可以用吗?」,条款只写了「必须年满 13 周岁」);absent(6 个):文档完全没有涉及(例如「发生纠纷必须走仲裁吗?」,这版条款只规定了管辖法院,没有仲裁条款)。
编号后的文档是这样的:
L054| 您享有对您的内容的所有权。 如果您发布非本人创作的内容,您有责任确保拥有发布该内容的权利,并遵守所有适用的许可。
L055| 您向我们和其他用户授予第 D.4–D.8 节中的许可。 这些许可适用于您的内容。 ……
L056| 4.向我们授予许可问题设计
第 1 步:给每一行编号
from pathlib import Path
LINES = Path("data/github_tos_zh.txt").read_text(encoding="utf-8").splitlines()
def line_id(i: int) -> str:
return f"L{i:03d}"
DOCUMENT = "\n".join(f"{line_id(i)}| {line}" for i, line in enumerate(LINES))第 2 步:答案在哪一行(Choice)
行号就是选项,选项描述留空:每一行的原文已经在 state 里了。查询写在 instructions 里,state 在不同查询之间保持不变。
from xiangxin import Choice
def where_question(query: str) -> Choice:
return Choice(
instructions=f"文档中哪一行回答了这个问题:「{query}」?",
criteria={line_id(i): None for i in range(len(LINES))},
)说明
一个 Choice 最多 255 个选项,所以这个做法一次最多检索 255 行。更长的文档分两轮:第一轮用一个 Choice 选出一个窗口(一段连续的行或一个章节),第二轮在窗口内逐行排序。上下文上限见模型。
第 3 步:文档里有没有答案(Noul)
from xiangxin import Noul
def exists_question(query: str) -> Noul:
return Noul(
instructions=f"文档中是否有某一行回应或回答了这个问题:「{query}」?",
criteria={
"true": "至少有一行明确写出或直接蕴含了答案",
"false": "没有任何一行涉及这个问题",
},
)第 4 步:两个问题放进同一个请求
state 只发送一次,多问一个 exists 几乎不增加开销。
from xiangxin import XiangxinClient
client = XiangxinClient()
def find(query: str) -> dict:
resp = client.system_one(
state=DOCUMENT,
questions={"where": where_question(query), "exists": exists_question(query)},
model="xiangxin-latest",
)
probs = resp.answers["where"].probabilities
return {
"exists": resp.answers["exists"].noul,
"relevance": [probs.get(line_id(i), 0.0) for i in range(len(LINES))],
}relevance 按文档顺序给出每一行的得分。
第 5 步:解读结果
verdict() 把 exists 分成三档,show() 用字符条形图打印前几行:
FOUND, ABSENT = 0.7, 0.35
def verdict(exists: float) -> str:
if exists >= FOUND:
return "文档中有答案"
return "文档中没有答案" if exists < ABSENT else "只涉及部分"
def show(query: str, top: int = 4) -> None:
r = find(query)
print(f"「{query}」\n exists {r['exists']:.2f} -> {verdict(r['exists'])}")
ranked = sorted(range(len(LINES)), key=lambda i: r["relevance"][i], reverse=True)
for i in ranked[:top]:
bar = "#" * max(1, round(r["relevance"][i] * 12))
print(f" {line_id(i)} {r['relevance'][i]:.2f} {bar:<12} {LINES[i][:30]}")阈值 0.7 / 0.35 沿用了 TypeSafe 原文的取值,没有针对本文数据调过;下文会看到,它对象信偏高。
运行与结果
四个示例
与 TypeSafe 原文一样,先看四个查询:两个有直接答案,一个文档没有答案,一个只涉及一部分。以下是真实输出(results/demo.txt):
212 行,15,316 字符(含行号)
「我上传的代码归谁所有?」
exists 0.84 -> 文档中有答案
L054 0.33 #### 您享有对您的内容的所有权。 如果您发布非本人创作的内容,您有
L055 0.04 # 您向我们和其他用户授予第 D.4–D.8 节中的许可。 这些
L115 0.04 # GitHub 不对您的输入或输出内容主张所有权。
L053 0.03 # 3. 所有权和许可授予
「GitHub 能不能不打招呼就把我踢出平台?」
exists 0.82 -> 文档中有答案
L164 0.33 #### GitHub 有权随时暂停或终止您对网站全部或任何部分的访问
L165 0.06 # 4.效力存续
L163 0.04 # 3.GitHub 可能终止
L197 0.03 # 我们保留在任何时候自行决定修改这些服务条款的权利,并在发生任
「发生纠纷必须走仲裁吗?」
exists 0.18 -> 文档中没有答案
L172 0.24 ### 在合同或者任何法律或条例要求向 GitHub 发出通知的任何
L171 0.02 # 2.向 GitHub 发出的法律声明必须采用书面形式
「未成年人经家长同意可以使用 GitHub 吗?」
exists 0.17 -> 文档中没有答案
L209 0.16 ## 本协议只能通过 GitHub 授权代表签署的书面修订加以修改
L015 0.01 # 11. “用户”、“您”和“您的”是指访问或使用网站或服务、- 前两个查询直接给出答案和可以引用的原文。「踢出平台」和原文「暂停或终止您对网站……的访问,无论有无理由或有无通知」几乎没有共同词语,象信仍把 L164 排在第一。BM25 把这一行排在第 35 名。
- 「仲裁」:排第一的 L172(向 GitHub 发出法律声明的形式要求)得到 0.24,但
exists只有 0.18,说明这一行只是「最不离谱的」,并没有回答问题。结论正确:这版条款没有仲裁条款。只看排序会被误导,exists纠正了它。 - 「家长同意」:TypeSafe 原文里这个查询被判为「部分涉及」,而象信给出 0.17,判成了「没有答案」,排第一的也不是年龄条款 L031。「只涉及部分」这一档的问题见讨论。
35 个查询:排序
排序指标只在有正确行的查询上计算。名次按最坏情况计:与正确行同分的行都算排在它前面。大模型基线只返回最多 3 个行号,所以没有 MRR。结果见 results/summary.json 与 results/llm_summary.json:
| 查询类型 | 方法 | 第一名命中 | 前 3 名命中 | MRR |
|---|---|---|---|---|
| 有答案(24 个) | 象信(Choice) | 70.8% | 87.5% | 0.789 |
| 有答案(24 个) | BM25(jieba 分词) | 25.0% | 62.5% | 0.450 |
| 有答案(24 个) | DeepSeek v4.1 flash | 95.8% | 100% | — |
| 部分涉及(5 个) | 象信(Choice) | 20% | 40% | 0.307 |
| 部分涉及(5 个) | BM25(jieba 分词) | 20% | 40% | 0.298 |
| 部分涉及(5 个) | DeepSeek v4.1 flash | 100% | 100% | — |
BM25 输得最多的,是用户说法和条款措辞差得远的查询:「账号注销以后数据多久会被删掉」(BM25 第 56 名,象信第 1 名)、「踢出平台」(第 35 名对第 1 名)、「按年付费中途能退钱吗」(第 13 名对第 1 名)。
象信有两个查询排得很差:「我可以写脚本批量注册账号吗?」的正确行 L028(不允许通过「自动程序」注册帐户)排在第 72 名,「收到发票后多少天内必须付款?」的正确行 L150 排在第 70 名。前者排第一的是 L203「不可转让」,后者排第一的是 L172「法律声明必须采用书面形式」,都是措辞上带有「不得」「必须」这类义务语气、内容却不相关的行。这类错误在 212 个选项的长 Choice 里更容易出现。
35 个查询:exists 能不能判断文档里有没有答案
| 查询类型 | exists 最小值 | 中位数 | 最大值 |
|---|---|---|---|
| 有答案(24 个) | 0.35 | 0.745 | 0.89 |
| 部分涉及(5 个) | 0.10 | 0.17 | 0.51 |
| 没有答案(6 个) | 0.11 | 0.18 | 0.22 |
- 有答案 vs 没答案:AUROC = 1.0,两组没有重叠。只看 Choice 的最高概率也能区分(AUROC 0.934),但它的数值很小(有答案时中位数 0.315,没答案时 0.105),不如
exists好设阈值。 - 有答案 vs 部分涉及:AUROC 0.983。
- 部分涉及 vs 没答案:AUROC 0.483,完全分不开。
按 TypeSafe 的阈值(0.7 / 0.35)做三分类,象信 35 个查询对了 21 个(60.0%)。DeepSeek 直接输出 yes / partial / no,对了 31 个(88.6%):
| 真实 \ 判定 | 象信:有答案 | 象信:部分 | 象信:没有 | DeepSeek:有答案 | DeepSeek:部分 | DeepSeek:没有 |
|---|---|---|---|---|---|---|
| 有答案(24) | 14 | 10 | 0 | 23 | 1 | 0 |
| 部分涉及(5) | 0 | 1 | 4 | 1 | 2 | 2 |
| 没有答案(6) | 0 | 0 | 6 | 0 | 0 | 6 |
象信的错误集中在一个方向:exists 整体偏低,有答案的查询有 10 个落进了「部分涉及」。在这 35 个查询上,FOUND 取 0.25 左右就能把「有答案」和「没答案」全部分开(有答案最低 0.35,没答案最高 0.22)。但这是在同一批数据上看出来的,查询也只有 35 个。上线前请在你自己的文档和查询上重新定阈值。
成本与耗时
| 象信 | DeepSeek v4.1 flash(关闭思考) | |
|---|---|---|
| 调用次数 | 35(0 次失败) | 35(0 次解析失败) |
| 每次输入 token | 10,641–10,665 | 8,494–8,506 |
| 输出 token(35 次合计) | 免费 | 751 |
| 输入 token(35 次合计) | 372,791 | 297,481 |
| 费用(35 次合计) | ¥0.0157(¥0.042/百万输入 token) | 自部署;换算成官方 API 费用 = 输入 token × 输入单价 + 输出 token × 输出单价 |
| 单次耗时中位数 | 模型 3.7 秒,墙钟 3.8 秒 | 墙钟 12.6 秒 |
- DeepSeek 的分词器对中文更省 token,同一篇文档少用约 20% 的输入 token。
- DeepSeek 跑在自己部署的 vLLM 上,与其他任务共用 GPU、串行调用,所以它的耗时只能作参考。象信的耗时非常稳定,35 次请求都在 3.72–3.77 秒之间。
- 大模型基线的提示词要求只输出 JSON:
{"lines": [最多 3 个行号], "exists": "yes | partial | no"},temperature=0,关闭思考模式(chat_template_kwargs.thinking=False)。
讨论
为什么有效。 把行号做成 Choice 的选项,「检索」就变成了「在 212 个选项里选一个」。模型读完整篇文档再作答,所以能把「踢出平台」和「暂停或终止访问」、「多久会被删」和「90 天内删除」对上,这正是关键词检索做不到的。Choice 负责「在哪」,Noul 负责「有没有」,两个问题共用一份 state,一次前向就都答完了。
和大模型比。 这个任务上,DeepSeek v4.1 flash 明显更准:第一名命中 95.8% 对 70.8%,三分类 88.6% 对 60.0%。象信的长处在三点:
- 返回概率而不是文本:每一行都有分数,可以取前 k 行,可以设阈值,不需要解析输出,也不会有格式错误。
- 耗时可预测:35 次请求模型耗时都在 3.7 秒左右,不随输出长短变化。
- 便宜:35 次合计 ¥0.0157。
需要「高召回的候选行 + 是否有答案的粗判」时,用象信先筛一遍,再把前几行交给大模型或人工确认,是比较合适的分工。
局限。
- 「部分涉及」这一档靠不住。 5 个部分涉及的查询里有 4 个的
exists落进了「没有答案」一档,和真正没答案的查询混在一起。「家长同意」「支付宝」这类问题需要先认出「条款规定了相邻的事(年满 13 周岁、信用卡和 PayPal)」,再判断这算不算回答。这种推理对象信偏难(见已知短板)。如果你的场景一定要区分「部分回答」,把它交给人工或大模型复核。 - 长 Choice 会偶尔「跑偏」。 24 个有答案的查询里有 2 个,正确行掉到了第 70 名以后。结果最好展示前 3 行,并把
exists偏低的查询交给人工。 - 概率很分散。 212 行的 Choice,排第一的行概率往往只有 0.2–0.4。只能按名次用,不要把它当成「这一行就是答案的概率」。
- 标注规模小。 35 个查询和它们的正确行由作者一人标注,数字只能说明量级。
什么时候不该这么用。 文档有几千行、查询量很大时,每个查询都把整篇文档发一遍并不划算。应该先用向量或 BM25 召回候选段落,再用象信给候选打分(见重排序)。如果只需要「有没有提到某个词」,直接用字符串匹配。
完整代码
main.py(象信 + BM25 基线)、llm_baseline.py(大模型基线)、prepare_data.py(抓取与切行)、data/(条款原文、切好的 212 行、35 个标注查询),以及全部原始输出 results/:
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/semantic_find

