Objetivo: ao final, o consultor identifica onde a busca semântica falha em contexto SAP, combina busca lexical e semântica, e conhece os ganhos medidos de contextual retrieval e reranking.
| O consultor procura | Exemplo | Semântica | BM25 |
|---|---|---|---|
| Código de mensagem | ME 083 |
Falha | Acha |
| Número de nota SAP | Note 2345678 |
Falha | Acha |
| Código de transação | ME21N, VA02 |
Falha | Acha |
| Nome de tabela | EKKO, EKPO |
Falha | Acha |
| Nome técnico de campo | MATNR, LIFNR |
Falha | Acha |
| Número de documento | 4500012345 |
Falha | Acha |
| Nome de objeto Z | ZMM_REL_PED |
Falha | Acha |
| "Como funciona a liberação de pedido?" | — | Acha | Falha se o documento usar outras palavras |
| "Qual o processo de bloqueio de fatura?" | — | Acha | Falha se o documento disser "retenção" |
As oito primeiras linhas são a razão de este módulo existir para a Wayon. Um assistente de documentação SAP que só tenha busca semântica falha na consulta mais frequente que o consultor faz: colar um código de erro e perguntar o que é.
E não é que a semântica erre por pouco. Ela pode devolver, com alta similaridade, o trecho sobre ME 084 — parecidíssimo em forma, e resposta errada.
from rank_bm25 import BM25Okapi
import numpy as np
# Índice lexical
bm25 = BM25Okapi([p.lower().split() for p in pedacos])
def buscar_hibrido(pergunta, k=20):
# Semântico
q = vo.embed([pergunta], model="voyage-4", input_type="query").embeddings[0]
sem = np.dot(vetores, q)
# Lexical
lex = bm25.get_scores(pergunta.lower().split())
# Normalizar cada lista para 0–1 antes de somar: as escalas não são comparáveis
def norm(x):
x = np.asarray(x, dtype=float)
return (x - x.min()) / (x.max() - x.min() + 1e-9)
combinado = 0.5 * norm(sem) + 0.5 * norm(lex)
return [pedacos[i] for i in np.argsort(combinado)[-k:][::-1]]
O detalhe que quebra implementações apressadas: as duas pontuações não são comparáveis em escala. Similaridade de cosseno vive entre −1 e 1; BM25 não tem teto. Somar direto faz o BM25 dominar. Normalizar antes é obrigatório.
O peso 0,5 / 0,5 é ponto de partida. Em acervo SAP, carregado de identificadores, subir o peso do BM25 costuma ajudar — e isso se mede, com o dataset e o grader do módulo 3.6, não se estima.
PROMPT_CONTEXTO = """<documento>
{documento}
</documento>
Aqui está o trecho que queremos situar dentro do documento acima:
<trecho>
{trecho}
</trecho>
Escreva uma frase curta (50 a 100 tokens) situando este trecho no documento, para
melhorar a recuperação em busca. Responda apenas com a frase."""
def contextualizar(documento: str, trecho: str) -> str:
r = client.messages.create(
model="claude-haiku-4-5", # tarefa simples, volume alto: modelo barato
max_tokens=150,
messages=[{"role": "user",
"content": PROMPT_CONTEXTO.format(documento=documento, trecho=trecho)}],
)
contexto = next(b.text for b in r.content if b.type == "text")
return f"{contexto.strip()}\n\n{trecho}"
Isso roda uma vez por pedaço, na indexação — não a cada consulta. Ainda assim, é uma chamada por pedaço: num acervo de 20 mil pedaços, são 20 mil chamadas. Duas coisas tornam isso viável:
| Configuração | Taxa de falha na recuperação | Redução vs. base |
|---|---|---|
| RAG base (embedding + BM25 comuns) | 5,7% | — |
| + Embedding contextualizado | 3,7% | −35% |
| + BM25 contextualizado também | 2,9% | −49% |
| + Reranking | 1,9% | −67% |
Esses números são de uma medição publicada pela Anthropic, em corpus dela — não são garantia para o acervo do Meridiano. Servem para dimensionar o esforço: cada estágio acrescenta trabalho e custo, e o retorno é conhecido em ordem de grandeza. Se o seu pipeline já está bom o suficiente para o caso, parar antes é uma decisão legítima.
resultados = vo.rerank(
query=pergunta,
documents=candidatos, # os 20 da busca híbrida
model="rerank-2.5",
top_k=5,
)
melhores = [r.document for r in resultados.results]
O reranker vê a pergunta e o pedaço juntos, o que a busca vetorial não faz — ela compara vetores calculados separadamente. Por isso ele é mais preciso e mais caro: roda só sobre os candidatos que a busca já filtrou.
Modelos de embedding contextualizado (voyage-context-4, 120 mil tokens de contexto) produzem o vetor do pedaço já considerando o documento inteiro, através de contextualized_embed() em vez de embed(). Isso elimina a etapa de gerar contexto com o Claude:
| Caminho | Etapas na indexação | Custo |
|---|---|---|
| Contextual retrieval manual | Fatiar → 1 chamada ao Claude por pedaço → embeddar | Chamada por pedaço, mitigada por cache |
| Embedding contextualizado | Fatiar → embeddar com contexto | Sem chamada ao Claude |
Menos peça, menos coisa para manter, menos coisa para quebrar. Vale medir os dois no seu acervo — mas comece pelo mais simples.
Regra Wayon Assistente de documentação técnica entregue a cliente SAP roda busca híbrida, nunca só semântica. Código de erro, nota SAP, transação, tabela e número de documento são a consulta mais frequente do consultor, e são exatamente onde a busca semântica falha. A escolha dos pesos entre lexical e semântico é medida com o dataset do módulo 3.6, não estimada — e o número medido vai na proposta, como na aula 3.6.5.
📖 Contextual Retrieval · Embeddings · Prompt caching — aula 3.7.4
Está no índice; o problema é a busca não o encontrar.
Não há filtragem — eles são vetorizados como qualquer texto.
Correto, e é o motivo de a semântica poder devolver ME 084 com alta similaridade.
Correto. Somar direto faz o BM25 dominar o resultado.
BM25 não produz vetores e não precisa produzir.
BM25 não devolve valor limitado, e é justamente essa a origem do problema.
35% é só o primeiro estágio; os outros dois somam mais.
Correto, e os estágios são cumulativos: −35%, −49%, −67%.
A falha medida cai a 1,9%, não abaixo de 1%.
Ele aceita qualquer lista de documentos; a razão é de custo e escala.
Correto. Rodá-lo sobre o acervo inteiro seria inviável.
Contextualização ajuda, mas não é pré-requisito do reranker.