逐行搜索
逐行搜索
为 GitHub 的服务条款构建语义搜索。一次请求里,用一个 Choice 问题给 218 个行 id 相对一句大白话查询打分,再用一个 Noul 问题检查文档里到底有没有答案。
你手上有 GitHub 的服务条款,还有一个关于它的大白话问题。你需要的是能回答这个问题的行,
以及一种在文档里没有答案时能察觉出来的办法。示例里的查询把有直接答案的行排在前面。
exists 阈值把其余情况分成「缺失」或「部分」。最终你会得到一个 find(),它返回
exists 概率,以及每一行的一个相关性分数。
搜索后端由三部分拼起来:
- 给每一行打上一个 ID,这样 TypeSafe 才能指向它。
- 用一个
Choice问题,按各行回答该查询的好坏给这些行 ID 排序。Choice 问题的概率总和恒为 1,所以即使没有任何一行回答了查询,也总会有一行排在第一。 - 在同一次请求里,用一个
Noul问题检查文档里到底有没有答案。
准备工作
获取 TypeSafe API key
在 TypeSafe 控制台创建一个 key 并导出:
export TYPESAFE_API_KEY="your-key-here"
安装依赖
pip install 'cooksafe>=0.2.0,<0.3.0'
JsonCache 会回放随附的 API 响应,所以下面的步骤不需要 API key
也不花一分钱。若想让请求真正发出去,就设置 TYPESAFE_API_KEY 并删掉
json_cache.json。
创建脚本
semantic_search.py 从导入和客户端开始:
import os
import urllib.request
from pathlib import Path
from cooksafe import JsonCache
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
client = TypeSafeClient(
api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"), timeout=120.0
)
json_cache = JsonCache(Path("json_cache.json"))
第 1 步:给每一行打上 ID
测试文档是 GitHub 的服务条款,切成 218 条,这样每条搜索结果都指向一行可以引用的文字。
往 semantic_search.py 里加上:
GIST = (
"https://gist.githubusercontent.com/eugene-shvarts/900632789a24983d5678ffd508dd01f6"
"/raw/cf9c2ab422d568deade949ef0a06bed6896964b9/github-tos.txt"
)
@json_cache
def fetch_document(url: str) -> str:
request = urllib.request.Request(
url, headers={"User-Agent": "typesafe-cookbook/1.0"}
)
with urllib.request.urlopen(request) as response:
return response.read().decode()
LINES = fetch_document(GIST).splitlines()
缓存避免了重复下载,splitlines() 留下一个含 218 个字符串的列表。
现在给每一行加一个短 ID 前缀,再把这些行拼回一个文档。模型用这些 ID 指向它的答案。
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))
DOCUMENT 现在长这样:
L052| You own Your Content. If you post Content you did not create, you are responsible for...
L053| You grant us and other Users the licenses in Sections D.4–D.8. These licenses apply...
L054| 4. License Grant to Us
第 2 步:问答案在哪
一个 Choice 问题会为每个选项返回一个概率。把行 ID 当作选
项,“挑一个选项” 就变成了 “指向某一行”。
def where_question(query: str) -> Choice:
return Choice(
instructions=f'Which line of the document contains the answer to: "{query}"?',
criteria={line_id(i): None for i in range(len(LINES))},
)
选项的描述都是 None,因为文档里已经含有每个
ID 对应的文本了。查询放在 instructions 里;状态在多次搜索之间保持不变。
第 3 步:检查是否存在答案
Choice 的概率总和恒为 1,所以即使文档 回答不了这个问题,也总有某一行排在第一。光靠排序,分不清真答案和离得最近的 不相关行。
所以在同一次请求里再问第二个问题:
def exists_question(query: str) -> Noul:
return Noul(
instructions=f'Does any line of the document address or answer: "{query}"?',
criteria=NoulCriteria(
true="At least one line of the document states or directly implies the answer",
false="No line of the document addresses this",
),
)
与 Choice 的概率不同,Noul 的概率不取决于其它选项, 所以当文档里没有答案时,它可以掉到接近零。
第 4 步:把两个问题放进一次请求
system_one 方法一趟就回答这两个问题。状态只发送一次,所以
加上存在性检查只需要多出一点点输出。
@json_cache
def _find(
model: str,
state: str,
where: Choice,
exists: Noul,
) -> dict:
response = client.system_one(
state=state,
questions={"where": where, "exists": exists},
model=model,
)
probabilities = response.answers["where"].probabilities
return {
"exists": response.answers["exists"].noul,
"relevance": [probabilities.get(line_id(i), 0.0) for i in range(len(LINES))],
}
def find(query: str) -> dict:
return _find(
TYPESAFE_MODEL,
DOCUMENT,
where_question(query),
exists_question(query),
)
relevance 列表按文档顺序为每一行保留一个分数。
第 5 步:读结果
两段本地代码收尾:verdict() 把原始的 exists 概率
变成三种状态,其中给部分回答留了一个中间态;show() 把 relevance
渲染成柱状图,让排序在终端里也能读。
FOUND, ABSENT = 0.7, 0.35 # present answers typically read >=0.9, absent <=0.05
def verdict(exists: float) -> str:
if exists >= FOUND:
return "answered in this document"
return "not in this document" if exists < ABSENT else "partially addressed"
def show(query: str, top: int = 4) -> dict:
result = find(query)
print(f'"{query}"')
print(f" exists {result['exists']:.2f} -> {verdict(result['exists'])}")
ranked = sorted(
range(len(LINES)), key=lambda i: result["relevance"][i], reverse=True
)
for i in ranked[:top]:
bar = "#" * max(1, round(result["relevance"][i] * 12))
preview = LINES[i][:58].rstrip()
print(f" {line_id(i)} {result['relevance'][i]:.2f} {bar:<12} {preview}")
return result
这些阈值能区分下面的例子,但在生产环境使用之前, 请拿你自己的文档调一调。
第 6 步:运行搜索
问两个有直接答案的问题,一个没有答案的,一个只有 部分答案的,一共四个。
print(f"{len(LINES)} lines, {len(DOCUMENT):,} characters\n")
show("who owns the code I upload?")
print()
show("can GitHub kick me off the platform without warning?")
print()
show("do I have to take disputes to arbitration?", top=2)
print()
show("can minors use GitHub with parental permission?", top=2)
218 lines, 43,980 characters
"who owns the code I upload?"
exists 0.98 -> answered in this document
L052 0.95 ########### You own Your Content. If you post Content you did not crea
L046 0.02 # Short version: You own content you create, but you allow u
L051 0.02 # 3. Ownership and License Grants
L217 0.01 # Questions about the Terms of Service? Contact us through t
"can GitHub kick me off the platform without warning?"
exists 0.97 -> answered in this document
L168 0.97 ############ GitHub has the right to suspend or terminate your access t
L167 0.03 # 3. GitHub May Terminate
L000 0.00 # Effective date: April 27, 2026 · A. Definitions
L001 0.00 # Short version: We use these basic terms throughout the agr
"do I have to take disputes to arbitration?"
exists 0.14 -> not in this document
L205 0.86 ########## Except to the extent applicable law provides otherwise, th
L168 0.02 # GitHub has the right to suspend or terminate your access t
"can minors use GitHub with parental permission?"
exists 0.46 -> partially addressed
L029 0.90 ########### You must be age 13 or older. While we are thrilled to see
L012 0.07 # “User,” “You,” and “Your” refer to the individual person,
这些分数意味着什么
前两个查询返回直接答案,以及核对它们所需的源文本行。
另外两个则说明存在性检查为什么重要:
- 仲裁: 排序把最接近的一行给了 0.86 分,但
exists只有 0.14。答案并不在文档里。 - 家长许可: 年龄规定排在第一位,但它没有回答家长许可是否会改变这条规定。结果是部分涉及。
排序告诉你该去哪儿找;exists 分数告诉你这个结果
有没有回答问题。
在你自己的文档上试试
在 TypeSafe playground 中打开这份打好标签的合同,对着同一段文本编辑问题。想搜你自己的文档,就替换 fetch_document() 里的 URL;脚本其余
每一行都基于 LINES 工作。