Búsqueda línea por línea
Construye búsqueda semántica para los Términos de Servicio de GitHub. En una sola solicitud, puntúa 218 ids de línea contra una consulta en lenguaje natural con una pregunta de Choice, y usa una pregunta de Noul para comprobar si el documento contiene una respuesta.
Tienes los Términos de Servicio de GitHub y una pregunta en lenguaje natural sobre ellos.
Necesitas las líneas que responden a la pregunta y una forma de detectar cuándo el documento
no tiene respuesta. Las consultas incluidas ordenan primero las líneas con respuestas
directas. Los umbrales de exists clasifican el resto de los casos como ausentes o parciales.
Terminas con find(), que devuelve la probabilidad de exists y una puntuación de relevancia
por línea.
El backend de búsqueda se arma en tres partes:
- Etiqueta cada línea con un ID para que TypeSafe pueda señalarla.
- Usa una pregunta
Choicepara ordenar esos ids de línea según lo bien que responden a la consulta. Las probabilidades de una pregunta Choice siempre suman 1, así que una línea queda primera aunque ninguna responda a la consulta. - En la misma solicitud, usa una pregunta
Noulpara comprobar si el documento contiene alguna respuesta.
Preparación
Obtener una clave de API de TypeSafe
Crea una clave en la consola de TypeSafe y expórtala:
export TYPESAFE_API_KEY="your-key-here"
Instalar las dependencias
pip install 'cooksafe>=0.2.0,<0.3.0'
JsonCache reproduce las respuestas de API incluidas, así que los pasos de abajo se ejecutan
sin clave de API ni gasto alguno. Para hacer las solicitudes en vivo, define
TYPESAFE_API_KEY y elimina json_cache.json.
Crear el script
Empieza semantic_search.py con las importaciones y el 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"))
Paso 1: etiquetar cada línea con un ID
El documento de prueba son los Términos de Servicio de GitHub, divididos en 218 cláusulas, para que cada resultado de búsqueda apunte a una línea citable.
Añade a 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()
La caché evita descargas repetidas, y splitlines() deja una lista de 218 cadenas.
Ahora antepón a cada línea un ID corto y vuelve a unir las líneas en un solo documento. El modelo usa estos IDs para señalar su respuesta.
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 ahora se ve así:
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
Paso 2: preguntar dónde está la respuesta
Una pregunta Choice devuelve una probabilidad para cada opción. Usa los ids de línea como
opciones, y «elegir una opción» se convierte en «señalar una línea».
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))},
)
Las descripciones de las opciones son None porque el documento ya contiene el texto de cada
ID. La consulta va en instructions; el estado no cambia entre búsquedas.
Paso 3: comprobar si existe una respuesta
Las probabilidades de Choice siempre suman 1, así que alguna línea queda primera aunque el documento no responda a la pregunta. Solo con el orden no se puede distinguir una respuesta real de la línea irrelevante más cercana.
Así que haz una segunda pregunta, en la misma solicitud:
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",
),
)
A diferencia de las probabilidades de Choice, la probabilidad de Noul no depende de las demás opciones, así que puede caer cerca de cero cuando el documento no tiene respuesta.
Paso 4: enviar ambas preguntas en una sola solicitud
El método system_one responde a ambas preguntas en una sola pasada. El estado se envía una
vez, así que añadir la comprobación de existencia solo requiere una pequeña cantidad de salida
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),
)
La lista relevance guarda una puntuación por línea, en el orden del documento.
Paso 5: leer el resultado
Dos fragmentos de código local terminan el trabajo: verdict() convierte la probabilidad bruta
de exists en tres estados, con uno intermedio para respuestas parciales, y show() representa
relevance como un gráfico de barras para que el orden sea legible en una 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
Estos umbrales separan los ejemplos de abajo, pero ajústalos con tus propios documentos antes de usarlos en producción.
Paso 6: ejecutar la búsqueda
Haz dos preguntas que tienen respuesta directa, una que no tiene respuesta y una que tiene una respuesta parcial: cuatro en 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,
Qué significan las puntuaciones
Las dos primeras consultas devuelven respuestas directas y las líneas de origen necesarias para verificarlas.
Las otras dos muestran por qué importa la comprobación de existencia:
- Arbitraje: El orden da a la línea más cercana una puntuación de 0.86, pero
existses solo 0.14. La respuesta no está en el documento. - Permiso parental: La regla de edad queda primera, pero no responde si el permiso parental cambia la regla. El resultado es abordado parcialmente.
El orden te dice dónde mirar; la puntuación de exists te dice si el resultado responde a la
pregunta.
Pruébalo con tu propio documento
Abre el contrato etiquetado en el playground de TypeSafe para editar las preguntas
sobre el mismo texto. Para buscar en el tuyo, cambia la URL en fetch_document(); todas las
demás líneas del script parten de LINES.