逐行检索
手里有一份服务条款,用户用大白话提问:「销户之后还能拿回我的数据吗?」你要找出回答这个问题的那一行,还要能判断出文档里根本没写的情况。关键词检索做不到后一点,而且用户的说法和条款原文常常对不上(「踢出平台」对「暂停或终止您的访问」)。
本文用 GitHub 服务条款的官方中文版(212 行)做一个 find():给每一行编号,用一个 Choice 把行号当选项,给每一行打相关度;同一个请求里再问一个 Noul:文档里有没有答案。在 35 个手工标注的查询上,文档里有答案的 24 个查询,正确行全部排进前 3 名,排第一的占 75%(jieba + BM25 基线为 25%)。文档里没答案的 6 个查询,exists 都不超过 0.26;有答案的 24 个查询都不低于 0.32,两组完全分开。但「只涉及一部分」这一档,象信一号 1.0 基本分不出来,详见讨论。
整个做法分三步:
- 给每一行加上行号,让模型可以「指」向某一行。
- 用
Choice在行号之间分配概率。Choice 的概率总和为 1,所以即使没有任何一行回答了问题,也总有一行排第一。 - 在同一个请求里用
Noul判断文档有没有回答这个问题。它的概率不受其他选项影响,没有答案时可以接近 0。
环境准备
pip install xiangxin-sdk rank-bm25 jieba
export XIANGXIN_API_KEY="sk-xx-..."rank-bm25 和 jieba 只用于对比基线。
数据
- 文档: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 行,13,833 个汉字和字符,最长的一行是一整段条款。 - 查询与标注: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}"第 2 步:答案在哪一行(Choice)
行号就是选项,选项描述留空:每一行的原文已经在 state 里了,查询写在 instructions 里。
from xiangxin import Choice
def where_question(query: str, w: range) -> Choice:
return Choice(
instructions=f"文档中哪一行回答了这个问题:「{query}」?",
criteria={line_id(i): None for i in w},
)第 3 步:文档里有没有答案(Noul)
from xiangxin import Noul
def exists_question(query: str) -> Noul:
return Noul(
instructions=f"文档中是否有某一行回应或回答了这个问题:「{query}」?",
criteria={
"true": "至少有一行明确写出或直接蕴含了答案",
"false": "没有任何一行涉及这个问题",
},
)第 4 步:按章节切窗口,每个窗口一个请求
一个 Choice 最多 255 个选项,212 行本来可以一次发完。但全文加上带 212 个选项的 Choice 约 1 万 token,本文运行时整篇一次发出的请求被服务端以 422 max_tokens_exceeded 拒绝(上下文上限见模型)。所以我们在第 J 节(AI 功能)处把文档切成两个窗口:L000–L110 和 L111–L211,每个窗口一个请求,约 5,000–5,700 token。行号保持全局编号。
两个窗口的结果这样合并:
- 文档级
exists取两个窗口的最大值:任一窗口有答案就算有答案; - 每一行的相关度 = 该行在本窗口 Choice 里的概率 × 本窗口的
exists。没有答案的窗口里,也会有一行被「矮子里拔将军」选成第一;乘上exists后,它就不会压过另一个窗口里真正的答案。
from xiangxin import XiangxinClient
client = XiangxinClient()
SPLIT = next(i for i, x in enumerate(LINES) if x.startswith("J. "))
WINDOWS = [range(0, SPLIT), range(SPLIT, len(LINES))]
def window_text(w: range) -> str:
return "\n".join(f"{line_id(i)}| {LINES[i]}" for i in w)
def find(query: str) -> dict:
parts = []
for w in WINDOWS:
resp = client.system_one(
state=window_text(w),
questions={"where": where_question(query, w), "exists": exists_question(query)},
model="xiangxin-latest",
)
parts.append((w, resp.answers["exists"].noul, resp.answers["where"].probabilities))
relevance = [0.0] * len(LINES)
for w, exists, probs in parts:
for i in w:
relevance[i] = probs.get(line_id(i), 0.0) * exists
return {"exists": max(e for _, e, _ in parts), "relevance": relevance}文档短到放得进一个请求时,去掉窗口循环即可,其余代码不变。文档超过 255 行时,也用同样的切窗口办法。
第 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 原文的取值,没有针对本文数据调过;下文会看到,它对象信一号 1.0 偏高。
运行与结果
四个示例
与 TypeSafe 原文一样,先看四个查询:两个有直接答案,一个文档没有答案,一个只涉及一部分。以下是真实输出(results/demo.txt):
212 行,13,833 字,分 2 个窗口
「我上传的代码归谁所有?」
exists 0.85 -> 文档中有答案
L115 0.53 ###### GitHub 不对您的输入或输出内容主张所有权。
L054 0.10 # 您享有对您的内容的所有权。 如果您发布非本人创作的内容,您有
L055 0.06 # 您向我们和其他用户授予第 D.4–D.8 节中的许可。 这些
L014 0.04 # 10. “服务”是指 GitHub 提供的应用程序、软件、产
「GitHub 能不能不打招呼就把我踢出平台?」
exists 0.84 -> 文档中有答案
L164 0.21 ### GitHub 有权随时暂停或终止您对网站全部或任何部分的访问
L165 0.11 # 4.效力存续
L163 0.08 # 3.GitHub 可能终止
L198 0.08 # 我们保留随时修改或者暂时或永久终止网站(或其任何部分)的权利
「发生纠纷必须走仲裁吗?」
exists 0.26 -> 文档中没有答案
L100 0.01 # 本协议根据知识共享零许可获得许可。 有关更多信息,请参阅我们
L172 0.01 # 在合同或者任何法律或条例要求向 GitHub 发出通知的任何
「未成年人经家长同意可以使用 GitHub 吗?」
exists 0.24 -> 文档中没有答案
L191 0.01 # Q. 免除和赔偿
L016 0.01 # 12. “用户生成内容”是指您或其他用户通过服务上传、提交或- 「踢出平台」:用户的说法和原文「暂停或终止您对网站……的访问,无论有无理由或有无通知」几乎没有共同词语,象信仍把 L164 排在第一。BM25 把这一行排在第 35 名。
- 「代码归谁」:排第一的是 L115「GitHub 不对您的输入或输出内容主张所有权」。这一行讲的是 AI 功能的输入输出,不是用户上传的代码。正确的 L054 排第二。两行都含有「所有权」,而且 L115 那个窗口的 Choice 更集中(0.65),所以胜出。
- 「仲裁」:两个窗口的
exists都不高,合并后排第一的行相关度只有 0.01。结论正确:这版条款没有仲裁条款。 - 「家长同意」:TypeSafe 原文里这个查询被判为「部分涉及」,而象信一号 1.0 给出 0.24,判成了「没有答案」,排第一的也不是年龄条款 L031(它排第 4)。「只涉及部分」这一档的问题见讨论。
35 个查询的整体表现
排序指标只在有正确行的查询上计算。名次按最坏情况计:与正确行同分的行都算排在它前面。结果见 results/summary.json:
| 查询类型 | 方法 | 第一名命中 | 前 3 名命中 | MRR |
|---|---|---|---|---|
| 有答案(24 个) | 象信:Choice × exists | 75.0% | 100% | 0.861 |
| 有答案(24 个) | 象信:只用 Choice 原始概率 | 70.8% | 91.7% | 0.826 |
| 有答案(24 个) | BM25(jieba 分词) | 25.0% | 62.5% | 0.450 |
| 部分涉及(5 个) | 象信:Choice × exists | 20% | 60% | 0.451 |
| 部分涉及(5 个) | BM25(jieba 分词) | 20% | 40% | 0.298 |
用 exists 给窗口加权,比直接拼接两个窗口的 Choice 概率更好(前 3 名命中 91.7% → 100%)。差别来自答案在一个窗口、另一个窗口里「矮子里拔将军」的情况。
BM25 输得最多的,是用户说法和条款措辞差得远的查询:「账号注销以后数据多久会被删掉」(BM25 第 56 名,象信第 1 名)、「踢出平台」(第 35 名对第 1 名)、「按年付费中途能退钱吗」(第 13 名对第 1 名)。
exists 能不能判断文档里有没有答案
| 查询类型 | exists 最小值 | 中位数 | 最大值 |
|---|---|---|---|
| 有答案(24 个) | 0.32 | 0.77 | 0.90 |
| 部分涉及(5 个) | 0.10 | 0.23 | 0.51 |
| 没有答案(6 个) | 0.14 | 0.185 | 0.26 |
- 有答案 vs 没答案:AUROC = 1.0,两组没有重叠。只看 Choice 原始最高概率也能分得不错(AUROC 0.962),但 Choice 的概率分散在 100 多个选项上,数值很小(有答案时中位数也只有 0.23),不如
exists好设阈值。 - 有答案 vs 部分涉及:AUROC 0.983。
- 部分涉及 vs 没答案:AUROC 0.5,完全分不开。
按 TypeSafe 的阈值(0.7 / 0.35)做三分类,35 个查询只对了 20 个(57.1%):
| 真实 \ 判定 | 有答案 | 部分涉及 | 没有答案 |
|---|---|---|---|
| 有答案(24) | 13 | 10 | 1 |
| 部分涉及(5) | 0 | 1 | 4 |
| 没有答案(6) | 0 | 0 | 6 |
错误集中在一个方向:象信一号 1.0 的 exists 整体偏低,有答案的查询有 10 个落进了「部分涉及」。在这 35 个查询上,把 FOUND 降到 0.3 左右就能把「有答案」和「没答案」全部分开。但这是在同一批数据上看出来的,查询也只有 35 个。上线前请在你自己的文档和查询上重新定阈值。
成本与耗时
- 每个查询 2 个请求,共 70 次象信调用,没有失败请求(
results/errors.json为空)。 - 每个查询的输入 token 为 10,722–10,770(两个窗口合计),35 个查询共 375,982 token。按 ¥0.042/百万 token 计,全部 35 个查询合计 ¥0.0158,单个查询约 ¥0.00045。
- 单个窗口请求的模型耗时中位数为 5.7 秒,最快 2.9 秒;单个查询(两个窗口顺序发出)墙钟耗时的中位数为 16.8 秒。运行时同一台机器上还有其他批量任务,这些数字偏慢;两个窗口并行发出可以直接省掉一半时间,见扇出。
大模型基线
llm_baseline.py 把同样的编号全文(不切窗口)交给 DeepSeek,让它输出最相关的 3 个行号和「yes / partial / no」。这部分结果待补:本文写作时 LLM 密钥还没有配置,脚本已就绪,未设置 LLM_API_KEY 时会直接退出。
讨论
为什么有效。 把行号做成 Choice 的选项,「检索」就变成了「在 212 个选项里选一个」。模型读完整个窗口再作答,所以能把「踢出平台」和「暂停或终止访问」、「多久会被删」和「90 天内删除」对上,这正是关键词检索做不到的。Choice 负责「在哪」,Noul 负责「有没有」,两个问题共用一份 state,一次前向就都答完了。
局限。
- 「部分涉及」这一档靠不住。 5 个部分涉及的查询里有 4 个的
exists落进了「没有答案」一档,和真正没答案的查询混在一起。「家长同意」「支付宝」这类问题需要先认出「条款规定了相邻的事(年满 13 周岁、信用卡和 PayPal)」,再判断这算不算回答。这种推理对 9B 的象信一号 1.0 偏难(见象信一号 1.0 的短板)。如果你的场景一定要区分「部分回答」,把它交给人工或大模型复核。 - 近义的错误行会排第一。 「代码归谁」排第一的是 AI 输入输出的所有权条款。结果最好展示前 2–3 行,而不是只给第一名。
- 概率很分散。 212 行的 Choice,排第一的行概率往往只有 0.1–0.3。只能按名次用,不要把它当成「这一行就是答案的概率」。
- 长度限制。 窗口越大,每个选项分到的注意力越少,也越容易超过上下文上限。按章节切窗口是最省事的做法。
- 标注规模小。 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

