Capa do artigo: Busca inteligente com IA: aplicando semântica com baixo custo e simplicidade

Busca inteligente com IA: aplicando semântica com baixo custo e simplicidade

Decidi colocar busca inteligente nos artigos deste blog sem transformar um site simples em uma plataforma pesada para coisas simples. O blog funciona de um jeito direto: o conteúdo nasce em Markdown, o GitHub Actions executa o build, o Markdown vira HTML e os arquivos são enviados para o servidor via SSH. O desafio era aplicar busca semântica nesse fluxo sem serviço de busca dedicado, sem banco vetorial e sem depender de uma API paga por token a cada consulta.

Essa restrição guiou a solução para um caminho mais simples. O site podia ganhar sofisticação sem carregar uma stack pesada nem criar custos adicionais. A decisão principal foi calcular antes tudo que pudesse ser calculado antes. O índice, os chunks, o vocabulário e parte dos vetores seriam gerados durante o build. O servidor ficaria responsável apenas pelo que depende da consulta digitada pelo leitor. O objetivo era aplicar IA com baixo custo, preservando simplicidade operacional.

Quando os artigos são curtos, uma busca simples por palavra resolve bastante coisa. Quando os artigos passam de uma hora de leitura, procurar pelo título muda o jogo. O conteúdo que o leitor quer pode estar enterrado dentro de uma seção no meio do texto, e o título do artigo pode não carregar o termo digitado. Alguém procura por "teorema CAP" e espera cair no trecho correto sobre consistência e disponibilidade. Alguém digita "hexagonil" com erro e espera que o sistema entenda que talvez esteja procurando por arquitetura hexagonal. Alguém digita "CAAP" e espera cair no trecho sobre CAP, sem precisar saber que uma letra a mais muda tudo para uma máquina.

Esse é o ponto em que a busca deixa de ser apenas um campo visual na interface. Ela passa a ter decisões de indexação, ranking, tolerância a erro, atualização do índice e custo de execução.

Alfred Korzybski ficou conhecido pela frase "the map is not the territory". O mapa nunca é o território. Um índice de busca também nunca é o conteúdo. Ele é uma representação compacta, parcial e enviesada do conteúdo. A qualidade da busca depende do tipo de mapa que escolhemos construir.

A busca literal por palavra

A busca mais simples trata o artigo como uma grande string e pergunta se o texto digitado aparece ali dentro. Em JavaScript, isso seria parecido com texto.toLowerCase().includes(consulta.toLowerCase()). Funciona quando a palavra buscada aparece exatamente no artigo, mas falha quando existe erro de digitação, variação de plural, sigla escrita de outro jeito ou intenção expressa com outras palavras.

Essa abordagem é fácil de implementar e ajuda em acertos exatos. Se o artigo contém CAP e a pessoa digita CAP, o resultado aparece. O problema aparece quando a consulta não repete exatamente o texto do artigo.

Uma pessoa nem sempre lembra o título exato. Muitas vezes ela lembra um conceito, digita uma palavra no singular quando o artigo usa plural, ou erra uma letra no caminho. Procura por "arquitetura hexagonil" quando o texto usa "arquitetura hexagonal". Digita "CAAP" quando o artigo fala de "CAP". Para uma busca literal, cada uma dessas pequenas diferenças vira um desencontro completo.

A busca literal também tem outro problema. Ela sabe dizer se encontrou algo, mas não sabe dizer se o resultado é bom. Um artigo que menciona "arquitetura" uma vez em uma nota de rodapé pode aparecer junto de outro que dedica vinte seções ao tema. Sem ranking, todos os acertos parecem iguais.

Quando todo resultado parece igual, o trabalho volta para o leitor. Ele precisa abrir artigo por artigo, usar a busca do navegador, procurar dentro da página, voltar, tentar outro termo. A busca entrega uma lista, mas não orienta a escolha.

O que uma busca indexada muda

Uma busca indexada muda o momento em que parte do trabalho acontece.

Na busca ingênua, cada consulta percorre o conteúdo bruto. Na busca indexada, o conteúdo é analisado antes. O sistema lê os artigos no build, quebra o texto em partes menores, extrai tokens, normaliza palavras, cria estruturas auxiliares e grava tudo em um formato rápido de consultar.

Essa diferença muda a responsabilidade do servidor. Em vez de ler todos os artigos a cada consulta, ele consulta estruturas já preparadas.

No meu caso, os artigos já passam por uma build. Markdown entra de um lado, HTML sai do outro. Isso cria uma oportunidade excelente. Em vez de gerar o índice no servidor a cada busca, o build pode gerar uma base SQLite junto com o site.

O servidor de produção fica com uma tarefa leve. Recebe a consulta, abre a base SQLite, calcula ranking e devolve os resultados. O trabalho caro acontece antes, na esteira.

flowchart LR
    MD[Artigos em Markdown] --> Build[Build do site]
    Build --> HTML[Páginas HTML]
    Build --> Index[Base SQLite de busca]
    HTML --> Host[Servidor]
    Index --> Host
    User[Leitor] --> API[Camada de busca]
    API --> Index
    API --> JSON[Resultados]
    JSON --> UI[Interface de busca]

Esse desenho preserva a simplicidade da hospedagem. O site continua estático na maior parte do tempo. A busca adiciona uma pequena API, mas o índice é um arquivo publicado junto com o site.

Por que indexar chunks em vez de artigos inteiros

Em artigos longos, indexar o artigo inteiro como uma única unidade produz resultados pouco precisos.

Esse caminho é comum no começo. O usuário busca, o sistema retorna artigos. Mas o artigo longo tem uma granularidade diferente. Um texto de uma hora pode falar de cinco assuntos com profundidade. Se o leitor busca "teorema CAP", retornar apenas o artigo inteiro obriga ele a procurar de novo dentro da página.

Por isso o índice foi criado por chunks.

Um chunk é um pedaço do artigo. Ele carrega o título do artigo, a categoria, tags, data, heading atual, texto do trecho, URL e âncora. Assim o resultado pode apontar para a seção correta.

O chunk muda a experiência porque o resultado passa a responder "onde está isso" em vez de apenas "em qual artigo existe algo sobre isso".

flowchart TD
    Article[Artigo longo] --> H2A[Seção sobre arquitetura]
    Article --> H2B[Seção sobre cache]
    Article --> H2C[Seção sobre CAP]
    H2A --> C1[Chunk 1]
    H2A --> C2[Chunk 2]
    H2B --> C3[Chunk 3]
    H2C --> C4[Chunk 4]
    C4 --> Result[Resultado com link direto para a seção]

O tamanho do chunk precisa de cuidado. Pequeno demais, ele perde contexto. Grande demais, ele volta a se comportar como artigo inteiro. Usei uma estratégia simples, quebrar por headings e limitar por quantidade aproximada de palavras, com pequena sobreposição entre blocos. A sobreposição reduz o risco de cortar um raciocínio no meio.

Essa escolha define a precisão do resultado antes mesmo de qualquer algoritmo de ranking entrar em cena.

Índice invertido e FTS5

O modelo clássico de busca textual nasce de uma estrutura chamada índice invertido.

Em um índice normal, você parte de um documento e encontra as palavras nele. Em um índice invertido, você parte de uma palavra e encontra os documentos onde ela aparece.

Se três chunks contêm a palavra "cache", o índice invertido guarda algo parecido com isto.

cache -> chunk 12, chunk 18, chunk 44
cap -> chunk 19, chunk 20
hexagonal -> chunk 91, chunk 93, chunk 94

Essa inversão explica por que mecanismos de busca conseguem responder rápido mesmo com muitos documentos. A consulta "cache cap" não precisa ler todos os textos. Ela consulta listas de ocorrência.

SQLite oferece FTS5, abreviação de Full Text Search 5. É uma extensão do SQLite para busca textual. Ela cria uma tabela virtual otimizada para procurar termos em campos de texto. A escolha por FTS5 veio de três fatores: já vem junto de muitas instalações do SQLite, não exige um serviço separado e funciona bem para um corpus pequeno como um blog.

No índice do blog, cada chunk entra tanto em tabelas de apresentação quanto em uma tabela FTS.

A tabela normal guarda os dados de apresentação e controle. A tabela FTS guarda os campos usados pela busca textual. Título, descrição, categoria, tags, heading e texto.

erDiagram
    TRECHOS {
        integer id
        string lang
        string slug
        string title
        string category
        string date
        string url
        string heading
        string excerpt
        string text
    }

    TRECHOS_FTS {
        string title
        string description
        string category
        string tags
        string heading
        string text
    }

    VOCABULARY {
        string lang
        string token
        integer doc_count
    }

    TRECHOS ||--|| TRECHOS_FTS : rowid

O FTS5 resolve a parte de localizar candidatos rapidamente. Depois disso ainda falta ordenar os resultados. Para essa ordenação textual entra o BM25.

Como o BM25 ordena os resultados

BM25 é um algoritmo de ranking textual. Ranking, nesse contexto, significa atribuir uma pontuação para cada resultado e ordenar os mais promissores primeiro. O nome completo, Okapi BM25, vem do sistema Okapi desenvolvido na City University London.

O objetivo do BM25 é responder uma pergunta prática. Quando uma consulta tem certos termos, quais chunks devem aparecer antes?

Ele usa três critérios principais.

Quando um termo da consulta aparece em um documento, isso aumenta o score. Quando aparece muitas vezes, aumenta mais, mas com saturação. A décima ocorrência não vale dez vezes a primeira. Depois de certo ponto, repetir a mesma palavra acrescenta pouca informação.

Quando um termo é raro no corpus, ele vale mais. Se a palavra "arquitetura" aparece em muitos artigos, ela ajuda, mas não diferencia tanto. Se "CAP" aparece em poucos chunks, ela carrega mais informação. Essa ideia é chamada IDF, inverse document frequency.

Quando um documento é muito longo, o BM25 ajusta a pontuação para evitar que textos grandes ganhem vantagem apenas por terem mais palavras. Isso é importante em artigos longos, porque um chunk maior naturalmente teria mais chances de conter qualquer termo.

A intuição do BM25 pode ser resumida assim.

score = raridade do termo
      * presença no documento com saturação
      * ajuste pelo tamanho do documento

Na fórmula completa, dois parâmetros controlam boa parte do comportamento. k1 regula a saturação da frequência do termo. Com k1 mais alto, repetir uma palavra no mesmo documento continua aumentando o score por mais tempo. Com k1 mais baixo, o ganho satura cedo. Isso evita que um trecho suba apenas porque repetiu a mesma palavra muitas vezes.

O parâmetro b controla o quanto o tamanho do documento pesa no ajuste. Com b mais alto, documentos longos são penalizados com mais força. Com b mais baixo, o tamanho importa menos. Em artigos longos, esse detalhe muda bastante a ordenação, porque um bloco maior tem mais chance de conter qualquer palavra por simples volume.

O FTS5 expõe o BM25 de uma forma prática. Em vez de reimplementar a fórmula inteira, eu uso a função bm25 e atribuo pesos diferentes por coluna. No índice do blog, título, heading e tags recebem mais peso que o corpo do texto. Isso respeita uma intuição editorial. Se um termo aparece no título de uma seção, ele provavelmente descreve melhor aquele trecho do que uma ocorrência isolada no meio de um parágrafo.

bm25(chunks_fts, 8.0, 3.0, 2.0, 2.5, 4.0, 1.0)

Esses pesos representam a importância relativa das colunas. Título pesa mais. Heading também. Texto pesa menos. A busca continua olhando tudo, mas entende que nem toda ocorrência tem o mesmo valor.

BM25 foi escolhido porque resolve bem a parte textual da busca sem exigir infraestrutura nova. Para esse tamanho de corpus, seria exagero começar por uma stack mais cara, como Elasticsearch, uma instalação dedicada de Lucene ou uma API externa de embeddings, inclusive OpenAI. Ele não entende o significado profundo do texto. Mesmo assim, costuma ordenar muito bem quando a consulta e o documento compartilham vocabulário.

Quando a consulta usa outra palavra, um erro de digitação ou uma sigla escrita de forma diferente, o BM25 precisa ser combinado com outros sinais.

Onde a busca textual tropeça

A busca textual tropeça em três situações comuns.

Primeiro, typo. A pessoa digita "hexagonil". O texto contém "hexagonal". Para um índice textual puro, são termos diferentes.

Segundo, siglas curtas. A pessoa digita "CAAP". O texto contém "CAP". A distância visual é pequena para uma pessoa. Para o índice, é outra palavra.

Terceiro, intenção. A pessoa digita uma formulação próxima, mas não idêntica. O texto talvez use uma sigla, um termo em inglês, uma variação técnica ou uma expressão mais específica. A busca textual só encontra bem aquilo que compartilha palavras.

Embeddings podem ajudar nesses casos, mas eles não eliminam problemas básicos de busca textual. A primeira decisão foi resolver o máximo possível com índice, ranking, correção de termos e sinais baratos de calcular. Cenários simples costumam pedir soluções simples. Além de mais baratas, elas são mais fáceis de explicar, testar e corrigir, a decisão então foi construir uma busca híbrida.

Busca híbrida como composição de aproximações

Busca híbrida significa combinar mais de um tipo de evidência para ordenar resultados.

Um sinal vem do BM25, usado quando as palavras da consulta aparecem no texto. Outro vem de um vetor lexical enriquecido, usado para aproximar termos relacionados dentro de um vocabulário conhecido. Outro vem da correção fuzzy, usada para typos. Outro vem de pesos editoriais, porque título e seção costumam descrever melhor o assunto do que uma ocorrência isolada no corpo.

Cada sinal cobre uma parte do problema. O ranking final soma essas evidências com pesos diferentes.

flowchart TD
    Query[Consulta do leitor] --> Normalize[Normalização]
    Normalize --> Correct[Correção por vocabulário]
    Correct --> FTS[Busca FTS5 e BM25]
    Correct --> Vector[Vetor lexical enriquecido]
    FTS --> Merge[Combinação de scores]
    Vector --> Merge
    Merge --> Dedup[Remoção de duplicidades por artigo e seção]
    Dedup --> Results[Resultados ordenados]

Essa composição tem uma vantagem operacional. A busca lexical depende apenas da base SQLite e de código comum de aplicação. Não existe modelo para carregar na memória nesse caminho. Não existe chave de API exposta. Não existe chamada externa a cada consulta. O custo por busca fica previsível.

Esse caminho tem limite. Um vetor lexical enriquecido aproxima termos, corrige entradas e melhora casos específicos do corpus. Quando a exigência passa a ser comparação semântica mais ampla, o próximo passo é gerar embeddings no build e salvar esses vetores no índice.

Normalização antes de qualquer algoritmo

Antes de falar em ranking, é preciso preparar a entrada.

Normalização é a etapa que reduz variações superficiais. O sistema converte texto para minúsculas, remove acentos, extrai tokens alfanuméricos e descarta stopwords. Assim, "Arquitetura", "arquitetura" e "arquitetúra" caminham para representações próximas.

Stopwords são palavras frequentes com pouco valor discriminativo. "de", "a", "o", "para", "com", "the", "and". Removê-las reduz ruído no índice e na consulta.

Isso também tem risco. Em alguns domínios, uma palavra curta pode ser importante. Em tecnologia, "io", "ai", "cap", "cpu" e "ddd" importam. Por isso a lista de stopwords precisa ser pequena e ajustada ao corpus. Uma lista genérica agressiva poderia remover justamente os termos técnicos que dão valor à busca.

Em um blog sobre engenharia de software, siglas, nomes de padrões, termos em inglês e português aparecem misturados. A busca precisa preservar esse vocabulário técnico em vez de aplicar uma limpeza genérica demais.

Correção fuzzy usando o vocabulário do corpus

Correção fuzzy é a etapa que tenta aproximar uma palavra digitada de uma palavra existente no índice. Ela foi adicionada para casos como hexagonil e CAAP.

Durante o build, o índice cria um vocabulário do corpus. Ele contém os tokens encontrados nos artigos e a quantidade de chunks onde cada token aparece.

Quando chega uma consulta, o sistema verifica se cada termo existe no vocabulário. Se existir, usa o termo como veio. Se não existir, procura candidatos parecidos dentro do vocabulário.

Essa busca por candidatos usa duas ideias.

A primeira é similaridade por trigramas. Um trigrama é uma sequência de três caracteres. A palavra hexagonil gera pedaços como hex, exa, xag, ago, gon, oni, nil. A palavra hexagonal gera muitos pedaços parecidos. A interseção entre esses conjuntos dá uma pista de proximidade.

A segunda é distância de edição, também conhecida como distância de Levenshtein. Ela mede quantas operações são necessárias para transformar uma palavra em outra. Inserir, remover ou substituir caracteres.

CAAP para CAP tem distância pequena. Basta remover um A. hexagonil para hexagonal também fica próximo o suficiente para ser corrigido.

O algoritmo evita corrigir qualquer coisa para qualquer coisa. Ele só compara candidatos com tamanho próximo, exige similaridade mínima por trigramas e aplica limite de distância conforme o tamanho do termo. Termos curtos têm limite menor, porque uma letra muda bastante o significado de uma sigla.

Depois da correção, a consulta carrega dois conjuntos de informação. Os termos corrigidos usados internamente e um mapa de correções que pode ser devolvido no JSON.

def correction_distance_limit(term):
    if len(term) <= 4:
        return 1
    if len(term) <= 8:
        return 2
    return 3


def correction_candidates(term, vocab_map, limit):
    candidates = []
    for token, doc_count in vocab_map.items():
        if token == term:
            continue
        if abs(len(token) - len(term)) > limit:
            continue
        similarity = trigram_similarity(term, token)
        if similarity < 0.28:
            continue
        distance = edit_distance(term, token, limit)
        if distance <= limit:
            candidates.append((distance, -similarity, -doc_count, token, doc_count))
    candidates.sort()
    return candidates


def should_correct_existing_term(term_count, best_count):
    return term_count <= 10 and best_count >= max(term_count * 2, term_count + 3)


def correct_query_terms(conn, terms, lang):
    if not terms:
        return terms, {}

    vocabulary = conn.execute(
        "SELECT token, doc_count FROM vocabulary WHERE lang = ?",
        (lang,),
    ).fetchall()
    vocab_map = {row["token"]: row["doc_count"] for row in vocabulary}
    corrected = []
    corrections = {}

    for term in terms:
        term_count = vocab_map.get(term)
        limit = correction_distance_limit(term)
        candidates = correction_candidates(term, vocab_map, limit)

        if term_count is not None:
            if candidates and should_correct_existing_term(term_count, candidates[0][4]):
                best = candidates[0][3]
                corrections[term] = best
                corrected.append(best)
            else:
                corrected.append(term)
            continue

        if candidates:
            best = candidates[0][3]
            corrections[term] = best
            corrected.append(best)
        else:
            corrected.append(term)

    deduped = []
    for term in corrected:
        if term not in deduped:
            deduped.append(term)
    return deduped, corrections

O código tem quatro travas importantes. A primeira é o limite por tamanho da palavra. Termos curtos, como siglas, só aceitam distância 1. A segunda é a similaridade mínima por trigramas, que reduz candidatos aleatórios. A terceira é a ordenação dos candidatos por distância, similaridade e frequência no corpus. A quarta permite corrigir até um termo que existe no vocabulário, mas só quando ele é raro e existe um candidato próximo muito mais frequente. Isso evita um efeito colateral comum em blogs técnicos: o próprio artigo sobre busca passa a citar exemplos errados, como hexagonil e CAAP, e esses exemplos entram no vocabulário. Assim, CAAP pode cair em CAP, e hexagonil pode cair em hexagonal, sem abrir espaço para correções agressivas demais.

Essa transparência ajuda muito na depuração. Quando uma busca parece estranha, dá para verificar se o problema está na correção, no ranking ou no conteúdo indexado.

O vetor lexical enriquecido

O vetor lexical implementado aqui não usa rede neural. Ele transforma características textuais em números para permitir uma comparação aproximada entre consulta e chunk.

A ideia é transformar cada chunk em um vetor esparso de dimensões fixas. Esparso significa que a maioria das posições fica vazia. Em vez de guardar 384 números para cada chunk, guardamos apenas as posições que receberam algum peso.

Cada token gera uma feature. Bigramas também geram features. Um bigrama combina dois termos consecutivos, como teorema_cap ou arquitetura_hexagonal. Isso ajuda a capturar expressões compostas.

Também entram trigramas de caracteres, com peso menor. Eles ajudam em typos e variações próximas, embora sozinhos possam gerar ruído. Por isso o peso é baixo.

Há ainda um pequeno mapa de conceitos. Quando o texto contém termos como arquitetura, hexagonal, mvc, camadas e sistema, o vetor recebe features relacionadas. Quando contém cache, redis, ttl, latência, também recebe aproximações conceituais. É um mapa manual, pequeno, explícito e fácil de revisar.

Esse mapa não tenta representar toda a língua. Ele registra relações úteis para o domínio do blog.

O cálculo usa uma técnica chamada feature hashing. Cada característica textual passa por uma função de hash e cai em uma posição do vetor.

"tok:hexagonal"        -> posição 91
"bi:arquitetura_hexagonal" -> posição 214
"tri:hex"             -> posição 37
"concept:arquitetura" -> posição 188

Depois disso, o vetor é normalizado. Normalizar significa ajustar os pesos para que o comprimento do vetor seja 1. Isso permite comparar vetores por cosseno.

Similaridade por cosseno

Até aqui, o índice textual estava preocupado em descobrir onde uma palavra aparece. O vetor muda um pouco a pergunta. Em vez de perguntar apenas "este trecho contém este termo?", ele permite perguntar "este trecho tem sinais parecidos com a consulta?".

Transformar texto em vetor significa transformar palavras e características do texto em números. Uma posição do vetor pode representar um token, como arquitetura. Outra pode representar um bigrama, como arquitetura_hexagonal. Outra pode representar um trigrama, como hex. Outra pode representar um conceito manual, como concept:arquitetura.

consulta: arquitetura hexagonil

tok:arquitetura     -> peso 1.0
tri:hex             -> peso 0.16
tri:exa             -> peso 0.16
concept:arquitetura -> peso 0.8

Um chunk sobre arquitetura hexagonal recebe sinais parecidos, mesmo que a palavra hexagonil esteja errada. Isso não significa que o vetor "entendeu" a consulta. Significa que os sinais numéricos da consulta e do trecho ficaram próximos o suficiente para justificar uma comparação.

Similaridade por cosseno mede essa proximidade olhando para a direção dos vetores.

Se dois textos apontam para direções parecidas no espaço vetorial, o cosseno fica alto. Se apontam para direções diferentes, fica baixo.

Essa escolha tem uma consequência importante. O cosseno compara direção, não tamanho bruto. Um trecho longo não deve vencer apenas porque tem mais palavras e, portanto, mais sinais. Depois da normalização, o que importa é a proporção dos sinais compartilhados.

O valor varia geralmente entre 0 e 1 quando usamos pesos positivos. Quanto mais próximo de 1, mais semelhantes são os vetores.

similaridade = produto_escalar(vetor_query, vetor_chunk)

Como os vetores já estão normalizados, o produto escalar basta.

Esse sinal vetorial entra como bônus no ranking. Se o vetor leve tiver peso demais, ele começa a puxar resultados por semelhanças superficiais. Foi o que apareceu na primeira tentativa. hexagonil trazia resultados sem relação forte com arquitetura hexagonal porque a similaridade por trigramas encontrava proximidades fracas em outros textos.

A correção veio de duas mudanças. Primeiro, corrigir termos contra o vocabulário antes da busca. Segundo, exigir uma similaridade mínima para que resultados puramente vetoriais entrem no conjunto de candidatos.

Por isso o vetor lexical entra com limite mínimo de similaridade e peso controlado. Ele amplia candidatos, mas não decide sozinho.

Como o ranking final é montado

O ranking final combina sinais.

O FTS5 retorna candidatos ordenados por BM25. O sistema também calcula similaridade vetorial entre a consulta e os chunks. Depois calcula pesos adicionais para título, tags, categoria, heading, frase exata e cobertura dos termos.

Cobertura significa quantos termos da consulta aparecem no chunk. Se a consulta tem cache cap, um chunk que contém os dois termos deve ganhar mais confiança do que um chunk que contém apenas cache.

Também existe deduplicação por artigo e seção. Sem isso, um artigo longo pode dominar a lista com vários chunks muito parecidos. O objetivo da busca é ajudar o leitor a decidir para onde ir, não mostrar dez variações do mesmo parágrafo.

O resultado final é calibrado para a experiência de leitura. O ranking precisa respeitar o comportamento do corpus e a expectativa do usuário. Se uma fórmula privilegia resultados tecnicamente próximos, mas pouco úteis para navegação, ela precisa ser ajustada.

Em pseudocódigo, o ranking fica próximo disto.

consulta = normalizar(entrada)
consulta = corrigir_por_vocabulario(consulta)

candidatos_textuais = buscar_por_fts5(consulta)
candidatos_vetoriais = buscar_por_vetor_lexical(consulta)
candidatos_densos = buscar_por_embedding(consulta) se disponível

para cada candidato
  score = 0
  score += peso_textual * bm25(candidato)
  score += peso_lexical * similaridade_lexical(candidato)
  score += peso_denso * similaridade_densa(candidato)
  score += bonus_titulo_se_conter_termos(candidato)
  score += bonus_heading_se_conter_termos(candidato)
  score += bonus_frase_exata(candidato)
  score += bonus_cobertura_de_termos(candidato)

ordenar por score
remover duplicidades excessivas do mesmo artigo
retornar os melhores resultados

O pseudocódigo mostra por que a busca híbrida precisa ser calibrada. Cada sinal tem uma função. BM25 organiza vocabulário compartilhado. Fuzzy corrige entrada. Vetores aproximam. Pesos editoriais ajudam a decidir se a ocorrência está em um lugar importante do artigo.

A interface precisa explicar o resultado

Busca não termina no JSON retornado pela API.

A interface decide se o resultado parece útil. Um resultado com título, categoria, data, seção e trecho orienta o leitor. Um resultado que despeja parágrafos longos cansa antes do clique.

Por isso os resultados foram compactados. O título vem em destaque. A categoria e data aparecem como metadado. A seção aparece abaixo, em cor de destaque. O trecho tem limite visual. O link inteiro é clicável.

O highlight ajuda o olho a encontrar o motivo do resultado. Ele precisa ser seguro. O código não injeta HTML vindo da API. Ele escapa o texto e só marca tokens da consulta dentro do conteúdo já tratado.

flowchart TD
    JSON[JSON da API] --> Escape[Escape HTML]
    Escape --> Tokenize[Separação em palavras]
    Tokenize --> Mark[Aplicação de mark nos termos]
    Mark --> DOM[Renderização segura]

Esse detalhe continua dentro do tema da busca porque ranking bom e apresentação ruim produzem a mesma sensação de falha. Se o leitor não entende por que um resultado apareceu, ele desconfia do mecanismo.

Por que SQLite antes de Postgres ou banco vetorial

Também seria possível usar um banco relacional remoto ou um banco vetorial. A escolha por SQLite foi uma escolha pela simplicidade.

O conteúdo do blog nasce estático. Os artigos mudam no repositório. O build já é o momento em que o site é gerado. Nesse cenário, uma base SQLite produzida pela esteira encaixa muito bem.

Postgres faria sentido se a busca precisasse guardar eventos, histórico de consultas, estatísticas, preferências, feedback do usuário ou conteúdo editado diretamente no servidor. Para consultar um índice gerado a partir de Markdown, ele adicionaria uma etapa de carga e sincronização que não traria muito ganho neste momento.

Banco vetorial teria seu lugar se houvesse muitos milhares ou milhões de chunks, ou se o tempo de comparação vetorial virasse gargalo. Para algumas centenas de chunks, consultar SQLite e calcular scores na aplicação é suficiente.

Essa escolha não impede evolução. O índice já guarda campos suficientes para trocar ou complementar o mecanismo de ranking no futuro.

O que a esteira passou a garantir

Um sistema de busca só é confiável quando a geração do índice faz parte do build.

Se alguém altera um artigo e esquece de atualizar o índice, a busca envelhece. Se o índice depende de uma execução manual, cedo ou tarde alguém esquece. Por isso o build gera a base SQLite. O deploy envia o índice junto com o site.

Além disso, a esteira passou a validar consultas específicas.

Ela verifica se a base SQLite existe e executa consultas que representam casos importantes.

hexagonil precisa encontrar hexagonal.

CAAP precisa encontrar CAP.

Esses testes são pequenos e protegem uma parte importante do comportamento. Eles impedem que uma alteração futura remova a correção fuzzy ou quebre a geração do vocabulário sem ser percebida.

sequenceDiagram
    participant CI as Esteira
    participant Build as Build do site
    participant Index as Base SQLite
    participant Test as Validação de busca
    participant Host as Servidor

    CI->>Build: gera HTML e índice
    Build->>Index: cria chunks, FTS, vocabulário e vetores
    CI->>Test: consulta hexagonil
    Test->>Index: espera resultado com hexagonal
    CI->>Test: consulta CAAP
    Test->>Index: espera resultado com CAP
    CI->>Host: publica site e índice

Quando a busca é parte da experiência principal, ela merece teste como qualquer outro comportamento.

Embeddings densos como camada semântica

O próximo salto foi testar embeddings densos sem API externa.

Nesse modelo, cada chunk passa por um modelo de embeddings durante o build. A consulta também vira vetor na hora da busca. Depois disso, o sistema compara o vetor da consulta com os vetores dos chunks usando similaridade por cosseno.

O desenho fica assim. O trabalho pesado dos artigos continua fora da requisição. Markdown entra no build, os chunks são extraídos, o modelo open source gera um vetor para cada chunk e esses vetores são salvos no próprio SQLite. Na hora da busca, o servidor precisa gerar apenas um vetor pequeno para a frase digitada pelo leitor.

Para esse teste, usei uma biblioteca open source de embeddings com um modelo multilíngue pequeno. Ele é leve o suficiente para ser considerado em uma hospedagem simples, atende português e inglês, e gera vetores pequenos o bastante para serem guardados dentro do próprio SQLite sem criar uma infraestrutura nova.

O teste mostrou uma separação útil entre tipos de problema. A consulta sistema lento encontrou o artigo sobre saturação progressiva com ajuda do embedding denso, porque a frase descreve uma situação próxima do conteúdo do artigo. Já hexagonil e CAAP continuam sendo casos melhores para correção fuzzy, porque são erros de digitação e variação de sigla. Um modelo semântico pode aproximar ideias, mas não deve ser usado para resolver tudo.

Por isso a busca ficou híbrida.

flowchart TD
    Query[Consulta do leitor] --> Normalize[Normalização e tokens]
    Normalize --> Fuzzy[Correção fuzzy pelo vocabulário]
    Fuzzy --> BM25[FTS5 e BM25]
    Fuzzy --> Sparse[Vetor lexical enriquecido]
    Query --> Dense[Embedding denso opcional]
    BM25 --> Rank[Combinação de scores]
    Sparse --> Rank
    Dense --> Rank
    Rank --> Results[Resultados com trecho, seção e link direto]

O BM25 continua cuidando das palavras que batem. A correção fuzzy continua cuidando de hexagonil e CAAP. O vetor lexical enriquecido continua barato e previsível. O embedding denso entra como mais um sinal, especialmente útil quando a pessoa descreve uma ideia com outras palavras.

Esse detalhe importa. Busca semântica boa raramente nasce de trocar um algoritmo por outro. Ela melhora quando sinais diferentes são combinados com pesos claros. Um resultado que acerta termo no título, aparece em uma seção adequada, tem boa pontuação BM25 e também fica perto no espaço vetorial merece subir. Um resultado que só ficou perto no embedding, mas não tem nenhum outro indício editorial, precisa entrar com cuidado para evitar uma lista confusa.

No índice atual, os embeddings densos são opcionais. Se a biblioteca de embeddings não estiver disponível, a busca continua funcionando com as camadas anteriores. Se o build for executado com embeddings ligados, o SQLite passa a carregar os vetores dos chunks. Se a consulta for executada com busca densa habilitada, a aplicação gera o vetor da consulta e mistura a similaridade por cosseno no score final.

Essa decisão preserva uma qualidade importante da arquitetura. O site funciona sem API paga e sem banco vetorial. Para algumas centenas ou poucos milhares de chunks, comparar vetores contra o SQLite ainda é aceitável. Quando o volume crescer, aí sim faria sentido olhar para FAISS, hnswlib, sqlite-vec, pgvector ou outro índice aproximado.

A busca ficou pronta para crescer sem antecipar uma infraestrutura que o blog ainda não precisa.

Mantendo a camada semântica barata

flowchart TD
    Browser[Leitor] --> Web[Camada web]
    Web --> Service[Serviço local de busca]
    Service --> Model[Modelo carregado uma vez]
    Service --> SQLite[Base SQLite]
    Service --> Response[Resultados]
    Web --> Fallback[Fallback lexical]

Gerar embeddings no build resolve metade do custo. A outra metade é evitar carregar o modelo a cada tecla digitada. Por isso a camada semântica roda como um serviço local persistente. O modelo carrega uma vez, as consultas reaproveitam esse processo, e a camada web mantém um fallback lexical caso a parte densa esteja indisponível.

A prova mais direta vem da própria resposta da camada de busca. Uma consulta para sistema lento retornou o artigo sobre saturação progressiva com sinal textual e sinal vetorial denso.

consulta: sistema lento
resultado: Saturação progressiva
sinais: score textual + score vetorial denso
estado: busca densa ativa

Esse retorno mostra que a camada densa está participando do ranking. Ele também ajuda a manter a expectativa no lugar certo. Embedding denso melhora consultas por intenção, como sistema lento. Correção fuzzy continua melhor para erro de digitação, como hexagonil. Distância de edição continua melhor para sigla digitada com uma letra a mais, como CAAP.

Quando um artigo novo entra, o build gera uma nova base SQLite e o deploy publica esse arquivo junto com o site. O serviço observa a data de modificação da base. Quando percebe que o índice mudou, limpa o cache e passa a abrir a versão atualizada nas próximas consultas.

O fluxo ficou assim.

sequenceDiagram
    participant Build as Esteira
    participant Site as Arquivos publicados
    participant Service as Serviço de busca
    Build->>Build: gera HTML e base SQLite
    Build->>Site: publica pacote novo
    Service->>Site: detecta mudança na base
    Service->>Service: limpa cache de consultas
    Service->>Site: usa índice atualizado

Essa decisão mantém embeddings densos no servidor, mas evita multiplicar processos por tecla digitada. É uma forma de respeitar a infraestrutura sem desistir da intenção original.

A busca depois da publicação

A busca não termina quando o endpoint responde.

Depois que o mecanismo entra no site, aparecem perguntas que o código sozinho não responde. Quais termos as pessoas digitam? Quais consultas voltam vazias? Quais resultados aparecem no topo e não recebem clique? Quais palavras o leitor usa que o autor quase nunca usa?

Se esse blog passar a coletar analytics de busca, o Postgres volta a entrar na conversa. Ele poderia guardar termo pesquisado, idioma, quantidade de resultados, primeiro resultado, clique ou ausência de clique. Com isso, seria possível ajustar vocabulário, criar aliases e escrever artigos onde há demanda.

Essa camada não precisa existir no primeiro momento. Para começar, log local, testes de consultas conhecidas e revisão manual dos resultados já dão sinais suficientes. Quando o volume justificar, o mesmo desenho aceita uma tabela de eventos sem trocar o índice inteiro.

Busca revela o vocabulário do leitor. Às vezes o leitor procura por uma palavra que o autor nunca usa. Essa diferença mostra uma distância entre como o conteúdo foi escrito e como ele é procurado.

Fechamento

O mecanismo implementado neste blog nasceu de uma restrição simples. Eu queria busca inteligente sem transformar a hospedagem em uma plataforma complexa.

A solução ficou em camadas.

Markdown vira HTML e também vira índice. Artigos longos viram chunks. Chunks entram no SQLite. FTS5 oferece busca textual rápida. BM25 ordena por relevância. Um vocabulário do corpus corrige typos. Um vetor lexical enriquecido aproxima alguns conceitos dentro do domínio. A interface destaca termos, permite navegação por teclado e aponta direto para a seção.

O resultado ainda pode evoluir. Analytics pode entrar depois. Um banco vetorial pode entrar depois. Um modelo maior pode entrar depois. Mas a busca já deixou de ser uma comparação literal de strings. Ela passou a combinar o que o leitor digitou, o vocabulário dos artigos, o ranking textual, a correção de erro e a proximidade vetorial.

Esse é o ponto mais importante da implementação. Situações simples foram tratadas com soluções simples e baratas. hexagonil e CAAP precisavam de vocabulário e fuzzy, não de modelo maior. sistema lento se beneficiou de embedding denso porque descreve uma situação, não apenas uma palavra. A arquitetura ficou em camadas para que cada problema pague apenas o custo que precisa pagar.