Pesquisa linha a linha
Constrói pesquisa semântica para os Termos de Serviço do GitHub. Num só pedido, pontua 218 ids de linha face a uma consulta em linguagem natural com uma pergunta Choice, e usa uma pergunta Noul para verificar se o documento contém uma resposta.
Tens os Termos de Serviço do GitHub e uma pergunta em linguagem natural sobre eles.
Precisas das linhas que respondem à pergunta e de uma forma de detetar quando o documento
não tem resposta. As consultas incluídas ordenam primeiro as linhas com respostas diretas.
Os limiares de exists classificam os restantes casos como ausentes ou parciais. Acabas
com find(), que devolve a probabilidade de exists e um score de relevância por linha.
O backend de pesquisa monta-se em três partes:
- Marca cada linha com um ID para que o TypeSafe a possa apontar.
- Usa uma pergunta
Choicepara ordenar esses IDs de linha segundo quão bem respondem à consulta. As probabilidades de uma pergunta Choice somam sempre 1, por isso uma linha fica em primeiro mesmo quando nenhuma responde à consulta. - No mesmo pedido, usa uma pergunta
Noulpara verificar se o documento contém alguma resposta.
Configuração
Obtém uma chave da API TypeSafe
Cria uma chave na consola do TypeSafe e exporta-a:
export TYPESAFE_API_KEY="your-key-here"
Instala as dependências
pip install 'cooksafe>=0.2.0,<0.3.0'
O JsonCache reproduz as respostas de API incluídas, por isso os passos abaixo correm sem
uma chave de API nem qualquer gasto. Para fazer os pedidos ao vivo, define
TYPESAFE_API_KEY e apaga o json_cache.json.
Cria o script
Começa o semantic_search.py com os imports e o cliente:
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"))
Passo 1: marcar cada linha com um ID
O documento de teste são os Termos de Serviço do GitHub, divididos em 218 cláusulas, para que cada resultado de pesquisa aponte para uma linha citável.
Adiciona ao 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()
A cache impede downloads repetidos, e splitlines() deixa uma lista de 218 strings.
Agora prefixa cada linha com um ID curto e volta a juntar as linhas num único documento. O modelo usa estes IDs para apontar a sua resposta.
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))
O DOCUMENT fica agora assim:
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
Passo 2: perguntar onde está a resposta
Uma pergunta Choice devolve uma probabilidade para cada opção. Usa os IDs de linha como
as opções, e «escolhe uma opção» passa a «aponta para uma linha».
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))},
)
As descrições das opções são None porque o documento já contém o texto de cada ID. A
consulta vai em instructions; o estado mantém-se inalterado entre pesquisas.
Passo 3: verificar se existe uma resposta
As probabilidades de um Choice somam sempre 1, por isso alguma linha fica em primeiro mesmo quando o documento não responde à pergunta. A ordenação por si só não distingue uma resposta real da linha irrelevante mais próxima.
Por isso faz uma segunda pergunta, no mesmo pedido:
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",
),
)
Ao contrário das probabilidades do Choice, a probabilidade do Noul não depende das outras opções, por isso pode cair perto de zero quando o documento não tem resposta.
Passo 4: enviar as duas perguntas num só pedido
O método system_one responde a ambas as perguntas numa só passagem. O estado é enviado
uma vez, por isso acrescentar a verificação de existência exige apenas uma pequena
quantidade de saída extra.
@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),
)
A lista relevance guarda um score por linha, pela ordem do documento.
Passo 5: ler o resultado
Duas peças de código local terminam o trabalho: verdict() transforma a probabilidade
bruta de exists em três estados, com um intermédio para respostas parciais, e show()
desenha a relevance como um gráfico de barras para que a ordenação seja legível num
terminal.
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
Estes limiares separam os exemplos abaixo, mas afina-os face aos teus próprios documentos antes de os usares em produção.
Passo 6: executar a pesquisa
Faz duas perguntas que têm respostas diretas, uma que não tem resposta, e uma que tem uma resposta parcial, quatro no total.
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,
O que significam os scores
As duas primeiras consultas devolvem respostas diretas e as linhas de origem necessárias para as verificar.
As outras duas mostram porque importa a verificação de existência:
- Arbitragem: A ordenação dá à linha mais próxima um score de 0.86, mas
existsé apenas 0.14. A resposta não está no documento. - Autorização parental: A regra de idade fica em primeiro, mas não responde se a autorização parental altera a regra. O resultado é parcialmente abordado.
A ordenação diz-te onde olhar; o score exists diz-te se o resultado responde à pergunta.
Experimenta com o teu próprio documento
Abre o contrato etiquetado no playground do TypeSafe para editar as perguntas
face ao mesmo texto. Para pesquisares o teu próprio, troca o URL em fetch_document();
todas as outras linhas do script trabalham a partir de LINES.