Information RetrievalSearchRAGLLMsNLPMachine Learning

Search Engine de Verdade: Fundamentos de Information Retrieval do Zero

Você treina um modelo, gera embeddings, monta um pipeline de RAG. Na demo funciona. Em produção, o LLM começa a alucinar sobre coisas que claramente estão nos seus documentos. O problema não é o LLM — é a busca. Os documentos certos simplesmente não estão chegando ao contexto.

Information Retrieval (IR) é o campo que estuda exatamente isso: dado um conjunto de documentos e uma query, como encontrar e ranquear os mais relevantes. É a fundação silenciosa de todo sistema de busca — do Google ao OpenSearch ao seu pipeline de RAG.

Este guia é orientado por problemas. Cada seção começa com algo que quebra — um modo de falha real em um sistema de busca — e mostra por que quebra antes de introduzir a solução. O objetivo é intuição, não receita.

O mapa completo do que vamos cobrir:

ConceitoO que é
Índice invertidoA estrutura de dados que torna a busca rápida possível — mapeia termos para os documentos que os contêm
Pipeline de análise de textoComo o texto bruto é limpo, tokenizado e normalizado antes da indexação
TF-IDFUma fórmula de ranking que pondera a frequência do termo pela raridade dele no corpus
BM25O algoritmo padrão de ranking lexical — lida melhor com comprimento de documento e saturação de termos do que o TF-IDF
Métricas de avaliaçãoP@k, Recall@k, MRR, NDCG — como medir se sua busca é realmente boa
Busca semânticaBusca baseada em embeddings que encontra documentos por significado, não só por correspondência de palavras
ANN (Approximate Nearest Neighbor)Algoritmos como HNSW que tornam a busca vetorial rápida o suficiente para milhões de documentos
Busca híbrida + RRFCombinar rankings lexical e semântico para que cada método cubra os pontos cegos do outro
HyDE + query expansionTécnicas assistidas por LLM que fecham o gap entre queries curtas e documentos longos
ChunkingDividir documentos em pedaços indexáveis — e por que o tamanho importa mais do que parece
Two-stage retrieval + rerankingUsar um retriever rápido para obter candidatos e um cross-encoder preciso para reordená-los
Filtros de metadadosFiltros fixos (tenant, data, status, ACL) que evitam resultados semanticamente certos mas operacionalmente errados
Métricas específicas para RAGContext Recall, Faithfulness, Answer Correctness — o que medir quando o consumidor é um LLM

No final, você não vai só saber como montar um pipeline de busca — vai saber onde olhar quando ele retorna lixo, e por que cada camada de um sistema RAG de produção existe.

Nota: Os exemplos de código são didáticos, otimizados para clareza. Sistemas reais de produção usam implementações muito mais sofisticadas. Falo sobre isso na última seção. Você pode rodar os exemplos célula a célula no Jupyter Notebook.


Problema 1: Você tem 10.000 documentos. Como achar o certo?

Imagine um corpus simples: notas de reuniões, documentação técnica, artigos de blog. O usuário digita uma query. Você precisa retornar os documentos mais relevantes.

A abordagem mais direta: percorrer todos os documentos e checar se contêm as palavras da query.

docs = [
    "Redes neurais aprendem representações hierárquicas dos dados",
    "Gradient descent minimiza a função de perda iterativamente",
    "Transformers usam atenção para capturar dependências de longo alcance",
    "Random forests combinam múltiplas árvores de decisão",
    # ... mais 9.996 documentos
]

def busca_linear(query: str, docs: list[str]) -> list[str]:
    query_lower = query.lower()
    return [doc for doc in docs if query_lower in doc.lower()]

resultados = busca_linear("redes neurais", docs)

Isso funciona para 10 documentos. Para 10 milhões — que é o tamanho de qualquer corpus sério — percorrer tudo a cada query é inviável. Com 10M documentos de 1KB cada, são 10GB de dados a varrer para cada busca.

Mas o problema vai além da performance. Essa busca não sabe quão relevante um documento é — só se contém ou não a query. Não há ranking. Não há noção de qual resultado é melhor.

O que precisamos: uma estrutura que permita encontrar documentos que contêm um termo sem percorrer todo o corpus, e uma forma de ranquear os resultados por relevância.


Conceito 1: O Índice Invertido

Um search engine não busca em documentos — ele busca num índice. O índice é construído uma vez (ou atualizado incrementalmente) e permite responder queries rapidamente.

A estrutura central é o índice invertido (inverted index): um mapeamento de termo → lista de documentos que o contêm.

termo           → documentos
───────────────────────────────
"neural"        → [doc_0, doc_7, doc_42, ...]
"gradient"      → [doc_1, doc_8, doc_19, ...]
"transformer"   → [doc_2, doc_7, doc_55, ...]
"árvore"        → [doc_3, doc_99, ...]

É a inversão do índice natural (documento → termos). Daí o nome.

from collections import defaultdict
import re

def tokenizar(texto: str) -> list[str]:
    # Minúsculas + apenas palavras alfanuméricas
    return re.findall(r'\b[a-záéíóúàâêôãõüç]+\b', texto.lower())

def construir_indice(docs: list[str]) -> dict[str, set[int]]:
    indice = defaultdict(set)
    for doc_id, doc in enumerate(docs):
        for termo in tokenizar(doc):
            indice[termo].add(doc_id)
    return dict(indice)

def buscar(query: str, indice: dict, docs: list[str]) -> list[str]:
    termos = tokenizar(query)
    # Interseção: documentos que contêm TODOS os termos
    if not termos:
        return []
    resultado = indice.get(termos[0], set())
    for termo in termos[1:]:
        resultado = resultado & indice.get(termo, set())
    return [docs[i] for i in resultado]

docs = [
    "Redes neurais aprendem representações hierárquicas",
    "Gradient descent otimiza redes neurais",
    "Transformers são redes neurais com atenção",
    "Random forests não usam gradientes",
]

indice = construir_indice(docs)
print(buscar("redes neurais", indice, docs))
# ['Redes neurais aprendem...', 'Gradient descent otimiza...', 'Transformers são...']

Agora a busca é O(1) por termo (lookup no dicionário) + O(k) para interseção das posting lists, onde k é o número de documentos que contêm o termo — muito menor que o corpus inteiro.

Mas surgiu um problema novo: os três documentos sobre redes neurais são igualmente relevantes para a query “redes neurais”? Intuitivamente não — alguns falam sobre o assunto, outros só mencionam. Precisamos de ranking.


Problema 2: Nem toda ocorrência vale o mesmo

Considere dois documentos sobre “machine learning”:

  • Doc A: “Machine learning é uma subárea de inteligência artificial. Machine learning usa dados para aprender padrões. Algoritmos de machine learning incluem redes neurais e árvores de decisão.”
  • Doc B: “Python é usado em machine learning, mas também em desenvolvimento web e automação.”

Para a query “machine learning”, Doc A claramente é mais relevante — o termo aparece 3 vezes e é o tema central. Doc B menciona uma vez, de passagem.

Frequência do termo (TF — Term Frequency): quantas vezes o termo aparece no documento.

Mas TF sozinho tem um problema: a palavra “de” aparece em quase todo documento. Alta frequência de “de” não indica relevância.

Frequência inversa de documento (IDF — Inverse Document Frequency): palavras que aparecem em poucos documentos são mais informativas.

IDF(termo) = log(N / df(termo))

onde:
  N = número total de documentos
  df = número de documentos que contêm o termo

“Machine learning” aparece em 100 de 10.000 docs → IDF = log(100) ≈ 4,6
”de” aparece em 9.999 de 10.000 docs → IDF ≈ 0,0001

Multiplicando os dois: TF-IDF.

import math
from collections import Counter

def calcular_tf(doc_tokens: list[str]) -> dict[str, float]:
    contagem = Counter(doc_tokens)
    total = len(doc_tokens)
    return {termo: freq / total for termo, freq in contagem.items()}

def calcular_idf(docs_tokens: list[list[str]]) -> dict[str, float]:
    N = len(docs_tokens)
    df = defaultdict(int)
    for tokens in docs_tokens:
        for termo in set(tokens):
            df[termo] += 1
    return {termo: math.log(N / freq) for termo, freq in df.items()}

def score_tfidf(query: str, doc: str, idf: dict) -> float:
    query_tokens = tokenizar(query)
    doc_tokens = tokenizar(doc)
    tf = calcular_tf(doc_tokens)
    return sum(tf.get(t, 0) * idf.get(t, 0) for t in query_tokens)

# Exemplo
docs = [
    "Machine learning é uma subárea de IA. Machine learning usa dados. Algoritmos de machine learning incluem redes neurais.",
    "Python é usado em machine learning, mas também em web e automação.",
    "Deep learning é uma subárea de machine learning com múltiplas camadas.",
]

docs_tokens = [tokenizar(d) for d in docs]
idf = calcular_idf(docs_tokens)

query = "machine learning"
scores = [(score_tfidf(query, doc, idf), doc[:50]) for doc in docs]
scores.sort(reverse=True)
for score, doc in scores:
    print(f"{score:.4f} | {doc}")

# 0.0812 | Machine learning é uma subárea de IA...   ← mais relevante
# 0.0271 | Deep learning é uma subárea de machine...
# 0.0108 | Python é usado em machine learning...     ← menos relevante

TF-IDF é o ranking padrão de busca por décadas. Mas ele tem limitações conhecidas:

  1. Normalização por comprimento é incompleta: nossa fórmula de TF divide pelo comprimento do documento, mas um doc longo que menciona “ML” repetidamente de passagem ainda pode superar um doc curto onde “ML” é o tema central. BM25 trata isso de forma mais robusta.
  2. TF cresce linearmente sem saturação: 10 ocorrências não é 10× mais relevante que 1, mas TF-IDF trata dessa forma.

Conceito 2: BM25 — O algoritmo padrão de ranking

BM25 (Best Matching 25) surgiu nos anos 90 e continua sendo a baseline padrão de recuperação lexical — amplamente usado em sistemas baseados em Lucene como OpenSearch e Elasticsearch.

BM25 resolve exatamente os problemas do TF-IDF com dois parâmetros:

  • k1 (tipicamente 1.2–2.0): controla a saturação da frequência do termo. Valores maiores deixam ocorrências repetidas continuar contribuindo; valores menores fazem o score saturar mais rápido. Ao contrário do TF, a contribuição nunca cresce linearmente sem limite.
  • b (tipicamente 0.75): controla a normalização por comprimento.
BM25(q, d) = Σ IDF(t) × [TF(t,d) × (k1 + 1)] / [TF(t,d) + k1 × (1 - b + b × |d|/avgdl)]

onde:
  |d|    = comprimento do documento
  avgdl  = comprimento médio dos documentos no corpus
class BM25:
    def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.docs_tokens = [tokenizar(d) for d in docs]
        self.N = len(docs)
        self.avgdl = sum(len(t) for t in self.docs_tokens) / self.N

        # IDF para cada termo
        df = defaultdict(int)
        for tokens in self.docs_tokens:
            for termo in set(tokens):
                df[termo] += 1
        self.idf = {
            t: math.log((self.N - freq + 0.5) / (freq + 0.5) + 1)
            for t, freq in df.items()
        }

    def score(self, query: str, doc_id: int) -> float:
        query_tokens = tokenizar(query)
        doc_tokens = self.docs_tokens[doc_id]
        dl = len(doc_tokens)
        tf_map = Counter(doc_tokens)

        total = 0.0
        for termo in query_tokens:
            if termo not in self.idf:
                continue
            tf = tf_map.get(termo, 0)
            idf = self.idf[termo]
            numerador = tf * (self.k1 + 1)
            denominador = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
            total += idf * (numerador / denominador)
        return total

    def buscar(self, query: str, docs: list[str], top_k: int = 5) -> list[tuple]:
        # Pontua todos os documentos por clareza. Um BM25 real usaria as posting
        # lists do índice invertido para pontuar apenas candidatos com os termos.
        scores = [(self.score(query, i), docs[i]) for i in range(self.N)]
        return sorted(scores, reverse=True)[:top_k]

A diferença prática entre TF-IDF e BM25 fica clara com documentos de comprimentos variados:

docs = [
    # Curto e focado
    "BM25 é o algoritmo de ranking padrão em search engines modernos.",
    # Longo com muitas menções, mas diluído
    "Este é um documento muito longo sobre muitas coisas. Fala de NLP, de IR, de machine learning, de Python, de estatística, de álgebra linear, de redes neurais. BM25 aparece aqui também, mas é um detalhe menor em meio a tudo isso. Continuamos falando de outros assuntos por muito mais parágrafos.",
    # Relevante mas sem mencionar exatamente "BM25"
    "Okapi BM25 estende o modelo probabilístico de Robertson e Spärck Jones.",
]

bm25 = BM25(docs)
resultados = bm25.buscar("BM25 ranking", docs)
for score, doc in resultados:
    print(f"{score:.4f} | {doc[:60]}...")
# O documento curto e focado rankeia acima do longo com muitas menções

Problema 3: Como saber se nossa busca é boa?

Construímos um sistema. Mas como sabemos se ele funciona bem? “Parece certo” não é uma métrica. Antes de adicionar mais complexidade — embeddings, busca híbrida, reranking — precisamos de uma forma de medir se cada camada nova realmente melhora o retrieval.

IR tem um conjunto de métricas de avaliação bem estabelecidas, todas baseadas num conceito simples: relevância julgada.

Para avaliar, precisamos de um test set: um conjunto de queries onde humanos julgaram quais documentos são relevantes. Com isso, comparamos o que nosso sistema retornou com o que deveria retornar.

Precision e Recall

Precision@k = (documentos relevantes nos top-k) / k
Recall@k    = (documentos relevantes nos top-k) / (total de relevantes)
def precision_at_k(retrieved: list, relevant: set, k: int) -> float:
    top_k = retrieved[:k]
    return len(set(top_k) & relevant) / k

def recall_at_k(retrieved: list, relevant: set, k: int) -> float:
    top_k = retrieved[:k]
    return len(set(top_k) & relevant) / len(relevant)

# Exemplo
retrieved = ["doc_2", "doc_5", "doc_1", "doc_8", "doc_3"]
relevant   = {"doc_1", "doc_2", "doc_4", "doc_7"}

print(f"P@3 = {precision_at_k(retrieved, relevant, 3):.2f}")  # 2/3 = 0.67
print(f"R@3 = {recall_at_k(retrieved, relevant, 3):.2f}")     # 2/4 = 0.50
print(f"P@5 = {precision_at_k(retrieved, relevant, 5):.2f}")  # 2/5 = 0.40
print(f"R@5 = {recall_at_k(retrieved, relevant, 5):.2f}")     # 2/4 = 0.50

Existe um trade-off natural: aumentar k aumenta recall (mais documentos relevantes capturados), mas geralmente diminui precision (mais irrelevantes incluídos).

Mean Reciprocal Rank (MRR)

Para casos onde só existe um documento correto (ex: busca de resposta factual), usamos MRR: qual a posição do primeiro resultado relevante?

def reciprocal_rank(retrieved: list, relevant: set) -> float:
    for i, doc in enumerate(retrieved, 1):
        if doc in relevant:
            return 1 / i
    return 0.0

def mean_reciprocal_rank(resultados: list[tuple[list, set]]) -> float:
    return sum(reciprocal_rank(r, rel) for r, rel in resultados) / len(resultados)

queries_resultados = [
    (["doc_3", "doc_1", "doc_2"], {"doc_1"}),  # RR = 1/2
    (["doc_5", "doc_4", "doc_2"], {"doc_4"}),  # RR = 1/2
    (["doc_1", "doc_2", "doc_3"], {"doc_1"}),  # RR = 1/1
]
print(f"MRR = {mean_reciprocal_rank(queries_resultados):.3f}")  # (0.5 + 0.5 + 1.0) / 3 ≈ 0.667

NDCG (Normalized Discounted Cumulative Gain)

MRR e Precision tratam relevância como binária (relevante/irrelevante). NDCG suporta graus de relevância (ex: muito relevante = 3, relevante = 2, marginal = 1, irrelevante = 0).

A ideia: um documento altamente relevante na posição 1 vale mais que na posição 5.

def dcg_at_k(scores: list[int], k: int) -> float:
    """scores[i] = grau de relevância do i-ésimo resultado"""
    return sum(s / math.log2(i + 2) for i, s in enumerate(scores[:k]))

def ndcg_at_k(retrieved_scores: list[int], ideal_scores: list[int], k: int) -> float:
    dcg = dcg_at_k(retrieved_scores, k)
    idcg = dcg_at_k(sorted(ideal_scores, reverse=True), k)
    return dcg / idcg if idcg > 0 else 0.0

# retrieved: relevâncias dos docs retornados na ordem
retrieved_scores = [3, 1, 2, 0, 3]
ideal_scores     = [3, 3, 2, 1, 0]  # melhor ordenação possível

print(f"NDCG@5 = {ndcg_at_k(retrieved_scores, ideal_scores, 5):.3f}")

Para RAG especificamente: Recall@k costuma ser a primeira métrica a otimizar — a resposta não pode ser fundamentada se a evidência nunca chega ao LLM. Mas precision também importa: chunks irrelevantes ou conflitantes podem diluir o contexto e induzir respostas erradas, mesmo quando o chunk certo está presente.


Problema 4: “Carro” e “automóvel” — busca lexical não entende sinônimos

BM25 é poderoso, mas tem uma limitação fundamental: opera na superfície textual. Ele não sabe que “carro” e “automóvel” significam a mesma coisa. Não sabe que “ML engineer” e “engenheiro de machine learning” são equivalentes.

docs = [
    "Como alugar um automóvel no Brasil",
    "Guia de manutenção do seu veículo",
    "Os melhores carros elétricos de 2024",
]

bm25 = BM25(docs)
resultados = bm25.buscar("alugar carro")
# "Como alugar um automóvel" rankeia BAIXO — contém "alugar" mas não "carro"
# "Os melhores carros elétricos" rankeia ALTO — contém "carros" mas não "alugar"

Esse problema se chama vocabulary mismatch — o vocabulário da query não coincide com o do documento.

A solução: em vez de comparar palavras, comparar significados — representados como vetores em um espaço semântico.


Conceito 3: Busca Semântica com Embeddings

Um embedding é um vetor de números reais que representa o significado de um texto. Modelos como sentence-transformers são treinados para que textos semanticamente similares produzam vetores próximos no espaço vetorial.

Nota sobre modelos de embedding: neuralmind/bert-base-portuguese-cased é usado aqui como exemplo. Em produção, prefira modelos treinados especificamente para retrieval — como E5, BGE ou BGE-M3 — que usam treinamento contrastivo em pares query-documento e costumam performar significativamente melhor em benchmarks de IR como BEIR e MTEB.

from sentence_transformers import SentenceTransformer
import numpy as np

modelo = SentenceTransformer('neuralmind/bert-base-portuguese-cased')

docs = [
    "Como alugar um automóvel no Brasil",
    "Guia de manutenção do seu veículo",
    "Os melhores carros elétricos de 2024",
    "Transformers revolucionaram o processamento de linguagem natural",
]

# Indexação: gera embeddings para todos os docs uma vez
doc_embeddings = modelo.encode(docs)  # shape: (4, 768)

def busca_semantica(query: str, top_k: int = 3) -> list[tuple]:
    query_embedding = modelo.encode([query])[0]
    # Similaridade de cosseno. Para vetores normalizados em L2, isso é equivalente
    # ao produto escalar — por isso sistemas de produção (FAISS, OpenSearch)
    # normalizam vetores no índice e usam inner product para busca ANN rápida.
    similaridades = np.dot(doc_embeddings, query_embedding) / (
        np.linalg.norm(doc_embeddings, axis=1) * np.linalg.norm(query_embedding)
    )
    indices = np.argsort(similaridades)[::-1][:top_k]
    return [(similaridades[i], docs[i]) for i in indices]

resultados = busca_semantica("alugar carro")
# Agora "Como alugar um automóvel" aparece no topo
# mesmo sem conter a palavra "carro"

A busca semântica captura intenção e sinônimos. Mas tem suas próprias fraquezas:

Quando a busca lexical é melhor:

  • Queries com termos técnicos exatos: NullPointerException, CVE-2024-1234, config.yaml
  • Nomes próprios e siglas: GPT-4, BERT, Fernando Wittmann
  • Buscas específicas de produto/código: SELECT * FROM users WHERE id = 42

Quando a busca semântica é melhor:

  • Queries em linguagem natural: “como faço para…?”
  • Sinônimos e paráfrases
  • Queries multilíngues (pergunta em PT, doc em EN)
  • Intenção sem palavras-chave específicas

Conceito 4: Busca Híbrida — o melhor dos dois mundos

Em produção, raramente usamos só um método. A abordagem moderna combina BM25 + busca semântica via fusão de rankings.

O método mais simples e eficaz é Reciprocal Rank Fusion (RRF):

RRF_score(d) = Σ 1 / (k + rank_i(d))

onde rank_i(d) é a posição do documento d no ranking i
e k é uma constante (tipicamente 60)
def reciprocal_rank_fusion(
    rankings: list[list[int]],
    k: int = 60
) -> list[tuple[int, float]]:
    """
    rankings: lista de listas de doc_ids, em ordem de relevância
    retorna: lista (doc_id, score) ordenada por score
    """
    scores = defaultdict(float)
    for ranking in rankings:
        for posicao, doc_id in enumerate(ranking, 1):
            scores[doc_id] += 1.0 / (k + posicao)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# Exemplo: combinar BM25 e busca semântica
def busca_hibrida(query: str, docs: list[str], top_k: int = 5):
    # Ranking BM25
    bm25 = BM25(docs)
    bm25_scores = [(bm25.score(query, i), i) for i in range(len(docs))]
    bm25_ranking = [i for _, i in sorted(bm25_scores, reverse=True)]

    # Ranking semântico (simplificado — em prod usa ANN)
    query_emb = modelo.encode([query])[0]
    doc_embs = modelo.encode(docs)
    sims = np.dot(doc_embs, query_emb) / (
        np.linalg.norm(doc_embs, axis=1) * np.linalg.norm(query_emb)
    )
    semantico_ranking = list(np.argsort(sims)[::-1])

    # Fusão RRF
    fused = reciprocal_rank_fusion([bm25_ranking, semantico_ranking])
    return [(docs[doc_id], score) for doc_id, score in fused[:top_k]]

Pesquisas empíricas consistentemente mostram que busca híbrida supera qualquer método isolado em benchmarks de IR. A intuição: os dois métodos erram em casos diferentes, e a fusão cancela os erros. Dito isso, busca híbrida não salva um modelo de embedding ruim, texto OCR ruidoso ou chunking inadequado — ela amplifica a qualidade que cada branch já tem.


O Motor por Baixo: Approximate Nearest Neighbor (ANN)

Há um problema prático escondido na busca semântica: para encontrar os k vetores mais similares a uma query, precisaríamos calcular a similaridade com todos os N documentos — O(N × d), onde d é a dimensão do embedding (tipicamente 768–1536).

Para 10M documentos com embeddings de 768 dimensões, isso é ~7,68 bilhões de operações por query. Inviável.

A solução: Approximate Nearest Neighbor (ANN) — algoritmos que encontram os k vizinhos mais próximos aproximadamente, com um pequeno sacrifício de precisão em troca de velocidade absurda.

Os mais usados:

  • HNSW (Hierarchical Navigable Small World): grafo hierárquico de navegação. Padrão em sistemas modernos. Comportamento sublinear na prática — próximo de O(log N) — mas latência e memória reais dependem bastante dos parâmetros do índice, dimensão dos vetores e target de recall.
  • IVF (Inverted File Index): divide o espaço vetorial em clusters, busca apenas nos clusters mais próximos.
  • Product Quantization: comprime vetores para reduzir memória.
Brute force:  O(N × d)   — inviável para N > 100k
HNSW:         ~O(log N)  — rápido na prática; bilhões de vetores geralmente
                           exigem infraestrutura distribuída ou quantização

Bibliotecas como faiss (Meta) e hnswlib implementam esses algoritmos. OpenSearch e Elasticsearch têm suporte nativo via plugins k-NN.

import faiss
import numpy as np

d = 128  # dimensão dos embeddings
N = 100_000

# Gerar embeddings de exemplo
embeddings = np.random.rand(N, d).astype('float32')
faiss.normalize_L2(embeddings)  # normaliza para similaridade de cosseno

# Construir índice HNSW
index = faiss.IndexHNSWFlat(d, 32)  # 32 = número de conexões por nó
index.add(embeddings)

# Busca: encontra os 10 mais similares à query
query = np.random.rand(1, d).astype('float32')
faiss.normalize_L2(query)

distances, indices = index.search(query, k=10)
# Milliseconds, não segundos

Conceito 5: Como documentos são processados antes da indexação

Antes de indexar, todo search engine aplica uma pipeline de análise de texto que transforma o documento bruto em tokens indexáveis. Cada etapa tem um propósito:

texto bruto → tokenização → normalização → stopwords → stemming/lemmatização → índice
import re
from typing import Optional

STOPWORDS_PT = {
    'a', 'e', 'o', 'de', 'do', 'da', 'em', 'um', 'uma', 'para',
    'com', 'que', 'se', 'não', 'na', 'no', 'por', 'mais', 'os',
    'as', 'dos', 'das', 'ao', 'à', 'é', 'são', 'foi', 'ser'
}

def analisar_texto(texto: str, remover_stopwords: bool = True) -> list[str]:
    # 1. Minúsculas
    texto = texto.lower()

    # 2. Normalizar acentos (simplificado)
    mapa = str.maketrans('áéíóúàâêôãõüç', 'aeiouaaeoaouc')
    texto = texto.translate(mapa)

    # 3. Tokenizar
    tokens = re.findall(r'\b\w+\b', texto)

    # 4. Remover stopwords
    if remover_stopwords:
        tokens = [t for t in tokens if t not in STOPWORDS_PT]

    # 5. Stemming simples (sufixos comuns em PT)
    stems = []
    for token in tokens:
        if token.endswith('ando') or token.endswith('endo'):
            token = token[:-4]  # "aprendendo" → "aprend"
        elif token.endswith('ção') or token.endswith('cao'):
            token = token[:-3] + 'c'
        stems.append(token)

    return stems

print(analisar_texto("As redes neurais estão aprendendo representações dos dados"))
# ['redes', 'neurais', 'aprend', 'representac', 'dados']

Stemming vs Lemmatização:

  • Stemming: corta sufixos mecanicamente. Rápido, mas impreciso ("correndo""corr").
  • Lemmatização: usa dicionário para encontrar a forma base. Preciso, mas mais lento ("correndo""correr").

Em produção, a escolha depende do idioma e do trade-off performance/qualidade.


Conectando Tudo: IR em RAG e Agentes com LLMs

Agora que você entende os fundamentos, vamos ver como isso se conecta com sistemas modernos de LLM.

RAG (Retrieval-Augmented Generation)

RAG é essencialmente IR + geração de texto:

query do usuário

[RETRIEVAL] → top-k documentos relevantes

[AUGMENTATION] → query + documentos montam o contexto

[GENERATION] → LLM gera resposta baseada no contexto

O gargalo é quase sempre o retrieval. Se os documentos certos não chegam ao LLM, a geração não tem como estar correta — e o LLM pode alucinar ou simplesmente responder “não sei.”

def pipeline_rag_simples(
    query: str,
    corpus: list[str],
    llm_client,
    top_k: int = 5
) -> str:
    # 1. Retrieval: encontra documentos relevantes
    bm25 = BM25(corpus)
    scores = [(bm25.score(query, i), i) for i in range(len(corpus))]
    top_indices = [i for _, i in sorted(scores, reverse=True)[:top_k]]
    documentos = [corpus[i] for i in top_indices]

    # 2. Augmentation: monta o contexto
    contexto = "\n\n".join(f"[{i+1}] {doc}" for i, doc in enumerate(documentos))
    prompt = f"""Baseado nos documentos abaixo, responda: {query}

Documentos:
{contexto}

Resposta:"""

    # 3. Generation
    return llm_client.generate(prompt)

Query Rewriting e HyDE

LLMs podem melhorar o retrieval antes de buscar:

Query Expansion: expandir a query com sinônimos e termos relacionados.

def expandir_query(query: str, llm_client) -> str:
    prompt = f"""Dado a query de busca: "{query}"
Gere uma versão expandida com sinônimos e termos relacionados.
Retorne apenas a query expandida, sem explicações.
Exemplo: "carro" → "carro automóvel veículo transporte"
"""
    return llm_client.generate(prompt)

HyDE (Hypothetical Document Embeddings): em vez de embedar a query diretamente, pede ao LLM para gerar um documento hipotético que responderia a query, e embeda esse documento. Funciona porque queries de usuários são curtas, informais e fragmentadas, enquanto os documentos do corpus são mais longos e escritos em outro registro. Gerar uma resposta hipotética fecha esse gap distribucional — o embedding resultante fica mais próximo dos documentos reais no espaço vetorial.

def hyde(query: str, llm_client, modelo_embedding) -> np.ndarray:
    # Gera documento hipotético
    prompt = f"Escreva um parágrafo que responda diretamente: {query}"
    doc_hipotetico = llm_client.generate(prompt)

    # Embeda o documento hipotético, não a query
    return modelo_embedding.encode([doc_hipotetico])[0]

Agentes com ferramentas de busca

Em sistemas de agentes, o search engine vira uma ferramenta. O agente decide quando e como buscar:

ferramentas = [
    {
        "name": "buscar_documentos",
        "description": "Busca documentos relevantes na base de conhecimento",
        "parameters": {
            "query": "string — o que você quer encontrar",
            "top_k": "int — quantos documentos retornar (padrão: 5)",
            "filtros": "dict — filtros opcionais por metadados (data, autor, tipo)"
        }
    },
    {
        "name": "buscar_codigo",
        "description": "Busca trechos de código no repositório",
        "parameters": {
            "query": "string — descrição do código ou função procurada"
        }
    }
]

Nesse contexto, a qualidade do retrieval afeta diretamente a capacidade do agente de raciocinar e agir corretamente. Um agente que busca mal, raciocina mal.

Chunking: dividindo documentos para indexação

Documentos longos precisam ser divididos em chunks antes de indexar. Isso afeta profundamente a qualidade do retrieval:

def chunkar_por_sentenca(
    texto: str,
    max_tokens: int = 512,
    overlap: int = 50
) -> list[str]:
    """
    Divide texto em chunks respeitando sentenças,
    com overlap para não perder contexto nas bordas.
    """
    sentencas = texto.split('. ')
    chunks = []
    chunk_atual = []
    tokens_atuais = 0

    for sentenca in sentencas:
        tokens_sentenca = len(sentenca.split())
        if tokens_atuais + tokens_sentenca > max_tokens and chunk_atual:
            chunks.append('. '.join(chunk_atual) + '.')
            # Overlap: mantém as últimas sentenças
            palavras_overlap = ' '.join(chunk_atual[-2:]).split()
            chunk_atual = [' '.join(palavras_overlap[-overlap:])]
            tokens_atuais = overlap
        chunk_atual.append(sentenca)
        tokens_atuais += tokens_sentenca

    if chunk_atual:
        chunks.append('. '.join(chunk_atual))

    return chunks

Tamanho do chunk vs qualidade:

  • Chunks muito pequenos (< 100 tokens): perdem contexto, dificultam a geração
  • Chunks muito grandes (> 1000 tokens): diluem o sinal de relevância
  • Ponto de partida: 256–512 tokens, com overlap de 10–20%

O tamanho ideal depende da janela de contexto do modelo de embedding, da estrutura do documento e do tipo de query. Trate como ponto de partida e valide com métricas de retrieval — não cargo-culte o número.


Re-ranking: refinar depois de recuperar

Um padrão comum em produção é o two-stage retrieval:

  1. Candidate retrieval: busca rápida (BM25 ou ANN) retorna top-100 candidatos
  2. Re-ranking: um modelo mais preciso (cross-encoder) reordena os top-100
query → [BM25/ANN] → top-100 candidatos → [Cross-Encoder] → top-10 reranqueados

Por que dois estágios? Cross-encoders analisam query e documento juntos — atenção completa sobre ambos. São muito mais precisos que bi-encoders (embeddings separados), mas também muito mais lentos. Usá-los em cima de 100 candidatos, não em 10 milhões, é viável.

from sentence_transformers import CrossEncoder

# Cross-encoder para re-ranking
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def two_stage_retrieval(query: str, candidatos: list[str], top_k: int = 10):
    # Stage 1: candidatos já vêm do BM25/ANN

    # Stage 2: re-ranking com cross-encoder
    pares = [[query, doc] for doc in candidatos]
    scores = reranker.predict(pares)

    resultado = sorted(zip(scores, candidatos), reverse=True)
    return [doc for _, doc in resultado[:top_k]]

Problema 5: Semanticamente certo, operacionalmente errado

Imagine um sistema de RAG sobre documentação interna de uma empresa. Um usuário pergunta sobre a política de férias. A busca semântica retorna chunks altamente relevantes — mas são da política de 2021, já que a versão de 2024 usa redação diferente e está em outra pasta.

O retrieval foi semanticamente correto. A resposta está errada.

Essa é uma classe de falha que ranking sozinho não resolve. A solução é filtragem por metadados: associar atributos estruturados a cada documento no momento da indexação e aplicar filtros fixos antes ou depois do ranking.

from dataclasses import dataclass

@dataclass
class Documento:
    id: str
    conteudo: str
    metadados: dict  # fonte, data, tenant, tipo_doc, versao, status...

corpus = [
    Documento("p1", "Funcionários têm direito a 15 dias de férias por ano.", {
        "tipo_doc": "politica", "status": "ativo", "versao": "2024", "tenant": "acme"
    }),
    Documento("p2", "Funcionários têm direito a 10 dias de férias por ano.", {
        "tipo_doc": "politica", "status": "deprecated", "versao": "2021", "tenant": "acme"
    }),
    Documento("p3", "Política de férias para prestadores de serviço.", {
        "tipo_doc": "politica", "status": "ativo", "versao": "2024", "tenant": "globex"
    }),
]

def busca_com_filtros(query: str, docs: list[Documento], filtros: dict, top_k: int = 5):
    candidatos = [
        d for d in docs
        if all(d.metadados.get(k) == v for k, v in filtros.items())
    ]
    bm25 = BM25([d.conteudo for d in candidatos])
    scores = [(bm25.score(query, i), candidatos[i]) for i in range(len(candidatos))]
    return [doc for _, doc in sorted(scores, reverse=True)[:top_k]]

resultados = busca_com_filtros(
    "dias de férias",
    corpus,
    filtros={"tenant": "acme", "status": "ativo"}
)
# Retorna apenas a política ativa de 2024 para o tenant correto

Campos de metadados comuns em RAG de produção: tenant, permissoes_usuario, tipo_doc, status (ativo/deprecated), data, fonte, idioma, versao_produto. Sem eles, você pode recuperar documentos semanticamente relevantes mas operacionalmente errados — dados do cliente errado, políticas desatualizadas ou conteúdo que o usuário não tem permissão de ver.


Métricas específicas para RAG

As métricas genéricas de IR (P@k, Recall@k, MRR, NDCG) medem se documentos relevantes foram recuperados. Em RAG, precisamos também medir se os chunks recuperados realmente sustentam uma geração correta.

MétricaO que mede
Context RecallRecuperamos os chunks que contêm a evidência da resposta?
Context PrecisionOs chunks recuperados são realmente úteis para a resposta?
FaithfulnessA resposta gerada está ancorada no contexto recuperado?
Answer CorrectnessA resposta final está factualmente correta?
Citation AccuracyAs citações apontam para os chunks que realmente suportam a afirmação?

Frameworks como RAGAS automatizam boa parte dessas avaliações usando um LLM como juiz. A distinção-chave do IR clássico: um documento pode ser relevante mas não conter evidência suficiente. Um chunk que menciona “política de férias” de passagem não ajuda o LLM a responder “quantos dias tenho direito”. Context Recall mede especificamente se o chunk com a evidência chegou ao top-k — por isso costuma ser a primeira métrica a otimizar num sistema de RAG.


O que diferencia o didático do real

Os exemplos deste artigo são intencionalmente simplificados. Sistemas reais de produção — OpenSearch, Elasticsearch, Weaviate, Qdrant — resolvem problemas adicionais que seriam artigos por si só:

AspectoDidáticoProdução
EscalaIn-memory, MBDistribuído, TB+
IndexaçãoSíncrona, rebuild totalIncremental, online
ANNFAISS/HNSW em memóriaFAISS/Qdrant/Weaviate/OpenSearch com tuning, persistência, sharding, filtros
TextoTokenizador simplesAnalyzers por idioma, stemmer, synonyms
RankingBM25 padrãoBM25 + learning-to-rank
ResiliênciaSem fault toleranceReplicação, sharding
SegurançaSem autenticaçãoRBAC, field-level security
MonitoramentoNenhumLatência p99, relevância drift

Quando você usa OpenSearch ou Elasticsearch, BM25 está lá — só que implementado em Java com décadas de otimizações. Quando você usa Weaviate ou Qdrant, HNSW está lá — implementado em Go/Rust com memória gerenciada.

O ponto de entender os fundamentos: quando seu sistema de RAG retorna lixo, você sabe onde olhar. Quando as métricas de precision@k caem, você sabe como investigar. Quando o usuário diz “a busca não encontra X”, você sabe se é problema de vocabulário (BM25), de embedding (semântica), de chunking, ou de análise de texto.


Resumo: a trilha de IR

Problema                         →  Solução introduzida
────────────────────────────────────────────────────────────────
Busca O(N) lenta                 →  Índice invertido
Sem ranking                      →  TF-IDF
Doc comprimento/saturação        →  BM25
Como medir qualidade?            →  P@k, Recall@k, MRR, NDCG
Vocabulary mismatch              →  Embeddings + similaridade de cosseno
ANN lento para escala            →  HNSW / FAISS
Lexical vs Semântico             →  Busca híbrida + RRF
Queries curtas vs docs           →  HyDE, query expansion
Top-100 → Top-10 precisão        →  Two-stage + cross-encoder
Doc certo mas operac. errado     →  Filtros de metadados (tenant, status, data, ACL)
O retrieval ajudou o LLM?        →  Métricas RAG (Context Recall, Faithfulness)

O campo de Information Retrieval tem 60+ anos de pesquisa. LLMs são novos; busca não é. Entender IR bem é o que separa pipelines de RAG que funcionam em demo dos que funcionam em produção.


Checklist de debugging de retrieval

Quando o retrieval quebra em produção, a maioria das falhas se encaixa em um desses padrões:

SintomaCausa provávelO que inspecionar
Termo exato não encontradoProblema no analyzer/tokenizaçãoToken stream, stemming, lista de stopwords
Sinônimos não encontradosVocabulary mismatchEmbeddings, busca híbrida, expansão de sinônimos
Docs antigos ou deprecated recuperadosFalta filtro de freshness/statusFiltros de metadados, campo status
Docs do tenant/cliente errado retornadosBug em permissão ou filtroFiltros ACL, isolamento de tenant
Chunks relacionados mas resposta erradaChunk não contém a evidênciaFronteiras do chunk, tamanho, métrica Context Recall
Top resultado parece certo mas LLM erraProblema de geração ou contextoPrompt, métrica de faithfulness, chunks conflitantes
Bons resultados em teste, ruim em prodShift de distribuiçãoQuery logs, mismatch de modelo de embedding, índice desatualizado

Para ir além

  • Notebook executável: implementação completa dos conceitos acima, disponível como Jupyter Notebook.
  • Benchmark BEIR: suite de avaliação de IR com 18 datasets heterogêneos — o padrão para comparar sistemas de retrieval.
  • Artigo BM25: Robertson & Zaragoza (2009), “The Probabilistic Relevance Framework: BM25 and Beyond.”
  • MTEB: Massive Text Embedding Benchmark — avalia modelos de embedding em tarefas de retrieval.
  • ColBERT: late interaction — alternativa eficiente aos cross-encoders que mantém precisão com latência razoável.
  • SPLADE e learned sparse models: modelos neurais que produzem representações esparsas com expansão de vocabulário — combinando a interpretabilidade léxica do BM25 com cobertura semântica. Vale explorar para corpora de domínio específico.