MÓDULO 14 · CONCEITO 07 DE 12

Search System — inverted index, sharding, ranking e latência sub-100ms

Design de um sistema de busca em escala — não o Google inteiro, mas um buscador robusto para um catálogo de 1 bilhão de documentos. O problema central: como devolver os 10 resultados mais relevantes para qualquer query de texto em menos de 100ms, mantendo o índice atualizado em tempo quase real. Inverted index como estrutura de dados fundamental. Sharding por documento vs por termo. TF-IDF e BM25 como ranking baseline. Segmentos imutáveis e merge policy à la Lucene. Quando construir o próprio vs usar Elasticsearch.

Tempo de leitura ~35 min Pré-requisito 06 · Messaging System · 02 · Capacity Estimation Próximo 08 · Notifications at Scale →

Em 1999, Doug Cutting trabalhava como engenheiro freelancer e começou a escrever, num cubículo emprestado, uma biblioteca de busca em Java porque acreditava que o mundo precisava de uma implementação open-source madura de inverted index — todas as alternativas da época eram fechadas ou acadêmicas demais. Ele chamou de Lucene, juntando o sobrenome da esposa com o "Lu" do meio do nome dela. Cinco anos depois, doou o código para a Apache Foundation. Em 2010, Shay Banon, um engenheiro israelense que estava desempregado tentando construir um app de receitas para sua esposa, percebeu que Lucene resolvia indexação mas não distribuição — então embrulhou Lucene numa camada de rede e cluster management, e batizou de Elasticsearch. Hoje, praticamente toda função de busca em produto SaaS no mundo é Lucene rodando em algum lugar — Elasticsearch, OpenSearch, Solr, ou diretamente. Entender search systems é, em grande parte, entender por que essas decisões de design de 1999 ainda dominam.

O que torna um sistema de busca diferente de uma consulta SQL com LIKE '%foo%' é a estrutura de dados. Um LIKE num banco relacional faz full scan de cada linha; para 1 bilhão de documentos, isso é proibitivo independentemente do hardware. A solução fundamental — descoberta nas décadas de 1960-70 em sistemas de bibliotecas e refinada por motores como AltaVista nos anos 90 — é o inverted index: em vez de mapear documento → palavras, mapear palavra → documentos. Essa inversão transforma uma operação O(N×M) num lookup O(1) na palavra seguido de uma intersecção O(K) entre listas de IDs. O resto do sistema de busca — distribuição, ranking, atualização em tempo real, snippets — são todos otimizações e extensões em torno dessa ideia central.

Requisitos e estimation

Requisitos funcionais:

Requisitos não-funcionais:

# Estimation — catálogo de 1B documentos

# Tamanho de um documento médio: 2KB de texto + 1KB de metadados = 3KB
# Storage bruto dos documentos: 1B × 3KB = 3TB
# Inverted index é tipicamente 30-50% do tamanho dos documentos (depende de compressão)
# Index size: ~1TB

# Vocabulário (termos únicos): ~10M termos em corpus diverso
# Postings list média por termo: 1B docs × probabilidade média de ocorrência
# Termos raros: dezenas de postings; termos comuns ("o", "de"): bilhões
# Distribuição segue Zipf — 100 termos mais comuns representam ~50% das ocorrências

# Capacity por shard:
# Um shard Lucene/Elasticsearch saudável: 10-50GB de índice
# 1TB de índice / 30GB por shard = ~35 shards
# Com replicação (RF=3 para alta disponibilidade): 105 shards físicos
# Hardware: 105 shards × 32GB RAM cada = ~30 servidores de 128GB

# Latência target (P99 <100ms) com scatter-gather:
# 35 shards consultados em paralelo, latência limitada pelo shard mais lento
# Cada shard: lookup no inverted index (~5ms) + ranking dos top-K (~10ms) = 15ms
# Coordenador: agregação + re-ranking final (~5ms) + serialização (~5ms) = 10ms
# Total: 15ms (shard) + 10ms (coord) + rede (10ms) = 35ms P50; P99 ~80ms

# Throughput:
# 10k QPS / 35 shards = ~285 QPS por shard
# Lucene single-shard suporta 1000+ QPS em hardware moderno → folga de 3.5x

# Indexação:
# 100M novos docs/dia = ~1200 docs/s sustentado, pico 5000 docs/s
# Lucene single-shard: ~10k docs/s sustentado → folga confortável
# Refresh interval de 1s = compromisso entre near-real-time e overhead de I/O

Inverted index — a estrutura fundamental

O inverted index mapeia termo → lista de documentos que contêm o termo. A lista, chamada de postings list, geralmente armazena também a posição do termo no documento (para queries de frase) e a frequência (para ranking). É a inversão dessa estrutura — em vez do natural "para cada documento, quais palavras" — que torna a busca tratável em escala.

# Construção conceitual do inverted index:

# Documentos:
# doc_1: "the quick brown fox"
# doc_2: "the lazy brown dog"
# doc_3: "quick fox jumps over lazy dog"

# Pipeline de tokenização:
# 1. Lowercase: "the quick brown fox"
# 2. Tokenize (split por whitespace + pontuação): [the, quick, brown, fox]
# 3. Stop words (remover "the", "a", "of"...): [quick, brown, fox]
# 4. Stemming (Porter, Snowball): [quick, brown, fox] (sem mudança aqui)
#    "jumps" → "jump"; "running" → "run"; "cats" → "cat"

# Inverted index resultante:
# {
#   "quick":  [(doc_1, freq=1, pos=[1]), (doc_3, freq=1, pos=[0])],
#   "brown":  [(doc_1, freq=1, pos=[2]), (doc_2, freq=1, pos=[2])],
#   "fox":    [(doc_1, freq=1, pos=[3]), (doc_3, freq=1, pos=[1])],
#   "lazy":   [(doc_2, freq=1, pos=[1]), (doc_3, freq=1, pos=[4])],
#   "dog":    [(doc_2, freq=1, pos=[3]), (doc_3, freq=1, pos=[5])],
#   "jump":   [(doc_3, freq=1, pos=[2])],
#   "over":   [(doc_3, freq=1, pos=[3])]
# }

# Query "quick fox" (AND implícito):
# 1. Lookup postings("quick") → [doc_1, doc_3]
# 2. Lookup postings("fox")   → [doc_1, doc_3]
# 3. Intersecção (merge sorted) → [doc_1, doc_3]
# 4. Ranking dos candidatos (TF-IDF/BM25) → ordenar por score
# 5. Retornar top-K

# Por que postings são sempre sorted por doc_id:
# - Intersecção de N listas sorted é O(M*N) com merge linear
# - Permite skip lists para pular grandes ranges quando uma lista é muito menor
# - Compressão delta-encoding: armazenar diferenças entre IDs (1, 5, 12 → 1, 4, 7)
#   reduz storage em ~70% para postings densas

A maior parte da inteligência de um engine de busca está em como esse índice é armazenado, comprimido e atualizado. Lucene usa uma estrutura chamada FST (Finite State Transducer) para o term dictionary — permite lookup O(1) mesmo com 100M termos únicos sem carregar tudo em memória. Os postings ficam em arquivos separados, lidos por mmap.

# Lucene storage layout (simplificado):

# Term dictionary (em RAM, via mmap do .tim/.tip):
# - FST mapeia "brown" → offset 0x4A2C no arquivo de postings
# - FST é compacto: 50M termos cabem em ~500MB RAM

# Postings file (.doc, .pos, .pay):
# - Para cada termo: lista comprimida de (doc_id, freq, positions)
# - Delta encoding + varint para doc_ids
# - Frame-of-reference (FOR) + PFOR-Delta para compressão eficiente
# - Block-encoded: blocos de 128 docs comprimidos juntos → cache-friendly

# Stored fields (.fdt):
# - O texto original ou metadata do documento (para snippets/retrieval)
# - Comprimido com LZ4 ou Deflate
# - Stored != indexed: pode-se indexar sem armazenar e vice-versa

# DocValues (.dvd):
# - Column-store por campo, otimizado para sorting/aggregations
# - Ex: sort by price, aggregate by category
# - Disjunto do inverted index — diferente estrutura para diferente query pattern

# Segments:
# - Um índice é composto de N segments (cada um é um mini-índice imutável)
# - Novos docs vão para um novo segment (in-memory, "buffer")
# - Periodicamente flush para disco (commit)
# - Em background, merge de segments pequenos em maiores (merge policy)
# - Delete: marcador no .liv file ("liveDocs"); doc físico só some no próximo merge

Tokenização e analisadores

Tokenização é o passo que determina o que pode ser buscado. Decisões aparentemente simples — case sensitivity, stemming, sinônimos — têm impacto direto na relevância e no recall. O erro mais comum: usar o analisador default para todos os campos.

# Analyzer = CharFilter → Tokenizer → TokenFilter chain

# Exemplo: analisador para busca em produtos (português)

# 1. CharFilters:
#    - HTML strip (remover tags se o conteúdo vem de HTML)
#    - Mapeamento de caracteres: ç→c, ã→a, ó→o (NFD normalization)

# 2. Tokenizer:
#    - StandardTokenizer (split por whitespace + pontuação, respeita Unicode)
#    - Alternativas: WhitespaceTokenizer, NGramTokenizer, KeywordTokenizer

# 3. TokenFilters (aplicados em sequência):
#    - Lowercase
#    - StopFilter: remover "o", "a", "de", "para"... (lista pt-BR)
#    - SynonymFilter: "celular" ↔ "smartphone" ↔ "telefone"
#    - StemmerFilter: "rodando" → "rod"; "casas" → "cas" (Snowball pt-BR)
#    - ASCIIFoldingFilter: garantir que "ação" e "acao" matchem

# CRÍTICO: o mesmo analyzer DEVE ser aplicado na indexação E na query
# Se indexar com stemming mas buscar sem, "rodando" no doc não casa com "rodando" na query
# (o índice tem "rod", a query busca "rodando")

# Analyzers diferentes para campos diferentes:
# - title: standard + stemming agressivo (boost de recall)
# - description: standard + stemming leve
# - sku: keyword (sem tokenização — match exato)
# - tags: lowercase only (sem stemming — termos curados)

# Multi-field indexing (mesmo conteúdo, analyzers diferentes):
# field "title" indexado três vezes:
#   - title (com stemming → recall)
#   - title.exact (sem stemming → precisão para frases exatas)
#   - title.autocomplete (edge n-grams → busca como você digita)
# Query usa o sub-field apropriado conforme o caso

# Custos de stemming agressivo:
# "computação" → "comput"
# "computador" → "comput"
# Ambos casam, mas perde-se a distinção semântica
# Tradeoff: recall (achar mais) vs precision (achar o certo)

Sharding: documento vs termo

Um índice de 1TB não cabe num único servidor — precisa ser distribuído. Existem duas estratégias fundamentais, com propriedades opostas.

# Estratégia 1: SHARDING POR DOCUMENTO (document-partitioned)
# Cada shard tem um subconjunto dos documentos, com índice completo localmente
# Particionamento: hash(doc_id) % N_shards

# Shard 0: docs cujo hash % 35 == 0 (cerca de 28M docs)
#   Inverted index local: todos os termos dos 28M docs deste shard
# Shard 1: docs cujo hash % 35 == 1
#   Inverted index local: todos os termos dos 28M docs deste shard
# ...

# Query "quick fox":
# 1. Coordinator dispara a query para TODOS os 35 shards (scatter)
# 2. Cada shard executa a query local: lookup + intersect + rank top-K (geralmente K=20)
# 3. Coordinator coleta resultados (gather)
# 4. Re-ranking global: ordena os 35 × 20 = 700 candidatos
# 5. Retorna top-10 ao cliente

# Vantagens:
# - Indexação trivialmente paralela: cada doc vai para um shard, não há coordenação
# - Adicionar/remover docs é local (sem reorganização global)
# - Falha de um shard degrada parcialmente, não totalmente

# Desvantagens:
# - Toda query toca TODOS os shards (overhead de coordenação)
# - Term frequency (df) é local — IDF aproximado, não exato
#   Fix: distributed frequency calculation (DFS_QUERY_THEN_FETCH no Elasticsearch)
# - Top-K em cada shard pode perder docs relevantes:
#   se o top-1 global está no shard X mas não no top-K do shard X, é descartado
#   Mitigação: K > 10 (geralmente K = max(num_results × 2, 20))

# Estratégia 2: SHARDING POR TERMO (term-partitioned)
# Cada shard tem TODOS os documentos, mas apenas um subconjunto de termos
# Particionamento: hash(termo) % N_shards

# Shard 0: postings de termos que hash % 35 == 0 (talvez "quick", "brown", ...)
# Shard 1: postings de termos que hash % 35 == 1 (talvez "fox", "lazy", ...)
# ...

# Query "quick fox":
# 1. Coordinator roteia "quick" para shard_A, "fox" para shard_B
# 2. shard_A retorna postings de "quick"; shard_B retorna postings de "fox"
# 3. Coordinator faz a intersecção
# 4. Ranking final

# Vantagens:
# - Apenas N shards consultados, onde N = número de termos da query (geralmente 2-4)
# - Term frequency é exata (toda a lista está num shard)
# - Eficiente para queries muito específicas

# Desvantagens:
# - Indexação é difícil: um doc com 100 palavras precisa atualizar 100 shards distintos
# - Transferência de postings entre shards no merge (overhead de rede)
# - Termos hot (palavras comuns) criam hotspots impossíveis de balancear
# - Mais complexo operacionalmente — quase ninguém usa

# Quem usa cada um:
# - Document-partitioned: Lucene/Elasticsearch/Solr, Google interno (versão simplificada)
# - Term-partitioned: pesquisa acadêmica, casos especializados
# - HÍBRIDO (Google real): document-partitioned dentro de DC, term-partitioned entre DCs
#   ou usado em sub-índices especializados

Query execution: scatter-gather

# Fluxo de uma query distribuída (document-partitioned):

# 1. Cliente → Query Coordinator (HTTP/gRPC)
#    GET /search?q=quick+fox&from=0&size=10

# 2. Query Coordinator:
async def execute_search(query: str, from_: int, size: int):
    # Parse + análise da query (mesma análise da indexação)
    parsed = query_parser.parse(query)
    analyzed = analyzer.analyze(parsed)  # ["quick", "fox"]

    # Phase 1: QUERY (scatter)
    # Cada shard retorna apenas (doc_id, score) — não os documentos completos
    shard_results = await asyncio.gather(*[
        shard.search(analyzed, size=from_ + size)
        for shard in active_shards
    ])
    # Timeout por shard (ex: 200ms) — se um shard não responder, ignorar e seguir
    # Resultado: list[list[(doc_id, score)]]

    # Phase 2: REDUCE (merge)
    # Heap dos top (from_ + size) globais
    global_top = heapq.nlargest(
        from_ + size,
        chain.from_iterable(shard_results),
        key=lambda x: x.score
    )
    # Aplicar paginação: top_global[from_ : from_ + size]
    page = global_top[from_:from_ + size]

    # Phase 3: FETCH (gather)
    # Buscar os documentos completos dos shards corretos
    docs_by_shard = defaultdict(list)
    for hit in page:
        shard_id = hit.doc_id % N_SHARDS
        docs_by_shard[shard_id].append(hit.doc_id)

    fetched = await asyncio.gather(*[
        shards[shard_id].fetch(doc_ids)
        for shard_id, doc_ids in docs_by_shard.items()
    ])

    # Phase 4: ASSEMBLE
    return assemble_response(page, fetched)

# Por que duas phases (query + fetch)?
# - Transferir apenas (doc_id, score) na phase 1 é barato
# - Documentos completos podem ser 10KB+; transferir só os 10 que vão ao usuário
# - Se transferíssemos tudo: 35 shards × 20 docs × 10KB = 7MB por query (proibitivo)

# Variantes:
# - QUERY_THEN_FETCH (default): a descrita acima
# - DFS_QUERY_THEN_FETCH: phase 0 extra para calcular IDF global → ranking mais preciso
#   custo: roundtrip adicional, +20-50ms latência
# - QUERY_AND_FETCH: combina em uma fase, mais rápido mas só correto se size > total docs

# Tail latency: o problema dos shards lentos
# P99 do sistema = P99 do shard mais lento (não a média)
# Mitigações:
# - Hedged requests: enviar para 2 shards (primary + replica), usar quem responder primeiro
# - Partial results: timeout por shard, marcar resultado como "incompleto"
# - Adaptive timeout: baseado em latência histórica do shard

Ranking: TF-IDF, BM25 e além

Ranking é o que diferencia busca de filtragem. A intersecção de postings dá os documentos que contêm os termos; o ranking ordena por relevância. As fórmulas clássicas, descobertas nos anos 70-90, ainda são o baseline de qualquer sistema moderno — mesmo quando o ranking final é feito por ML.

# TF-IDF: a intuição fundamental

# TF (Term Frequency): quantas vezes o termo aparece no documento
# Mais ocorrências → mais relevante (assumindo que o termo é informativo)
tf(termo, doc) = count(termo, doc)

# IDF (Inverse Document Frequency): quão raro é o termo no corpus inteiro
# Termos raros (ocorrem em poucos docs) carregam mais informação
# log para suavizar: a diferença entre "1 doc" e "10 docs" importa mais que entre "1M" e "10M"
idf(termo) = log(N / df(termo))
# N = total de docs; df = doc frequency (em quantos docs o termo aparece)

# Score TF-IDF de um doc para uma query (soma sobre os termos):
score(doc, query) = sum(tf(t, doc) * idf(t) for t in query.terms)

# Problemas do TF-IDF puro:
# 1. Documentos longos têm TF maior naturalmente — fica enviesado para docs grandes
# 2. TF cresce linearmente — 100 ocorrências do termo é 100x melhor que 1? Não é
# 3. Sem normalização por tamanho do documento

# BM25 (Best Matching 25 — Robertson et al., 1994):
# Refinamento do TF-IDF que corrige os problemas acima
# É o ranking default do Elasticsearch desde a v5

import math

def bm25(tf, df, doc_len, avg_doc_len, N, k1=1.2, b=0.75):
    """
    tf: term frequency no doc
    df: document frequency do termo no corpus
    doc_len: tamanho do doc em tokens
    avg_doc_len: tamanho médio dos docs no corpus
    N: número total de docs
    k1: saturação de TF (1.2-2.0)
    b: normalização por tamanho (0 = sem; 1 = completa)
    """
    idf = math.log(1 + (N - df + 0.5) / (df + 0.5))
    norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * doc_len / avg_doc_len))
    return idf * norm

# Por que k1 cria saturação:
# Com k1=1.2, o ganho de TF se aproxima de um teto:
#   TF=1  → norm ≈ 1.0
#   TF=2  → norm ≈ 1.5
#   TF=10 → norm ≈ 2.0
#   TF=100 → norm ≈ 2.1  (quase nada de ganho a partir daqui)
# Modela a intuição: 100 ocorrências NÃO é 100x melhor que 1

# Por que b normaliza por tamanho:
# Doc de 100 palavras com 5 menções de "fox" é mais relevante que
# doc de 10000 palavras com as mesmas 5 menções
# b=0.75 ajusta o score em proporção ao doc_len / avg_doc_len

# Boosting por campo (queries multi-campo):
# Mesmo termo na URL/título importa mais que no body
final_score = (
    3.0 * bm25(...) [no campo title] +
    1.5 * bm25(...) [no campo headers] +
    1.0 * bm25(...) [no campo body]
)

# Re-ranking com ML (Learning to Rank):
# - BM25 dá os top-200 (fast, full corpus)
# - Modelo ML (LambdaMART, neural ranker) re-ranqueia os top-200 → top-10
# - Features: BM25 score, click-through rate histórico, freshness, autor, popularidade
# - Treinamento: judgements humanos OU clickstream (clicks como proxy de relevância)
# - Trade-off: latência (+20-50ms) vs qualidade (quase sempre vale a pena para search comercial)

Near-real-time indexing: o segredo dos segments

Como atualizar um índice gigante em tempo quase real sem parar o serviço? A resposta de Lucene: nunca modificar o índice no lugar. Todo índice é uma coleção de segments imutáveis; novos documentos vão para um novo segment; segments velhos são compactados em background.

# Lifecycle de um documento no Lucene/Elasticsearch:

# 1. Indexação:
#    - Doc chega → adicionado ao in-memory buffer (no segment ainda)
#    - Buffer cresce até atingir o threshold (16MB default) OU refresh_interval (1s default)

# 2. Refresh:
#    - In-memory buffer vira um in-memory segment SEARCHABLE
#    - Aqui a busca já encontra o doc — "near-real-time"
#    - Mas ainda NÃO está no disco — risco de perda em crash

# 3. Translog (write-ahead log):
#    - Em paralelo, o doc também foi escrito num translog persistente
#    - Translog garante durabilidade mesmo antes do flush para disco
#    - Default: translog é fsync'd a cada request OU a cada 5s

# 4. Flush:
#    - In-memory segment é escrito para disco como um novo segment file
#    - Translog antigo é descartado
#    - Default: a cada 30 minutos OU translog atinge 512MB

# 5. Merge:
#    - Background: pequenos segments são merged em maiores
#    - Tiered Merge Policy: agrupa segments de tamanhos similares
#    - Durante o merge, os segments antigos continuam servindo queries
#    - Quando o novo segment está pronto, atomicamente substitui os antigos
#    - Segments antigos são deletados

# Por que segments imutáveis?
# - Sem locking: leitura nunca bloqueia escrita
# - Filesystem caching é eficiente: arquivos não mudam → cache válido por mais tempo
# - Incremental backup: copiar segments novos é suficiente (os antigos não mudaram)
# - Snapshot consistency: lista de segments num momento = snapshot atômico

# Update e Delete são "soft":
# - Delete: marca doc_id no .liv file ("liveDocs") — bitset de docs ativos
#   Doc físico permanece no segment; queries filtram pelo liveDocs
# - Update: equivale a delete + insert → marca antigo, insere novo no buffer
# - O espaço só é recuperado no próximo merge que processa o segment

# Custo do refresh frequente:
# - Cada refresh cria um novo segment file
# - Muitos segments pequenos = muitos arquivos = muitos file handles + I/O
# - Index time vs Search time tradeoff:
#   refresh_interval=1s  → indexação rápida, mais merges, query overhead
#   refresh_interval=30s → indexação batched, menos overhead, mas resultados "atrasados"
# - Bulk loading: setar refresh_interval=-1 (desativado) durante load inicial, depois reabilitar

# Translog vs commit:
# - Commit (flush) é caro: fsync de todos os segments, ~segundos
# - Translog é fsync'd a cada operação → muito mais frequente, muito mais barato
# - Em caso de crash: ao iniciar, Elasticsearch replay translog desde o último commit
# - Tradeoff: durabilidade total vs throughput (translog assíncrono é 3-5x mais rápido)

Autocomplete, snippets e correção

# Autocomplete: dois approaches comuns

# Approach 1: Edge N-grams (indexação especial)
# Para o termo "search", indexa: ["s", "se", "sea", "sear", "searc", "search"]
# Query "sea" → match em qualquer doc com termo começando com "sea"
# Vantagem: prefix queries são naturalmente rápidas (lookup direto no index)
# Desvantagem: índice cresce ~10x por campo de autocomplete

# Approach 2: Completion Suggester (estrutura dedicada)
# Lucene/Elasticsearch tem uma estrutura chamada FST especializada para autocomplete
# Cada termo é indexado como sequência → FST permite percorrer todos os prefixos em O(L)
# Suporta pesos por sugestão (popularidade) e fuzziness (typos)
# Sub-milissegundo independente do tamanho do corpus

# Highlight / Snippets:
# - Para cada hit, extrair fragmentos do doc original onde os termos aparecem
# - Marcar os termos com ... para renderização
# - Strategy 1: re-scan do stored field (mais simples, mais lento)
# - Strategy 2: term_vectors armazenados na indexação (mais rápido, mais storage)
#   Term vectors = (termo, posição, offset) por documento, permite extrair contexto sem re-scan

# Exemplo de resultado com snippet:
# {
#   "doc_id": 12345,
#   "title": "How to design search systems at scale",
#   "snippet": "...this article explains how to build a robust search
#              system for catalogs of billions of documents...",
#   "score": 8.42
# }

# Spell correction ("Did you mean?"):
# Quando a query não retorna resultados (ou retorna poucos), sugerir alternativas

# Approach 1: Edit distance (Levenshtein)
# Para cada termo da query sem matches, buscar termos do índice com edit_distance ≤ 2
# Usar BK-tree ou Levenshtein automaton para tornar a busca tratável
# "serch" → ["search" (1), "serch" → "porch" (2), "perch" (2)]

# Approach 2: Trigrams / n-grams
# Indexar trigrams: "search" → ["sea", "ear", "arc", "rch"]
# Query "serch" → trigrams ["ser", "erc", "rch"]
# Termos compartilhando muitos trigrams são candidatos → ordenar por overlap

# Approach 3: Frequency-based (estatística do corpus)
# Sugestões devem ser termos comuns no índice (não pode sugerir typo por typo)
# Combinar: edit_distance ≤ 2 AND doc_frequency > threshold
# Para multi-termo: usar log-likelihood de cada candidato e o contexto

# Approach 4 (state-of-the-art): query log mining
# Coletar pares (query_with_typo, query_correta) do clickstream histórico
# Treinar modelo seq2seq ou embedding-based → sugestões aprendidas
# Google usa basicamente isso desde meados dos anos 2000

Arquitetura completa

                                Cliente
                                   │
                                   ▼
                       ┌─────────────────────┐
                       │  Search API Gateway │ (rate limit, auth, A/B)
                       └──────────┬──────────┘
                                  │
                  ┌───────────────┴───────────────┐
                  ▼                               ▼
        ┌───────────────────┐         ┌──────────────────────┐
        │ Query Coordinator │         │ Autocomplete Service │
        └─────────┬─────────┘         │  (FST in-memory)     │
                  │                   └──────────────────────┘
        scatter-gather paralelo
                  │
   ┌──────┬──────┼──────┬──────┐
   ▼      ▼      ▼      ▼      ▼
 Shard1 Shard2 Shard3 ... Shard35     (Lucene indices, RF=3)
   │      │      │      │      │
   └──────┴──┬───┴──────┴──────┘
             │ scores
             ▼
        ┌────────────────┐
        │ Reduce + Top-K │
        └────┬───────────┘
             │ top doc_ids
             ▼
        ┌────────────────┐
        │  Fetch docs    │ (de volta aos shards corretos)
        └────┬───────────┘
             │
             ▼
        ┌────────────────┐
        │ ML Re-ranker   │ (LambdaMART / neural — opcional)
        └────┬───────────┘
             │
             ▼
        ┌────────────────┐
        │ Snippet / hl   │
        └────┬───────────┘
             │ resposta
             ▼
          Cliente

Pipeline de indexação (separado da query path):

  Source (DB, Kafka topic, crawler)
     │ events: doc_created, doc_updated, doc_deleted
     ▼
  ┌────────────────────┐
  │ Ingestion Service  │ (Kafka consumer)
  └──────────┬─────────┘
             │ batch (1000 docs / 5s)
             ▼
  ┌────────────────────┐
  │  Index Builder     │ (analyzer, validação, enrichment)
  └──────────┬─────────┘
             │ routing por hash(doc_id)
             ▼
  ┌──────────┴──────────┐
  ▼                     ▼
Shard primary    Shard replica (RF=3)
  │                     │
  └──────── ack ────────┘
             │
             ▼
  Cliente notificado (commit_id)

Caches:
  - Edge cache (CDN): queries muito populares com TTL curto (10-60s)
  - Query result cache (Redis): top-N por query normalizada, TTL 5min
  - Field data cache (Lucene): valores de sort/aggregation em memória
  - OS page cache: o "cache" mais importante — segments mmap'd, hot data fica em RAM

Observabilidade essencial:
  - Latência por phase: parse / shard query / reduce / fetch / rerank
  - Tail latency por shard (P99 individual)
  - Cache hit ratio
  - Indexação lag (event_timestamp - searchable_at)
  - Top-N queries (para identificar candidatos a cache especial)
  - Queries sem resultados (sinal de gap de cobertura ou typos não corrigidos)
tail latency: o inimigo invisível em scatter-gather

Num sistema com 35 shards consultados em paralelo, a latência da query é determinada pelo shard mais lento — não pela média. Se cada shard tem P99=80ms (1% das queries acima de 80ms), a chance de ter pelo menos um shard lento entre os 35 é praticamente 30% (1 - 0.99^35). O P99 do sistema completo fica bem pior que o P99 individual. Esse fenômeno foi descrito por Jeff Dean no clássico "The Tail at Scale" (CACM, 2013) — e a mitigação é hedged requests: enviar a mesma query para duas réplicas e cancelar a que retornar segundo. Adiciona ~5% de carga ao sistema, mas reduz P99 em 30-60%. O insight conceitual: em sistemas distribuídos, otimizar a média sem olhar para a cauda é otimizar para o caso comum enquanto o usuário sofre o caso ruim.

Decisões de engenharia

Elasticsearch vs construir o próprio engine de busca

Construir um search engine do zero parece ser um problema de algoritmos (inverted index, BM25), mas na verdade 90% do trabalho é operacional: sharding/replicação, recovery após falhas, rolling upgrades sem downtime, balanceamento de carga, snapshot/restore, monitoramento. Lucene resolve o algoritmo; Elasticsearch/OpenSearch resolvem a operação. Construir tudo isso internamente faz sentido para casos muito específicos: requisitos de latência extrema (Google Search precisa de <100ms em 1000 datacenters), modelo de relevância proprietário (Spotify para recomendação), ou restrições de hardware (busca embarcada num dispositivo).

Regra prática: Elasticsearch ou OpenSearch para 99% dos casos. Construir custom só quando o volume justifica time dedicado (10+ engenheiros), e mesmo assim, geralmente em cima de Lucene como biblioteca (não reimplementar inverted index). Para casos específicos: Vespa (Yahoo) para ranking complexo, Tantivy (Rust) para embarcado, Typesense/Meilisearch para search simples.

Sharding por documento vs por termo

Document-partitioned é a escolha quase universal (Lucene, Elasticsearch, Solr) porque o tradeoff é claramente favorável em workloads reais. A indexação é simples (cada doc vai para um shard, sem coordenação), a falha de um shard degrada parcialmente em vez de quebrar queries específicas, e o overhead de scatter-gather é aceitável quando paralelizado bem. Term-partitioned tem vantagens teóricas (apenas N shards consultados, IDF exato), mas a complexidade operacional — indexação distribuída, hotspots em termos comuns, merges entre shards — não compensa.

Regra prática: document-partitioned por default. Se a query média toca 100+ shards e a latência de coordenação domina, considerar reduzir N_shards (shards maiores) antes de mudar a estratégia. Term-partitioned só faz sentido em casos exóticos: search em corpus pequeno com queries muito longas, ou arquiteturas geo-distribuídas onde termos raros podem ficar próximos de quem os busca.

Refresh interval: 1s vs 30s vs maior

O refresh_interval é o tradeoff mais frequente em operação de Elasticsearch. Refresh de 1s (default) garante near-real-time, mas cria muitos segments pequenos, multiplica o overhead de merges em background, e reduz o throughput de indexação em 30-50%. Refresh de 30s é a configuração default razoável para a maioria dos sistemas onde "5-30 segundos de atraso" é aceitável (logging, analytics, full-text search em catálogo). Refresh desabilitado (-1) é usado em bulk reindex, ligado ao final.

Regra prática: não use 1s sem necessidade real. 30s é o "default profissional". Pergunte ao produto: "se um doc novo demora 30 segundos para aparecer na busca, isso quebra alguma promessa para o usuário?" Quase nunca quebra. Para os casos onde quebra (alerting, fraud detection), considere refresh: 1s + tuning agressivo de merge policy; ou um índice "hot" separado com refresh curto para os últimos 24h e um índice "warm" para o histórico.

BM25 puro vs Learning to Rank vs busca vetorial

BM25 é o baseline; é grátis, é interpretável, e funciona surpreendentemente bem para 80% dos casos. Learning to Rank (LTR) re-ranqueia o top-200 do BM25 usando ML treinado em judgements ou clickstream — ganha 10-30% em métricas de relevância (NDCG), com custo de latência (+20-50ms). Busca vetorial (embeddings + ANN como HNSW) captura semântica que BM25 não pega ("car" e "automobile" são similares no espaço de embedding mas não no inverted index), mas é cara em storage (cada doc é um vetor de 768+ dimensões) e latência de update.

Regra prática: sempre começar com BM25 puro. Se o produto cresce a ponto de ter um time de relevância, adicionar LTR como segunda fase. Busca vetorial fica reservada para casos onde a similaridade semântica é a feature principal (recomendação, busca de imagens, RAG para LLMs). Híbrido (BM25 + vetor combinados) é o state-of-the-art atual: pegar os top-K de ambos e fundir os scores com RRF (Reciprocal Rank Fusion).

Perguntas de entrevista

Como você atualiza o esquema de indexação (ex: adicionar um campo novo, mudar o analyzer) num índice de 1 bilhão de documentos em produção, sem downtime?

Esta é a operação mais delicada em search systems — e a resposta canônica é reindex via aliases, padronizada pelo Elasticsearch e adotada em qualquer sistema sério de busca.

1. O índice é exposto por um alias, nunca pelo nome direto. A aplicação consulta products (alias), que aponta para products_v3 (índice físico). Esse nível de indireção é o que torna a migração possível.

2. Criar o novo índice em paralelo: products_v4 com o novo schema. Não está no alias ainda — nenhum cliente vê.

3. Reindex em background: ler todos os docs de products_v3 e escrever em products_v4 com a nova análise. Pode levar horas/dias para 1B docs. Usar slicing (paralelizar por hash do doc_id) e desativar refresh durante a carga inicial. Throttle para não impactar queries em produção.

4. Sincronização incremental: durante o reindex, novos updates continuam chegando em products_v3. Solução: indexar em ambos (dual-write) durante a janela de migração, OU usar o mecanismo nativo de "reindex from remote with continue" do Elasticsearch que sincroniza via timestamps.

5. Validação: shadow traffic — replicar uma fração das queries de produção contra products_v4 e comparar resultados com products_v3. Métricas: overlap de top-10, latência, diff de scores.

6. Cutover atômico: mover o alias products de v3 para v4 num único request — todas as queries seguintes vão para o novo índice, sem reinicialização da aplicação. Manter v3 por algumas horas como fallback.

O ponto crítico: nunca, jamais, tentar mudar o schema de um índice in-place. Lucene segments são imutáveis; o "schema" é definido na criação. Mudar tipo de campo ou analyzer requer reindex completo.

Como você implementaria autocomplete que sugere resultados enquanto o usuário digita, mantendo latência abaixo de 50ms?

Autocomplete tem requisitos diferentes de busca completa: latência absurdamente baixa (cada keystroke dispara uma requisição), recall mais importante que precision, e sensibilidade a popularidade (sugerir o que outros usuários buscaram).

Estrutura de dados: não usar inverted index. Usar uma estrutura especializada — em Lucene, o Completion Suggester (baseado em FST). Em sistemas custom: trie comprimido (radix tree) ou DAWG (Directed Acyclic Word Graph). A propriedade fundamental: lookup de prefixo em O(L), onde L é o tamanho do prefixo — não depende do tamanho do corpus.

O que indexar: não os documentos inteiros — apenas sugestões: nomes de produtos, queries populares do log de busca, entidades nomeadas (cidades, marcas, pessoas). Cada sugestão tem um peso (popularidade, recência). Para um e-commerce: 10-100M entradas, cabe em ~2-5GB de RAM por nó.

Servir tudo da RAM: nada de hit disco, nada de network multi-hop. Cada nó de autocomplete tem o FST inteiro em memória. Múltiplos nós replicados atrás de um load balancer. Para escala maior: shard por primeira letra ou intervalo do alfabeto.

Latência típica: <5ms no servidor + ~30ms de rede = ~35ms end-to-end. O custo dominante é o roundtrip, não a computação.

Otimizações de produto: debounce no cliente (esperar 150ms após o último keystroke antes de enviar a request); cancelar requests anteriores quando o usuário continua digitando; cache local no browser para os últimos prefixos consultados.

Fuzziness: o Completion Suggester suporta erros (edit_distance=1 ou 2) com overhead baixo — "seatrch" ainda sugere "search". Útil para mobile typing, mas custa CPU; só ativar se as métricas mostrarem valor.

O P99 da sua busca está em 300ms enquanto o P50 está em 20ms. Como você diagnostica e ataca esse gap?

Esse gap entre P50 e P99 é o sintoma clássico de tail latency em sistemas scatter-gather. A primeira coisa é decompor a latência por fase para identificar onde está o problema.

Hipótese 1 — shard lento intermitente: instrumentar latência por shard por query. Se um shard específico tem P99 alto sistematicamente, pode ser problema de hardware (disco com latência irregular), de carga desbalanceada (mais documentos hot nesse shard), ou de GC pause (especialmente em JVM). Solução: hedged requests — disparar a query para duas réplicas e usar a primeira que responder. Cancela a outra. Aumenta carga em ~5%, reduz P99 em 30-60% tipicamente.

Hipótese 2 — queries com termos comuns: termos como "the" ou "de" têm postings lists gigantes (centenas de MB). A query "the best restaurant" toca uma postings list muito maior que "michelin restaurant". Verificar se as queries lentas correlacionam com termos de alto df. Solução: stop words (remover termos sem valor) ou min_should_match (exigir que múltiplos termos casem, eliminando matches só pelos comuns).

Hipótese 3 — cold cache: queries raras tocam segments que não estão no OS page cache. O primeiro acesso lê do disco; o segundo é da memória. Verificar a correlação entre query rara e latência. Solução: warm-up no startup, manter segments hot via background scans, ou aumentar a RAM dos nós.

Hipótese 4 — GC pauses (se Lucene/Elasticsearch): a JVM faz pausas de GC ocasionais que congelam a query. Verificar logs de GC e correlacionar timestamps. Solução: tunar GC (G1GC, ZGC), reduzir heap, evitar field data cache em campos high-cardinality.

Hipótese 5 — paginação profunda: queries com from=10000 precisam recuperar top 10000+10 de cada shard, depois reduce. Custa O(N × from) por shard. Solução: substituir paginação numérica por cursor-based (search_after), que mantém a latência constante independente da página.

A metodologia: instrumentar agressivamente, encontrar a correlação entre características da query e a latência alta, atacar a causa raiz dominante (geralmente uma hipótese explica 70%+ do P99).

Por que BM25 é melhor que TF-IDF puro? Em que casos eles divergem em rankings?

BM25 é um refinamento de TF-IDF que corrige três problemas matemáticos do TF-IDF puro. Cada correção afeta o ranking em casos específicos:

1. Saturação de TF: em TF-IDF puro, o termo ocorrendo 100 vezes vale 100x mais que ocorrendo 1 vez. Isso é falso semanticamente: 100 ocorrências indica spam, não relevância 100x maior. BM25 satura com o parâmetro k1: o ganho marginal cai rapidamente após as primeiras ocorrências. Caso divergente: doc com termo repetido obsessivamente (keyword stuffing) ranqueia alto em TF-IDF, baixo em BM25.

2. Normalização por tamanho do documento: TF-IDF puro favorece documentos longos (mais palavras → maior TF naturalmente). BM25 normaliza por doc_len / avg_doc_len via parâmetro b. Caso divergente: dois docs sobre o mesmo tópico, um de 100 palavras e outro de 10000. Em TF-IDF o longo ganha; em BM25 podem empatar se a densidade de termos for similar.

3. Probabilistic IDF: a fórmula de IDF do BM25 (log(1 + (N - df + 0.5) / (df + 0.5))) é derivada de um framework probabilístico (Probabilistic Relevance Framework), enquanto o IDF clássico é uma heurística. A diferença prática é pequena exceto nos extremos: termos que aparecem em metade do corpus têm IDF negativo no BM25 (penalizando), zero no TF-IDF clássico. Caso divergente: queries com termos médio-comuns; BM25 dá mais peso aos termos raros relativos da query.

Na prática, BM25 supera TF-IDF em qualquer benchmark de IR padrão (TREC, MS MARCO) em 5-15% de NDCG@10. Os parâmetros k1=1.2 e b=0.75 são defaults razoáveis para texto natural; tunar com base no seu corpus pode dar mais 2-5%. Por isso o Elasticsearch tornou BM25 o default em 2016.

Como você implementa filtros (categoria, faixa de preço, marca) de forma eficiente junto com busca textual?

Filtros são fundamentalmente diferentes de queries de busca em duas dimensões: não contribuem para o score (são binários: matches ou não matches) e tendem a ser reusados entre queries diferentes. Essas características permitem otimizações específicas.

Cache de filtros (filter cache): para cada filtro, computar uma vez o bitset dos doc_ids que satisfazem (ex: "categoria=eletrônicos" → bitset de 50M bits). Armazenar em RAM. Quando a query "iphone" + "categoria=eletrônicos" chega: executar a busca textual (obter doc_ids candidatos) e fazer AND com o bitset cacheado. Operação extremamente rápida (bitwise AND em batches de 64 bits).

Filter context vs query context: no Elasticsearch, expressar filtros via filter em vez de must tem dois efeitos: (1) não contribui para o score (poupa cálculo) e (2) é cacheável (o bitset pode ser reusado entre queries). Filtros como categoria, range de preço, data, status são sempre filter context.

Range queries em colunas numéricas: filtros como "price BETWEEN 100 AND 500" não usam inverted index — usam doc values (column store por campo). Cada doc tem o preço armazenado em formato column-oriented; range scan é sequencial e eficiente. Se a coluna tem cardinalidade baixa (ex: 100 valores distintos), pode-se pré-computar bitsets por valor.

Ordem de execução: aplicar filtros baratos primeiro (cacheados, baixa seletividade) e caros depois. Se "categoria=A" elimina 90% dos docs, executar isso antes da busca textual significa que a textual scaneia apenas 10% dos postings.

Cardinalidade do bitset: filtros com seletividade muito alta (poucos docs) podem ser representados como roaring bitmaps em vez de bitsets densos — menor memory footprint, AND/OR mais rápido para conjuntos esparsos.

O resultado prático: queries com filtros bem cacheados são frequentemente mais rápidas que queries sem filtros (mesmo tendo mais condições) porque os filtros reduzem o espaço de busca antes do trabalho caro de scoring.

Exercícios práticos

Exercício 1 — Construir um inverted index em memória

Implemente um mini search engine em Python ou Go: receber uma coleção de documentos (texto simples), aplicar tokenização (lowercase + split + remoção de stop words), e construir o inverted index (dict de termo → lista de doc_ids). Implementar query AND: dados N termos, retornar os doc_ids que contêm todos. Testar com 1000 documentos sintéticos (ex: artigos da Wikipedia ou conteúdo gerado).

Critério: a query "fox AND lazy" retorna apenas docs que contêm ambos os termos. A intersecção é implementada como merge de listas sorted (não usar set difference em listas não-sorted, que é O(N²)). Medir: latência da indexação de 1000 docs, latência média de uma query em ms, tamanho do índice em bytes vs tamanho do corpus.

Exercício 2 — Implementar BM25 ranking

Estenda o Exercício 1 adicionando ranking BM25. Para cada query, calcular o score de cada doc candidato usando a fórmula BM25 (k1=1.2, b=0.75) e retornar o top-10 ordenado por score decrescente. Comparar com TF-IDF simples (score = soma de tf × log(N/df)) — gerar 5 queries de teste e mostrar como os rankings divergem.

Critério: os scores BM25 são calculados corretamente (verificar manualmente com um doc de teste). Documentar pelo menos 2 queries onde o ranking BM25 difere de TF-IDF e explicar por quê (geralmente: doc longo com poucas ocorrências cai no BM25; doc curto com alta densidade sobe). Bonus: variar k1 e b para ver o impacto nos rankings.

Exercício 3 — Sharding por documento com scatter-gather

Simule sharding de um índice de 10000 documentos em 4 shards (cada um é uma instância independente do search engine do Exercício 1). Implementar o Query Coordinator que: (1) dispara a query para todos os shards em paralelo (asyncio em Python, goroutines em Go), (2) coleta os top-20 de cada shard, (3) faz reduce global mantendo os top-10 por score. Comparar latência: shard único com 10000 docs vs 4 shards de 2500 docs cada com scatter-gather.

Critério: o resultado top-10 do sharded é idêntico (ou quase idêntico, modulo IDF aproximado) ao do índice único. Medir: latência média e P99 do sharded vs do único. Simular um shard lento (adicionar sleep de 100ms num shard) e observar o impacto no P99 do sistema — esse é o problema de tail latency em ação.

Exercício 4 — Autocomplete com prefix tree

Implemente um serviço de autocomplete que carrega 10.000 sugestões (queries comuns, nomes de cidades, nomes de produtos) numa estrutura de prefix tree (trie). Servir um endpoint HTTP que, dado um prefixo, retorna as top-10 sugestões ordenadas por popularidade (peso pré-definido em cada entrada). Medir latência: deve ficar abaixo de 5ms no servidor para prefixos de 1-10 caracteres.

Critério: prefixo "ber" retorna ["berlin", "berkeley", "bernie sanders", ...] ordenados por popularidade. Latência P99 medida em 1000 queries: <5ms server-side. Tamanho de memória da estrutura é razoável (<50MB para 10k entradas). Bonus: suportar fuzziness — "berlim" também sugere "berlin" via edit distance ≤ 1.

Exercício 5 — Near-real-time indexing com segments

Implemente um mini sistema com segments imutáveis: novos documentos vão para um "in-memory segment" que é searchable imediatamente. A cada 30 segundos (refresh interval), o in-memory segment é flushed para disco (um arquivo serializado) e um novo in-memory segment é criado. Implementar a query que percorre todos os segments (in-memory + disk) e faz merge dos resultados. Implementar deletes via "tombstones" (marcar doc_id como deletado num bitset por segment).

Critério: documento adicionado é encontrado pela query imediatamente (sem aguardar flush). Após restart do processo, segments persistidos no disco são carregados e queries continuam funcionando. Deletes não removem fisicamente o doc até o próximo "merge" (implementar como exercício adicional). Documentar: o número de segments cresce com o tempo se não houver merge — qual seria sua merge policy?

Referências

  1. book Manning, Raghavan, Schütze — Introduction to Information Retrieval Cambridge University Press · 2008 · o livro-texto canônico de IR · capítulos 1-3 cobrem inverted index, tokenização e construção do índice em profundidade; cap. 6 cobre scoring (TF-IDF e BM25)
  2. article Robertson, S. — The Probabilistic Relevance Framework: BM25 and Beyond Foundations and Trends in IR · 2009 · o paper definitivo sobre BM25 — derivação probabilística, parâmetros, e extensões
  3. article Dean, J. & Barroso, L. — The Tail at Scale Communications of the ACM · 2013 · o paper de referência sobre tail latency em sistemas distribuídos — explica por que P99 explode em scatter-gather e as técnicas para mitigar (hedged requests, micro-partitioning)
  4. docs Lucene — File Formats lucene.apache.org/core/9_x/core/org/apache/lucene/codecs/lucene99/package-summary.html · documentação técnica dos arquivos de segmentos Lucene — FST, postings compression, doc values
  5. docs Elasticsearch — Near Real-Time Search elastic.co/guide · explicação detalhada de refresh, flush, translog e merge — fundamental para entender o tradeoff entre indexação rápida e queries eficientes
  6. book Banon, S. (Elastic) — Elasticsearch: The Definitive Guide O'Reilly · 2015 (ainda atual para conceitos) · livro escrito pelos autores do Elasticsearch · embora antigo, os capítulos sobre análise, mapping e sharding são essenciais
  7. article Yelp Engineering — Replacing MySQL Search with Elasticsearch engineeringblog.yelp.com · case study real de migração: P99 caiu de 5s para 80ms, schema design, problemas operacionais encontrados
  8. article Algolia — Building a Search Engine from Scratch blog.algolia.com · série de posts sobre as decisões de design da Algolia — por que C++ no core, como otimizam tail latency, e o tradeoff de não usar Lucene
  9. article Discord Engineering — Implementing Server-Wide Search with Elasticsearch discord.com/blog · sharding strategy para search multi-tenant em escala — bilhões de mensagens, milhares de servidores, requisitos de isolamento
  10. paper Burges, C. — From RankNet to LambdaRank to LambdaMART Microsoft Research · 2010 · o paper que estabeleceu Learning to Rank na indústria — LambdaMART é ainda hoje um baseline forte para re-ranking
  11. article Karpukhin et al. — Dense Passage Retrieval for Open-Domain Question Answering EMNLP · 2020 · busca vetorial moderna baseada em embeddings — fundamento da arquitetura híbrida (BM25 + vetor) usada em RAG e search comercial state-of-the-art
  12. docs Malkov & Yashunin — Efficient and Robust Approximate Nearest Neighbor Search using HNSW arxiv.org/abs/1603.09320 · o algoritmo de ANN dominante hoje · usado por todos os vector databases comerciais (Pinecone, Weaviate, Qdrant) e pelo k-NN do Elasticsearch