Построчный поиск
Семантический поиск по Условиям использования GitHub. В одном запросе оцените 218 идентификаторов строк по запросу на естественном языке вопросом Choice и проверьте вопросом Noul, есть ли в документе ответ.
У вас есть Условия использования GitHub и вопрос о них на естественном языке. Вам нужны
строки, отвечающие на вопрос, и способ обнаружить, когда в документе ответа нет.
Включённые запросы ставят строки с прямыми ответами на первое место. Пороги exists
классифицируют остальные случаи как отсутствие или частичный ответ. В итоге у вас есть
find(), возвращающая вероятность exists и одну оценку релевантности на строку.
Поисковый бэкенд собирается из трёх частей:
- Помечайте каждую строку идентификатором, чтобы TypeSafe мог на неё указать.
- Вопросом
Choiceранжируйте эти идентификаторы строк по тому, насколько хорошо они отвечают на запрос. Вероятности вопроса Choice всегда в сумме дают 1, поэтому какая-то строка выходит на первое место, даже когда ни одна не отвечает на запрос. - В том же запросе вопросом
Noulпроверяйте, есть ли в документе ответ вообще.
Установка
Получите ключ API TypeSafe
Создайте ключ в консоли TypeSafe и экспортируйте его:
export TYPESAFE_API_KEY="your-key-here"
Установите зависимости
pip install 'cooksafe>=0.2.0,<0.3.0'
JsonCache воспроизводит включённые ответы API, поэтому шаги ниже работают без ключа API
и без затрат. Чтобы сделать запросы реальными, задайте 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: пометьте каждую строку идентификатором
Тестовый документ — Условия использования 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 строк.
Теперь добавьте к каждой строке короткий идентификатор и снова соедините строки в один документ. Модель использует эти идентификаторы, чтобы указать на свой ответ.
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 возвращает вероятность для каждого варианта. Используйте идентификаторы
строк как варианты,
и «выбрать вариант» превращается в «указать на строку».
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, потому что документ уже содержит текст для каждого
идентификатора. Запрос идёт в 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, чтобы отредактировать
вопросы по тому же тексту. Чтобы искать по своему документу, замените URL в
fetch_document(); все остальные строки скрипта работают с LINES.