ДокументацияПрактические руководства (Cookbooks)Построчный семантический поиск

Построчный семантический поиск

Build semantic search for GitHub's Terms of Service. In one request, score 218 line ids against a plain-language query with a Choice question, and use a Noul question to check whether the document contains an answer.

Создайте семантический поиск по Условиям обслуживания GitHub. В одном запросе оцените 218 идентификаторов строк относительно запроса на естественном языке с помощью вопроса Choice и используйте вопрос Noul для проверки наличия ответа в документе.

У вас есть Условия обслуживания GitHub и вопрос к ним на естественном языке. Вам необходимо найти строки, содержащие ответ на вопрос, а также определять ситуации, когда в документе нет ответа. Представленные запросы выводят строки с прямыми ответами на первое место. Пороговые значения exists классифицируют остальные случаи как отсутствующие или частичные ответы. В итоге вы получаете функцию find(), которая возвращает вероятность exists и оценку релевантности для каждой строки.

Запрос сканирует документ и находит ответ, привязанный к соответствующей строке

Бэкенд поиска строится из трех частей:

  1. Разметить каждую строку идентификатором (ID), чтобы TypeSafe мог на нее сослаться.
  2. Использовать вопрос Choice, чтобы проранжировать эти идентификаторы строк по степени соответствия запросу. Сумма вероятностей в вопросе Choice всегда равна 1, поэтому какая-то строка окажется на первом месте, даже если ни одна из них не отвечает на запрос.
  3. В том же запросе использовать вопрос Noul, чтобы проверить, содержится ли ответ в документе вообще.

Настройка

Получите ключ API TypeSafe

Создайте ключ в консоли TypeSafe и экспортируйте его:

BASH THEME={NULL} api.wedstack.ru/v1
export TYPESAFE_API_KEY="your-key-here"

Установите зависимости

BASH THEME={NULL} api.wedstack.ru/v1
pip install "typesafe-sdk>=0.5.7" cooksafe \
  --extra-index-url https://pypi.typesafe.ai/

JsonCache воспроизводит сохраненные ответы API, поэтому описанные ниже шаги выполняются без ключа API и каких-либо затрат. Чтобы отправлять реальные запросы, задайте переменную TYPESAFE_API_KEY и удалите json_cache.json.

Создайте скрипт

Начните semantic_search.py с импорта библиотек и инициализации клиента:

PYTHON THEME={NULL} api.wedstack.ru/v1
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:

PYTHON THEME={NULL} api.wedstack.ru/v1
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 для указания на свой ответ.

PYTHON THEME={NULL} api.wedstack.ru/v1
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 теперь выглядит следующим образом:

PLAINTEXT api.wedstack.ru/v1
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 возвращает вероятность для каждого варианта. Используйте идентификаторы строк в качестве вариантов ответа — тогда «выбор варианта» превращается в «указание на строку».

PYTHON THEME={NULL} api.wedstack.ru/v1
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) остается неизменным между поисками.

Вопрос Choice принимает до 255 вариантов, поэтому этот рецепт позволяет выполнять поиск по документам объемом до 255 строк в одном запросе. Для документов большего размера поиск выполняется в два этапа: первый вопрос Choice выбирает окно строк, а второй ранжирует строки внутри выбранного окна.

Шаг 3: проверьте, существует ли ответ

Сумма вероятностей Choice всегда равна 1, поэтому какая-то строка займет первое место, даже если документ вообще не отвечает на вопрос. Одно лишь ранжирование не способно отличить реальный ответ от ближайшей нерелевантной строки.

Поэтому в том же запросе задается второй вопрос:

PYTHON THEME={NULL} api.wedstack.ru/v1
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 отвечает на оба вопроса за один проход. Состояние отправляется один раз, поэтому добавление проверки наличия ответа требует лишь незначительного дополнительного объема выходных данных.

Размеченный документ и вопрос пользователя передаются в один запрос TypeSafe. Вопрос Choice оценивает каждую строку, в то время как вопрос Noul проверяет наличие ответа. Затем локальный код ранжирует строки и выносит вердикт по документу.

PYTHON THEME={NULL} api.wedstack.ru/v1
@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 THEME={NULL} api.wedstack.ru/v1
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: выполните поиск

Задайте четыре вопроса: два с прямыми ответами, один без ответа и один с частичным ответом.

PYTHON THEME={NULL} api.wedstack.ru/v1
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)
PLAINTEXT api.wedstack.ru/v1
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, чтобы протестировать и отредактировать вопросы на том же тексте. Чтобы выполнить поиск по своему документу, замените URL в fetch_document(); весь остальной скрипт работает со списком LINES.