Construindo um Retriever Híbrido para 16.894 Arquivos do Obsidian
Um grep em 16.894 arquivos markdown leva de 11 a 66 segundos dependendo do termo e retorna centenas de resultados de baixa relevância. Uma busca vetorial retorna conteúdo semanticamente relacionado, mas não encontra o nome exato da função que você digitou. Um retriever híbrido que funde ambos os métodos retorna a resposta certa em 23 milissegundos (ponta a ponta, incluindo embedding da consulta) a partir de um único arquivo SQLite de 83 MB com zero chamadas de API.1
O problema do tomador de notas obsessivo não é a coleta. O problema é a recuperação. O Obsidian torna a captura sem atrito. Acumule arquivos suficientes e o vault se torna um banco de dados somente escrita: fácil de adicionar, impossível de consultar. A busca por nome de arquivo funciona até que os nomes se tornem sem sentido. A busca full-text funciona até que a mesma palavra-chave apareça em 400 documentos. Tags funcionam até que você esqueça de tagar algo.
Um comentarista do HN pediu a arquitetura completa por trás do sistema de recuperação que construí para meu vault do Obsidian.2 Aqui está: a estratégia de chunking, o modelo de embedding, o schema SQLite com índice duplo, a matemática de fusão com números reais e os modos de falha que encontrei após consultar o sistema centenas de vezes.
TL;DR
O retriever combina busca por palavras-chave FTS5 BM25 com busca por similaridade vetorial Model2Vec, fundidas via Reciprocal Rank Fusion (RRF) em uma lista única ranqueada. Tudo roda localmente em um único banco de dados SQLite: 49.746 chunks de 16.894 arquivos em 83 MB. A reindexação completa leva quatro minutos. Atualizações incrementais rodam em menos de dez segundos. O sistema se integra ao Claude Code através de hooks, dando ao agente acesso ao conhecimento do vault sem carregar arquivos no contexto. O BM25 captura identificadores exatos e nomes de funções. A busca vetorial captura correspondências semânticas entre terminologias diferentes. O RRF mescla ambos sem exigir calibração de scores. O trade-off honesto: conteúdo superficial bem tagueado pode superar conteúdo profundo mal estruturado porque o BM25 recompensa densidade de palavras-chave, não profundidade.
Pontos-Chave
Para tomadores de notas com vaults grandes. Na minha experiência, a busca full-text sozinha se tornou inutilizável acima de alguns milhares de arquivos — e os plugins de busca existentes do Obsidian (Smart Connections, Omnisearch) indexam dentro do aplicativo, não como uma biblioteca externa que outras ferramentas podem consultar.1 Adicionar busca vetorial sobre o BM25 captura as consultas em que você lembra do conceito mas não da palavra-chave. O retriever roda inteiramente em SQLite sem serviços externos, sem GPU e sem custos de API. O Model2Vec embeda na velocidade da CPU porque o modelo é 30 MB de vetores de palavras estáticos, não um transformer.3
Para desenvolvedores construindo sistemas de recuperação. O RRF é o método de fusão que exige o mínimo de ajuste. A fórmula usa apenas posições de ranking, não scores brutos, então você nunca precisa calibrar scores BM25 contra distâncias de cosseno. Comece com k=60 e pesos iguais. Ajuste somente após medir casos de falha nos seus próprios dados. A extensão sqlite-vec traz busca vetorial KNN para dentro do SQLite sem precisar de um banco de dados vetorial separado.4
Para usuários do Claude Code. O retriever roda como uma biblioteca que hooks podem chamar. Um hook PreToolUse consulta o vault antes de o agente começar a trabalhar. O agente vê 2-3 KB de resultados focados com atribuição de caminho de arquivo em vez de carregar arquivos inteiros. A integração mantém as janelas de contexto pequenas enquanto dá ao agente acesso a 16.894 arquivos de conhecimento.
Versão mínima viável. O ponto de partida mais simples: crie uma tabela virtual FTS5 sobre seus arquivos markdown (somente BM25, sem embeddings). Adicione sqlite-vec e Model2Vec quando a busca por palavras-chave começar a perder correspondências semânticas. Adicione a fusão RRF por último. Cada camada funciona independentemente. O stack completo requer Python 3, um download de modelo de 30 MB e pip install model2vec sqlite-vec. Sem GPU, sem Docker, sem serviços externos. Espaço total em disco para 16.894 arquivos: 83 MB.
Quer o guia operacional completo? A referência de Infraestrutura de IA para Obsidian cobre arquitetura do vault, configuração de plugins, configuração de servidor MCP, receitas de indexação incremental e solução de problemas — o companheiro passo a passo para o mergulho arquitetural deste post.
Por Que a Busca por Palavras-Chave Sozinha Falha em Escala
A busca full-text se degrada em escala de vault de formas previsíveis. O FTS5 com ranking BM25 se destaca em correspondências exatas: busque por requestAnimationFrame e todo arquivo contendo aquele token exato aparece, ranqueado por frequência do termo e comprimento do documento.5 A pesquisa de Robertson e Zaragoza sobre modelos de relevância probabilística confirma a força do BM25: o algoritmo performa bem em consultas com muitas palavras-chave e ajuste mínimo de parâmetros.14 O modo de falha são sinônimos e correspondência de conceitos. Busque por “como lidar com falhas de autenticação” e o BM25 retorna todo arquivo que menciona “autenticação” ou “falhas” individualmente, diluindo os resultados com conteúdo tangencialmente relacionado.
A busca vetorial resolve o problema de sinônimos. Incorpore a consulta e encontre chunks cujos embeddings estão próximos no espaço vetorial. “Como lidar com falhas de autenticação” corresponde a conteúdo sobre “recuperação de erros de login” e “tratamento de expiração de sessão” porque o embedding captura similaridade semântica entre terminologias diferentes.6 Karpukhin et al. demonstraram com Dense Passage Retrieval (DPR) que embeddings densos superam o BM25 em question answering de domínio aberto por 9-19% em acurácia top-20, precisamente porque representações densas capturam significado além da sobreposição lexical.15 O modo de falha é o oposto: a busca vetorial não encontra identificadores exatos. Busque pelo nome de função _rrf_fuse e a busca vetorial retorna conteúdo sobre fusão e algoritmos de ranking, mas pode classificar a definição real da função abaixo de uma explicação conceitual.
Nenhum método sozinho cobre ambos os modos de falha. Uma única consulta ilustra a diferença (não é prova de superioridade — avaliação agregada requer um conjunto golden, que o sistema ainda não possui). A consulta “PostToolUse hook for context compression” retorna diferentes top-3 resultados de cada método:
| Rank | Somente BM25 | Somente Vetorial | Híbrido (RRF) |
|---|---|---|---|
| 1 | hook-stdlib.sh “PostToolUse Handler” | context-is-the-new-memory.md “Compression Layers” | context-is-the-new-memory.md “Compression Layers” |
| 2 | settings.json “PostToolUse Events” | token-budget-analysis.md “Context Engineering” | hook-stdlib.sh “PostToolUse Handler” |
| 3 | compress-output.sh “Tool Output Filter” | agent-memory-patterns.md “Retrieval Integration” | compress-output.sh “Tool Output Filter” |
O BM25 encontrou o arquivo de hook exato e a referência de configurações (correspondência de palavra-chave em “PostToolUse”) mas perdeu a nota conceitual sobre engenharia de contexto. A busca vetorial encontrou as notas sobre estratégia de compressão (correspondência semântica em “context compression”) mas perdeu a implementação específica do hook. O RRF promoveu as notas que importam tanto para o conceito quanto para a implementação, colocando a nota de estratégia e o arquivo de hook nas posições um e dois.13
Pesquisas sobre o ranking de passagens do MS MARCO apoiam o padrão em benchmarks de busca web: a recuperação híbrida consistentemente supera tanto o BM25 quanto a recuperação densa sozinhos, com os maiores ganhos em consultas que contêm tanto termos específicos quanto conceitos abstratos.716
A Arquitetura: Três Camadas que se Potencializam
O sistema possui três camadas independentes. Cada uma funciona sem as outras, mas juntas elas se potencializam.
Camada 1: Ingestão. Um pipeline de scoring de sinais de 733 linhas em Python avalia cada sinal recebido em quatro dimensões: relevância, acionabilidade, profundidade e autoridade. Sinais com pontuação de 0,55 ou acima são roteados automaticamente para uma das 12 pastas de domínio. Sinais entre 0,40 e 0,55 entram em fila para revisão manual. Abaixo de 0,40, o pipeline descarta o sinal. O pipeline processou 7.771 sinais ao longo de 14 meses sem tagueamento manual.1 A camada de ingestão determina o que entra no vault. A camada de recuperação torna tudo encontrável.
Camada 2: Recuperação. O motor de busca híbrida detalhado abaixo. O motor divide cada arquivo em limites de cabeçalho, embeda os chunks com Model2Vec e os indexa no SQLite com uma tabela vec0 para KNN vetorial e uma tabela virtual FTS5 para BM25. Uma consulta roda contra ambos os índices simultaneamente, e o RRF funde os resultados em uma lista única ranqueada.
Camada 3: Integração. Hooks do Claude Code que conectam o retriever ao fluxo de trabalho do agente. Um hook dispara no envio do prompt, consulta o vault por contexto relevante e injeta os resultados principais na conversa. O agente vê chunks focados com atribuição de fonte em vez de conteúdo bruto de arquivos:
# Illustrative output (format matches production, content simplified)
## Relevant Memory Context
### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...
### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...
Cada resultado carrega o cabeçalho da seção e o projeto de origem, limitado a um orçamento de 500 tokens para evitar inchaço de contexto.
O retriever também habilita um segundo ponto de integração: um hook PostToolUse que comprime saídas de ferramentas antes de entrarem na conversa. A saída bruta de ferramentas contém timestamps, artefatos de ordenação e formatação verbosa que variam entre execuções. O retriever substitui o despejo bruto por um subconjunto estável e focado. O agente nunca vê o ruído, apenas o extrato relevante. Um benefício colateral: como a saída do retriever é determinística para a mesma consulta (o mesmo estado de índice produz os mesmos resultados ranqueados), a saída comprimida ajuda no prompt caching. Consultas repetidas contra dados inalterados produzem blocos de contexto idênticos, e o cache automático de prompts da CLI reutiliza o prefixo cacheado.
A história mais ampla da infraestrutura explica como hooks, skills e agentes se compõem em uma camada programável ao redor do modelo.
As camadas são desacopladas por design. O scoring de ingestão não sabe nada sobre embeddings. O retriever não sabe nada sobre regras de roteamento de sinais. Mas a ingestão garante que o vault contenha conteúdo de alta qualidade, a recuperação revela o subconjunto certo para qualquer consulta, e a integração entrega esse subconjunto ao agente sem inchaço de contexto. Eu escrevi sobre o enquadramento teórico de contexto como recurso crítico. O retriever é a implementação prática.
Chunking: Onde a Qualidade da Recuperação Começa
O chunking determina a granularidade dos resultados de busca. Chunks muito grandes e a busca vetorial retorna arquivos inteiros onde apenas um parágrafo é relevante. Chunks muito pequenos e o embedding perde o contexto necessário para correspondência semântica. Pesquisas sobre pipelines de RAG confirmam que o tamanho do chunk tem impacto maior na qualidade da recuperação do que a escolha do modelo para a maioria dos casos de uso, com chunks de 200-500 tokens performando melhor para tarefas de recuperação no nível de parágrafo.18
O chunker divide nos limites de cabeçalho H2 (##), preservando a estrutura markdown.8 Uma nota sobre rotação de tokens OAuth com três seções H2 se torna três chunks, cada um autossuficiente o bastante para que o embedding capture seu significado. O indexador armazena o texto do cabeçalho e o título da nota pai como metadados junto a cada chunk, fornecendo contexto para correspondência BM25 mesmo quando o texto do chunk em si é esparso.
# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000
def _split_at_headings(body):
sections = []
current_heading = ""
current_lines = []
for line in body.split("\n"):
if line.startswith("## "):
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
current_heading = line[3:].strip()
current_lines = []
else:
current_lines.append(line)
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
return sections
O chunker divide seções que excedem 2.000 caracteres adicionalmente: primeiro nos limites H3, depois nas quebras de parágrafo. Ele descarta seções abaixo de 30 caracteres. O chunker também pula seções Related, See Also, Links e References, que tipicamente são listas de wiki-links em vez de conteúdo pesquisável.
Duas escolhas de design importam para a qualidade da recuperação. Primeiro, o indexador armazena a string de contexto do cabeçalho ("OAuth Token Rotation | note | security, authentication") em uma coluna separada e a indexa no FTS5 com um peso menor (0,3) do que o texto do chunk (1,0). O BM25 ainda corresponde ao cabeçalho quando o corpo do chunk não contém o termo de busca, mas a correspondência de cabeçalho pontua menos que uma correspondência no corpo. Segundo, o chunker extrai tags de frontmatter e tipo da nota e os inclui no contexto do cabeçalho, então uma busca por “security” corresponde a notas tagueadas com security mesmo quando o texto do corpo usa terminologia diferente.
Embedding: Modelo de 30 MB, Zero Chamadas de API
O modelo de embedding é o potion-base-8M do Model2Vec, um modelo de embedding de palavras estáticas com 7,6 milhões de parâmetros produzindo vetores de 256 dimensões.3 No conjunto de benchmarks MTEB, o potion-base-8M atinge 89% do desempenho do all-MiniLM-L6-v2 (50,03 vs 56,09 de média) a até 500x a velocidade de inferência, tornando-o prático para indexar grandes corpora em hardware de consumo.917 Uma ressalva: o sub-score de Retrieval do modelo no MTEB é notavelmente menor (31,71) do que seus scores de Classification (64,44) ou STS (73,24). Os benchmarks de recuperação do MTEB testam ranking no nível de documento em corpora web, não correspondência no nível de parágrafo em chunks homogêneos de markdown. A diferença importa menos quando os chunks são curtos, tematicamente focados e escritos em vocabulário consistente. Diferente de modelos de embedding baseados em transformer, o Model2Vec não executa camadas de atenção sobre a entrada. O modelo destila o conhecimento de um sentence transformer em embeddings de token estáticos, produzindo vetores por meio de média ponderada em vez de computação sequencial.9
Por que embeddings estáticos funcionam para este caso de uso? Chunks curtos de markdown (200-400 palavras em média) contêm vocabulário concentrado sobre um único tópico. A média ponderada desses vetores de token cai em uma região significativa do espaço de embedding porque há pouca diluição fora do tópico. Na prática, um documento de 2.000 palavras cobrindo três assuntos diferentes tende a produzir um centróide borrado que fica entre clusters de tópicos em vez de dentro de um. Um chunk sobre rotação de tokens OAuth, em contraste, produz um vetor que se agrupa fortemente com outro conteúdo sobre autenticação. Embeddings estáticos trocam desambiguação contextual (a palavra “banco” em “banco do rio” vs “banco de dados”) por velocidade bruta. Em uma base de conhecimento pessoal onde cada chunk cobre um conceito, a penalidade por ambiguidade é pequena e o artigo relata até 500x de aceleração na inferência.9
# embedder.py: lazy-loading Model2Vec in a dedicated venv
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256
class Model2VecEmbedder:
def __init__(self, model_name=DEFAULT_MODEL):
self._model_name = model_name
self._model = None
def _ensure_model(self):
if self._model is not None:
return
_activate_venv() # Add memory venv to sys.path
from model2vec import StaticModel
self._model = StaticModel.from_pretrained(self._model_name)
def embed_batch(self, texts):
self._ensure_model()
vecs = self._model.encode(texts)
return [v.tolist() for v in vecs]
A consequência prática: uma reindexação completa de 16.894 arquivos se completa em quatro minutos em um Apple M3 Pro. A indexação incremental (apenas arquivos alterados, detectados por comparação de mtime) roda em menos de dez segundos em um dia típico de edições.1
O modelo roda em um ambiente virtual isolado em ~/.claude/venvs/memory/ para evitar conflitos de dependência com o restante da toolchain. O embedder carrega o modelo de forma lazy no primeiro uso, não no momento do import, então importar o módulo não custa nada quando o retriever recorre ao modo somente BM25.
Por que não um modelo maior? Dois motivos. Primeiro, os vetores de 256 dimensões mantêm o banco de dados SQLite em 83 MB para 49.746 chunks. Vetores de dimensões maiores (768 ou 1.024) triplicariam ou quadruplicariam o tamanho do banco para melhoria de qualidade marginal em chunks curtos de markdown.10 Segundo, embeddings baseados em API (text-embedding-3-small da OpenAI a $0,02 por milhão de tokens, por exemplo) introduzem latência, custo e dependência de rede para um sistema que deveria funcionar offline.11 O re-embedding completo do vault custa aproximadamente $0,30 nos preços de API, trivial isoladamente, mas o custo real é a latência de ida e volta multiplicada por 49.746 chunks e a implicação de privacidade de enviar notas pessoais para uma API externa.
Um mecanismo de hash do modelo rastreia compatibilidade de embeddings. O indexador armazena um hash derivado do nome do modelo e tamanho do vocabulário. Se o modelo mudar, a indexação incremental detecta a incompatibilidade e dispara uma reindexação completa automaticamente.
O Schema SQLite: Três Tabelas, Um Arquivo
O índice inteiro vive em um único arquivo SQLite (vectors.db, 83 MB) usando modo WAL para segurança de leitura concorrente.12 Três tabelas servem a propósitos diferentes:
-- Chunk content and metadata
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL,
section TEXT NOT NULL,
chunk_text TEXT NOT NULL,
heading_context TEXT DEFAULT '',
mtime_ns INTEGER NOT NULL,
embedded_at REAL NOT NULL
);
-- FTS5 for BM25 search (content-synced to chunks)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
chunk_text, section, heading_context,
content=chunks, content_rowid=id
);
-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
id INTEGER PRIMARY KEY,
embedding float[256]
);
A tabela FTS5 usa um padrão de content-sync: ela referencia a tabela chunks diretamente em vez de armazenar uma cópia duplicada do texto.5 Um ponto de atenção: tabelas content-sync não propagam exclusões automaticamente. O indexador deve emitir comandos explícitos INSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?) antes de remover linhas da tabela chunks, ou o índice FTS5 se torna silenciosamente inconsistente. Pesos de colunas em consultas BM25 atribuem 1,0 ao texto do chunk, 0,5 aos cabeçalhos de seção e 0,3 ao contexto do cabeçalho:
# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score
A extensão sqlite-vec armazena vetores de ponto flutuante de 256 dimensões como dados binários empacotados e suporta consultas KNN com distância de cosseno.4 O struct.pack do Python serializa os vetores:
def _serialize_vector(vec):
return struct.pack(f"{len(vec)}f", *vec)
O schema lida com degradação graciosa por design. Se o sqlite-vec falhar ao carregar (extensão ausente, plataforma incompatível), o retriever recorre à busca somente BM25. A propriedade vec_available rastreia se a busca vetorial está operacional.
Reciprocal Rank Fusion: A Matemática que Faz Tudo Funcionar
O RRF mescla duas listas ranqueadas sem exigir calibração de scores.7 Por que não combinar os scores brutos diretamente? O BM25 retorna scores de relevância negativos (mais negativo = mais relevante na implementação FTS5 do SQLite) enquanto a distância de cosseno retorna valores entre 0 e 2. Comparar essas escalas requer normalização que é sensível à distribuição de consultas. O RRF contorna o problema inteiramente usando apenas posições de ranking, não scores. A fórmula atribui a cada documento um score baseado em onde ele apareceu em cada lista:
score(d) = Σ (weight_i / (k + rank_i))
Onde k é uma constante (60 na implementação, seguindo o artigo original de Cormack et al.7), rank_i é o ranking do documento na lista de resultados i, e weight_i é um multiplicador opcional por lista (padrão 1,0 para ambos).
Aqui está um exemplo detalhado com rankings reais. Considere uma consulta: “how does the review aggregator handle disagreements.” Cinco chunks aparecem nos resultados combinados:
| Chunk | Rank BM25 | Rank Vec | RRF BM25 | RRF Vec | Score Fundido |
|---|---|---|---|---|---|
| review-aggregator.py “Disagreement Resolution” | 3 | 1 | 1/63 = 0,0159 | 1/61 = 0,0164 | 0,0323 |
| deliberation-config.json “Review Weights” | 1 | 8 | 1/61 = 0,0164 | 1/68 = 0,0147 | 0,0311 |
| code-review MOC “Multi-Agent Review” | 7 | 2 | 1/67 = 0,0149 | 1/62 = 0,0161 | 0,0310 |
| jiro-artisan.sh “Review State Machine” | 2 | 12 | 1/62 = 0,0161 | 1/72 = 0,0139 | 0,0300 |
| quality-loop.md “Evidence Gate” | - | 3 | 0 | 1/63 = 0,0159 | 0,0159 |
O primeiro chunk vence porque se posiciona bem em ambas as listas. O BM25 correspondeu “review”, “aggregator” e “disagreements” no texto. A busca vetorial correspondeu o conceito semântico de resolução de conflitos em revisão de código. O segundo chunk ficou em primeiro no BM25 (correspondência exata de palavra-chave em “review” no arquivo de configuração) mas em oitavo na busca vetorial (o JSON de configuração é semanticamente esparso). O RRF o rebaixou apropriadamente. O último chunk apareceu apenas nos resultados vetoriais, então recebeu um score RRF de apenas uma fonte.
# retriever.py: RRF fusion core
RRF_K = 60
def _rrf_fuse(self, bm25_results, vec_results,
bm25_weight=1.0, vec_weight=1.0):
scores = {}
for rank, r in enumerate(bm25_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
scores[cid]["bm25_rank"] = rank
for rank, r in enumerate(vec_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
scores[cid]["vec_rank"] = rank
return [SearchResult(chunk_id=cid, **data)
for cid, data in scores.items()]
O pool padrão de candidatos é 30 resultados de cada fonte antes da fusão, produzindo até 60 candidatos. O retriever retorna os 10 melhores resultados fundidos. Um parâmetro opcional max_tokens trunca resultados para caber dentro de um orçamento de tokens, estimando 4 caracteres por token.
Indexação: Completa e Incremental
O indexador suporta dois modos. A reindexação completa limpa o banco de dados e reconstrói do zero. A indexação incremental compara tempos de modificação de arquivo (mtime_ns) contra timestamps armazenados e reprocessa apenas arquivos alterados.1
# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes) # mtime changed or new
deleted = index.get_deleted_files(vault_paths) # no longer in vault
O embedding roda em lotes de 64 textos para amortizar o overhead do Model2Vec.8 Um contador de progresso imprime a cada 500 arquivos durante a reindexação completa. Um handler de SIGINT permite desligamento gracioso, finalizando o arquivo atual antes de parar.
O arquivo de configuração usa um modelo de allowlist para controlar a indexação de pastas. O vault tem 22 pastas permitidas e 5 pastas permanentemente excluídas (notas pessoais de saúde, documentos de carreira, diretórios internos do Obsidian).20 O indexador processa apenas arquivos dentro das pastas permitidas e pula todo o resto.
Uma escolha de design crítica: o indexador executa um filtro de credenciais em cada chunk antes de armazená-lo. Notas pessoais contêm chaves de API, bearer tokens, strings de conexão de banco de dados e chaves privadas coladas durante sessões de debug. O filtro de credenciais corresponde 21 padrões específicos de vendors (chaves OpenAI, PATs do GitHub, chaves de acesso AWS, tokens Stripe e 17 outros) mais 11 detectores genéricos para URLs de banco de dados, JWTs, bearer tokens, atribuições de senha e strings base64 de alta entropia.20 O filtro substitui conteúdo correspondido por tokens [REDACTED:pattern-name] e registra em log quais padrões dispararam, mas nunca registra o segredo em si.
# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
logger.info("Scrubbed %d credential(s) from %s [%s]",
scan_result.match_count, file_path, sub_heading)
Indexar notas pessoais sem filtro de credenciais criaria um banco de dados pesquisável de segredos. O filtro roda antes do embedding, então as representações vetoriais nunca codificam padrões de credenciais. Uma consulta por “chave de API” retorna notas que discutem gerenciamento de chaves de API, não notas que contêm chaves reais.
O Que Dá Errado: Modos de Falha Honestos
Após centenas de consultas contra o índice de produção, quatro padrões de falha são claros.
Conteúdo superficial denso em palavras-chave supera conteúdo profundo. Uma nota curta tagueada com security, authentication, oauth e um resumo de três frases pontua mais alto no BM25 do que um mergulho profundo de 2.000 palavras sobre implementação OAuth que usa a terminologia uma vez na introdução e depois muda para detalhes específicos do protocolo. O BM25 recompensa frequência do termo relativa ao comprimento do documento, uma propriedade que Robertson e Zaragoza documentaram como o componente de “saturação de frequência de termo” do algoritmo.514 A nota superficial tem maior densidade de palavras-chave. O RRF corrige parcialmente o problema porque a busca vetorial classifica o conteúdo profundo mais alto (o embedding captura a profundidade semântica), mas a nota superficial ainda aparece nos resultados fundidos quando provavelmente não deveria.
Dados estruturados se indexam mal. Arquivos de configuração JSON, blocos de frontmatter YAML e trechos de código com nomes de variáveis produzem correspondências BM25 de baixa qualidade. Uma busca por “review configuration” corresponde a todo arquivo JSON com uma chave review. A busca vetorial lida com dados estruturados um pouco melhor porque o embedding captura os relacionamentos chave-valor, mas conteúdo estruturado é fundamentalmente mais difícil de dividir em chunks do que prosa. Achatar JSON para pares chave-caminho: valor antes do embedding melhoraria a qualidade da recuperação para notas com muita configuração.
Limites de chunk dividem o contexto. O chunker divide um parágrafo que abrange o limite entre duas seções H2 em dois chunks. Cada chunk contém metade da explicação. Nenhum dos chunks embeda bem porque o embedding carece do contexto completo. O chunker mitiga o problema com o contexto do cabeçalho (carregando o cabeçalho pai nos metadados), mas o texto do corpo ainda perde continuidade no limite. Janelas sobrepostas ajudariam mas aumentariam a contagem de chunks e o tamanho do banco de dados.
Relevância temporal é invisível. O retriever não tem noção de recência. Uma nota de 14 meses atrás sobre uma decisão arquitetural inicial é classificada igualmente a uma nota de ontem sobre a implementação atual. Para uma base de conhecimento que evolui, notas mais recentes frequentemente substituem as mais antigas. O retriever não sabe disso.
O Que Vem a Seguir: O Roadmap de Expansão
Cinco adições resolveriam os modos de falha e estenderiam as capacidades do sistema.
Camada de re-ranking com learning-to-rank. Após a fusão RRF, um re-ranker leve poderia ajustar scores baseado em sinais de metadados: recência da nota, relevância das tags para o domínio da consulta, densidade de links (notas com muitos links são frequentemente mais autoritativas). O re-ranker rodaria sobre os 30 melhores resultados fundidos, não sobre o corpus inteiro, mantendo a latência abaixo da baseline de 23ms.
Classificação de intenção da consulta. Diferentes consultas precisam de diferentes estratégias de recuperação. Uma busca exata de identificador (_rrf_fuse) deveria pesar fortemente o BM25. Uma questão conceitual (“how does review handle disagreements”) deveria pesar a busca vetorial. Um classificador leve que ajusta bm25_weight e vec_weight por consulta melhoraria a precisão sem alterar a arquitetura de fusão.
Decaimento temporal. Pesar notas recentes ligeiramente mais alto para consultas sobre estado atual. Uma função de decaimento aplicada pós-fusão reduziria o score de chunks de arquivos modificados pela última vez há mais de N meses. O timestamp mtime_ns já existe no schema; o decaimento precisa apenas de uma função de ponderação no retriever.
Harness de avaliação com consultas golden. O sistema atualmente não tem medição automatizada de qualidade. Um conjunto de 50-100 pares consulta-resposta curados habilitaria testes de regressão de qualidade da recuperação: execute o conjunto de testes após qualquer mudança em chunking, embedding ou parâmetros de fusão e verifique que o recall@10 não degradou. O benchmark BEIR demonstrou que sistemas de recuperação podem variar em 20+ pontos em nDCG@10 entre diferentes distribuições de consultas, tornando a avaliação específica por domínio essencial.19 Sem um conjunto golden, melhorias são anedóticas.
Indexação de relacionamentos entre notas. Wiki-links do Obsidian ([[note-name]]) codificam relacionamentos explícitos entre notas. O sistema atual ignora completamente a estrutura de links. Indexar alvos de links como metadados permitiria ao retriever impulsionar chunks de notas que muitas outras notas bem pontuadas linkam, similar ao PageRank para o vault.
A análise de topologia do espaço de embeddings que executei no vault completo revela onde essas melhorias teriam maior impacto. Clusters densos (ferramental de IA, segurança) já recuperam bem porque a terminologia é consistente. Regiões de ponte esparsas entre clusters são onde o retriever mais tem dificuldade, e onde a indexação de relacionamentos e classificação de intenção proporcionariam os maiores ganhos.
FAQ
Por que SQLite em vez de um banco de dados vetorial dedicado?
O stack inteiro de recuperação roda em um arquivo com zero dependências externas. O modo WAL do SQLite lida com leituras concorrentes de múltiplas sessões do Claude Code. A extensão sqlite-vec adiciona busca vetorial KNN sem exigir uma instância separada de Pinecone, Weaviate ou Qdrant.4 Com 49.746 chunks, a latência de consulta é 23ms.1 Um banco de dados vetorial dedicado adicionaria complexidade operacional (hospedagem, backups, autenticação) para uma base de conhecimento de usuário único que cabe em 83 MB.
Por que Model2Vec em vez de embeddings da OpenAI ou um modelo maior?
Três razões: latência, privacidade e custo. O Model2Vec roda localmente na velocidade da CPU sem chamada de rede.3 Notas pessoais nunca saem da máquina. Embeddings baseados em API custariam aproximadamente $0,30 por reindexação completa para o tamanho atual do vault,11 negligível isoladamente, mas a latência de ida e volta por 49.746 chunks e a exposição de privacidade de conteúdo pessoal são os custos reais.
O que é Reciprocal Rank Fusion e quando você deve usá-lo?
O RRF não requer dados de treinamento, calibração de scores nem ajuste de hiperparâmetros além da constante k.7 Um modelo de fusão aprendido exigiria julgamentos de relevância rotulados para treinamento, que não existem para uma base de conhecimento pessoal. O RRF é o método de fusão com a menor barreira para produzir resultados úteis. Use RRF quando combinar listas ranqueadas de métodos de recuperação que produzem tipos de score incompatíveis.
Como um retriever local se conecta ao Claude Code?
Um hook PreToolUse chama o método search() do retriever com o prompt atual, formata os melhores resultados como um bloco de contexto com caminhos de arquivo e cabeçalhos de seção, e injeta o bloco na conversa. O agente vê chunks focados, não arquivos brutos. Um parâmetro max_tokens garante que o contexto injetado caiba dentro de um orçamento.
Como você previne que segredos sejam indexados em um sistema de recuperação?
Execute um filtro de credenciais em cada chunk antes do armazenamento. O filtro neste sistema corresponde 21 padrões específicos de vendors e 11 detectores genéricos para JWTs, bearer tokens e chaves privadas.20 Ele substitui conteúdo correspondido por tokens [REDACTED:pattern-name] e roda antes do embedding, então as representações vetoriais nunca codificam padrões de credenciais.
Referências
-
Author’s production data. 49,746 chunks, 16,894 files, 83.56 MB SQLite database, 7,771 signals processed across 14 months. Query latency (23ms) measured via
time.perf_counter()in retriever.py, wrapping the full search path: BM25 lookup, query embedding via Model2Vec, vector KNN search, and RRF fusion.grep -rlmeasured at 11-66 seconds depending on term frequency (Apple M3 Pro, APFS). Full reindex measured at ~4 minutes on Apple M3 Pro. Incremental measured at <10 seconds for typical daily changes. FTS5-only search became unusable for the author above ~3,000 files due to keyword collision rates. ↩↩↩↩↩↩ -
HN thread: “Stop Burning Your Context Window”. Comments from danw1979 and tclancy requesting a detailed write-up. ↩
-
Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. The potion-base-8M model uses static word embeddings distilled from a sentence transformer, producing 256-dimensional vectors without running attention layers. ↩↩↩
-
sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. Provides
vec0virtual tables for KNN vector search within SQLite, using the same query interface as standard tables. ↩↩↩ -
SQLite FTS5 Extension. SQLite documentation. FTS5 provides full-text search with BM25 ranking, content-sync tables, and configurable column weights via the
bm25()auxiliary function. ↩↩↩ -
Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. Foundational work on dense semantic similarity for text retrieval, establishing the vector search approach used in hybrid retrieval systems. ↩
-
Cormack, G.V., Clarke, C.L.A., and Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. Introduces RRF with k=60 as a parameter-free method for combining ranked lists that outperforms trained fusion models. ↩↩↩↩
-
Author’s implementation.
chunker.pysplits at H2 boundaries in the_split_at_headingsfunction, with fallback to H3 then paragraph splitting for sections exceeding 2,000 characters. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000.index_vault.pyembeds in batches of 64 (BATCH_SIZE=64). ↩↩ -
van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. Describes the distillation approach producing static embeddings from sentence transformers with 50-500x inference speedup. ↩↩↩
-
Author’s measurement. 256-dim vectors at 49,746 chunks produce 83 MB SQLite. Extrapolating to 768-dim vectors: ~215 MB. To 1024-dim: ~280 MB. Marginal quality improvement on short markdown chunks (avg 200-400 words) does not justify the storage and latency increase. ↩
-
OpenAI Embeddings Pricing. text-embedding-3-small: $0.02 per million tokens. Estimated vault cost per full reindex: ~$0.30 based on average chunk length of ~200 tokens. ↩↩
-
SQLite Write-Ahead Logging. SQLite documentation. WAL mode allows concurrent readers with a single writer, suitable for the retriever’s read-heavy access pattern. ↩
-
Author’s query trace. Ran “PostToolUse hook for context compression” against BM25-only, vector-only, and hybrid modes. Results captured from retriever.py with
methodfield tracking which search path produced each result. ↩ -
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. Survey of the BM25 family of ranking functions and their theoretical foundations. ↩↩
-
Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. Demonstrated that learned dense representations outperform BM25 by 9-19% on open-domain QA benchmarks, establishing dense retrieval as a complement to lexical search. ↩
-
Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. Analysis of hybrid sparse-dense retrieval on MS MARCO, showing consistent improvements over single-modality approaches. ↩
-
MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M scores 50.03 average MTEB vs 56.09 for all-MiniLM-L6-v2 (89.2% retention). Per-task breakdown: Classification 64.44, Clustering 32.93, Retrieval 31.71, STS 73.24. Source: Model2Vec results. ↩
-
Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. Survey of RAG architectures including analysis of chunking strategies and their impact on retrieval quality. ↩
-
Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. Demonstrates high variance in retrieval performance across domains, underscoring the need for domain-specific evaluation. ↩
-
Author’s configuration and credential filter implementation.
memory-config.jsondefines 22allowed_foldersand 5excluded_alwaysentries.credential_filter.pydefines 21 vendor-specificCREDENTIAL_PATTERNS(OpenAI through Turnstile) plus 9 generic single-line patterns (DB URLs, bearer tokens, JWTs, passwords, secrets, API keys, auth tokens, base64 secrets) and 2 multiline patterns (RSA/SSH private keys, PGP keys). Total: 32 patterns. ↩↩↩