文档目录

逐行搜索

为 GitHub 服务条款构建语义搜索。在一次请求中,用 `Choice`(选择题)给 218 个行 ID 评分,再用 `Noul`(是非题)检查文档是否包含答案。

非官方中文翻译 · 内容以 英文原文 ↗ 为准 · 同步于 2026-10-15

你手里有 GitHub 服务条款和一个关于它的自然语言问题。你需要找到那些能回答该问题的行,还需要一种办法来检测文档中没有答案的情况。下面这些查询会优先把直接包含答案的行排在前面。exists 阈值把剩下的情况分成「缺失」和「部分」两类。最终你会得到一个 find(),它返回 exists 概率以及每一行的一个相关性分数。

一次查询扫描整篇文档,并在匹配到的行上呈现一个答案

搜索后端由三部分组成:

  1. 给每一行打上一个 ID,让 TypeSafe 能指向它。
  2. 用一个 Choice 问题把这些行 ID 按回答问题的程度排序。Choice 问题的概率加起来总是等于 1,所以即使没有任何一行回答了问题,仍然会有一行排在第一位。
  3. 在同一次请求里,用一个 Noul 问题检查文档到底有没有答案。

准备工作

获取 TypeSafe API 密钥

在 TypeSafe 开发者控制台里创建一个密钥,然后导出它:

bash
export TYPESAFE_API_KEY="your-key-here"

安装依赖

bash
pip install 'cooksafe>=0.2.0,<0.3.0'

JsonCache 会回放打包好的 API 响应,所以下面的步骤不需要 API 密钥,也不花任何费用。如果想真正发起请求,把 TYPESAFE_API_KEY 设上,并删掉 json_cache.json。

创建脚本

在 semantic_search.py 里先写好导入语句和客户端:

python
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 里加:

python
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 来指向它的答案。

python
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 现在长这样:

text
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 当成选项,「选一个选项」就变成了「指向一行」。

python
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(指令)里;state(上下文状态)在多次搜索之间保持不变。

步骤 3:检查答案是否存在

Choice 的概率加起来总是 1,所以即使文档并没有回答问题,也会有某行排在最前面。仅靠排序没法区分「真的有答案」和「最相关的那条不相关的行」。

所以在同一次请求里再问一个问题:

python
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 的概率不依赖其他选项,所以当文档没有答案时,它会接近 0。

步骤 4:把两个问题放在同一次请求里发出

system_one 方法能一次同时回答这两个问题。state 只需要发一次,所以加上存在性检查只多花一点输出量。

一篇带标签的文档和一个用户问题进入一次 TypeSafe 请求。Choice 问题给每一行打分,Noul 问题检查答案是否存在。本地代码随后对这些行排序,并给出文档级判断。

python
@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 渲染成一张柱状图,这样在终端里也能看清排序结果。

python
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

这些阈值能把下面的例子区分开,但用在生产环境前,请针对你自己的文档调一下。

分别问两个有直接答案的问题、一个没有答案的问题、一个只有部分答案的问题,一共四个。

python
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)
text
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,

分数的含义

前两个查询会返回直接答案以及验证它们所需的来源行。

另外两个查询说明了存在性检查为什么重要:

  • 仲裁(Arbitration): 排序把最接近的一行打成了 0.86 分,但 exists 只有 0.14。答案并不在文档中。
  • 家长许可(Parental permission): 年龄规则排在第一位,但它并未回答家长许可是否会改变该规则。结果是 部分覆盖(partially addressed)。

排序告诉你该去哪里看;exists 分数告诉你该结果是否回答了该问题。

尝试你自己的文档

在 TypeSafe playground 中打开已打标的合同,即可针对同一段文本编辑问题。要搜索你自己的文本,只需替换 fetch_document() 中的 URL;脚本的其他每一行都是基于 LINES 来工作的。


本文档由 Mintlify 构建和托管,Mintlify 是一款面向开发者的文档平台。