Alguém do jurídico pergunta: “os termos dizem se posso ser suspenso sem aviso?”. O documento tem duzentas cláusulas. A resposta, se existir, é uma linha específica — e a pessoa precisa da linha, não de um resumo, porque vai citar.
Esse é um problema de busca dentro de um documento. Não há acervo para varrer: há um texto e uma pergunta em linguagem natural.
Por que a solução ingênua falha
A primeira tentativa costuma ser Ctrl+F. Falha porque a pergunta usa as
palavras do usuário (“me expulsar da plataforma”) e o documento usa as dele
(“suspender ou encerrar o acesso”).
A segunda tentativa é montar um índice vetorial por trecho. Funciona, mas é infraestrutura: fatiar, gerar embeddings, guardar, atualizar quando o documento muda. Para um contrato, é desproporcional.
A terceira é pedir ao LLM que devolva a cláusula. Aí aparece o problema que essa receita resolve de raiz: o modelo pode reescrever a cláusula em vez de citá-la, e uma citação levemente alterada num documento jurídico é pior que nenhuma. Além disso, quando a resposta não está no documento, o modelo tende a oferecer a linha mais parecida como se fosse resposta.
O desenho das perguntas
A receita tem três movimentos. Primeiro, marque cada linha com um id curto:
def id_da_linha(i: int) -> str:
return f"L{i:03d}"
DOCUMENTO = "\n".join(f"{id_da_linha(i)}| {linha}" for i, linha in enumerate(LINHAS))
O documento passa a carregar seus próprios rótulos, assim:
L052| Você é dono do seu conteúdo. Se publicar conteúdo que não criou, é responsável por...
L053| Você concede a nós e a outros usuários as licenças das seções D.4 a D.8...
L054| 4. Concessão de licença
Segundo, use os ids como opções de um Choice. Escolher uma opção passa a significar apontar uma linha, e a distribuição de probabilidades vira a relevância de cada linha:
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient
def pergunta_onde(consulta: str) -> Choice:
return Choice(
instructions=f'Qual linha do documento contém a resposta para: "{consulta}"?',
criteria={id_da_linha(i): None for i in range(len(LINHAS))},
)
def pergunta_existe(consulta: str) -> Noul:
return Noul(
instructions=f'Alguma linha do documento trata ou responde: "{consulta}"?',
criteria=NoulCriteria(
true="Pelo menos uma linha afirma ou implica diretamente a resposta",
false="Nenhuma linha do documento trata disso",
),
)
with TypeSafeClient() as client:
resposta = client.system_one(
state=DOCUMENTO,
questions={"onde": pergunta_onde(consulta), "existe": pergunta_existe(consulta)},
)
relevancia = resposta.answers["onde"].probabilities
existe = resposta.answers["existe"].noul
Repare no None nas descrições das opções: o texto de cada linha já está no
state, então descrever a opção seria repetição. E note que as duas perguntas vão
na mesma requisição — o documento é enviado uma vez.
O terceiro movimento é o que separa esta receita de uma busca comum. As probabilidades de um Choice somam 1 por construção, então alguma linha sempre fica em primeiro lugar, inclusive quando o documento não responde nada. O Noul de existência resolve isso porque não depende das opções: ele pode ficar perto de zero enquanto a melhor linha tem relevância alta.
O que o experimento oficial mediu
O cookbook usa os termos de serviço do GitHub, em inglês: 218 linhas, 43.980 caracteres, tudo pontuado em uma requisição. Os limiares adotados foram 0,70 para “respondido” e 0,35 para “ausente”. Os resultados publicados pela TypeSafe:
| Consulta | exists | Melhor linha | Leitura |
|---|---|---|---|
| Quem é dono do código que eu envio? | 0,98 | L052 com 0,95 | Respondido |
| O GitHub pode me suspender sem aviso? | 0,97 | L168 com 0,97 | Respondido |
| Sou obrigado a levar disputas à arbitragem? | 0,14 | 0,86 | Não está no documento |
| Menor de idade pode usar com autorização dos pais? | 0,46 | L029 com 0,90 | Parcialmente abordado |
As duas últimas linhas são a razão de existir do Noul. Na pergunta sobre
arbitragem, a melhor linha tem relevância 0,86 — alta o bastante para um sistema
ingênuo entregar como resposta — enquanto o exists de 0,14 diz que a resposta
não está ali. Na pergunta sobre menores, a regra de idade aparece com 0,90, mas ela
não diz se autorização dos pais muda a regra: parcialmente abordado.
A lição do cookbook cabe em uma frase: a ordenação diz onde olhar, o exists diz
se aquilo responde. O experimento declara rodar no jev-1.12, e o próprio texto
pede para calibrar os limiares nos seus documentos antes de produção.
O que muda em português
O documento do experimento é em inglês. A receita não tem nenhuma dependência de
idioma: ids são ids, e a consulta vai nas instructions em português se o seu
documento é português.
Há um ponto prático que o Brasil sente mais: contrato e termo de uso em português
costumam ter cláusulas longas e numeração própria (“Cláusula 7.2.1”). Vale usar a
numeração do próprio documento como id em vez de inventar L000, porque assim a
resposta já sai citável. E, como a TypeSafe declara que o inglês é a língua
principal de treino, teste com as suas cláusulas antes de confiar no limiar.
Onde isso quebra
- Acima de 255 linhas. É o teto de opções de um Choice. O cookbook sugere duas passagens: uma escolhe a janela, outra ordena dentro dela.
- Documento grande. Mesmo abaixo de 255 linhas, um documento inflado com anexos irrelevantes cai no modo de falha de state grande descrito em limites do jev-1.13.
- Linha errada por leitura literal. Se a consulta é ambígua, a resposta segue o que está escrito, não a intenção. Reescrever a consulta em termos do documento ajuda mais que insistir no mesmo texto.
- Datas e prazos. “A cláusula de rescisão vale depois de 90 dias?” mistura achar a linha com fazer conta de data. Ache a linha com esta receita e faça a conta no código.
Para achar o documento certo dentro de um acervo antes de entrar aqui, veja reranking de busca. O índice das receitas fica em receitas.
Perguntas frequentes
Como o modelo aponta uma linha específica?
Você numera as linhas com um id curto no início de cada uma e usa esses ids como as opções de uma pergunta Choice. Escolher uma opção passa a ser apontar uma linha, e as probabilidades do Choice dão a relevância de cada linha.
Por que preciso de uma segunda pergunta?
Porque as probabilidades de um Choice sempre somam 1, então alguma linha fica em primeiro lugar mesmo quando o documento não responde nada. Um Noul separado responde se existe resposta no documento, e esse valor não depende das opções.
Quantas linhas cabem por requisição?
Uma pergunta Choice aceita até 255 opções, então a receita cobre documentos de até 255 linhas por requisição. Acima disso, o cookbook sugere duas passagens: um Choice escolhe a janela de linhas e um segundo ordena dentro dela.
Que limiares o experimento usou?
No cookbook, exists a partir de 0,70 conta como respondido e abaixo de 0,35 como ausente; entre os dois, parcialmente abordado. O próprio texto recomenda calibrar esses cortes nos seus documentos antes de usar em produção.
Isso substitui busca vetorial?
Para um documento, resolve sem índice nenhum. Para um acervo grande, não: você continua precisando de uma etapa de busca rápida para achar o documento, e aí a receita de reranking é a companheira natural.