A lição anterior terminou com um índice sendo escrito; esta fecha o circuito com a LEITURA. buildChunkIndex/writeChunkIndex escrevem linhas de vetor offline, $0, com dedupe por chunk_id; searchChunkIndex — o motor do alembic search — embeda a consulta com o MESMO backend, varre o índice em stream e ranqueia por cosseno com desempate determinístico. E quando os top-K vêm parecidos demais, mmrRerank troca um pouco de relevância por diversidade, puro e sem IO.
Durante muito tempo o motor tinha só a metade da escrita — "indexa mas não recupera". O query.ts declara a missão de fechar o gap logo no header: é "The RAG READ path… The index builder WRITES rows; this module READS them back" (packages/embeddings/src/query.ts:1–6). O contrato entre as metades é um só: a consulta TEM de ser vetorizada pelo MESMO backend (e modelo) com que o índice foi construído, "for scores to be meaningful" (query.ts:170–176) — por default, o backend offline determinístico dos dois lados, então buscar num índice offline é hermético, $0 e reproduzível.
Pense como… um catálogo de biblioteca por impressão digital: cada ficha guarda a digital do texto (não o texto), e para achar algo você tira a digital da PERGUNTA com a mesma máquina e procura as fichas de digital mais parecida. Onde quebra: a digital offline é um fingerprint estável do texto, NÃO um embedding semântico real — "suitable for wiring, caching, and index plumbing", diz o próprio backend (offline-backend.ts:11–13); sinônimos não se atraem. O backend real é injetável pela mesma porta.
buildChunkIndex percorre os ChunkRecords de um pacote e, para cada chunk com texto não-vazio, pede um vetor ao backend injetado e valida a linha { chunk_id, package_path, vector, dim, model } na borda (index-builder.ts:67–106). A linha deliberadamente NÃO carrega timestamp — junto com o backend sem relógio/RNG, isso faz "the same chunk always yield the same row" (index-builder.ts:12–15, 61–65). O backend offline default produz vetores de 16 dimensões (OFFLINE_DIMENSION, offline-backend.ts:27) por um hash polinomial por slot, esmagado para [-1, 1) — cada slot dobra os char codes com a posição e um multiplicador ímpar próprio (offline-backend.ts:15–21, 68–89).
const seen = parseExistingChunkIds(existing.value); // ids já no arquivo const fresh: EmbeddingIndexRow[] = []; for (const row of rows) { if (seen.has(row.chunk_id)) continue; // DEDUPE por chunk_id seen.add(row.chunk_id); // duplicata no lote também colapsa fresh.push(row); } if (fresh.length === 0) return ok(0); // 2ª run: appenda ZERO (converge)
writeChunkIndex appenda as linhas em <data-dir>/embeddings-index/<família>.jsonl — APPEND-ONLY com dedupe por chunk_id lido do arquivo existente, então rodar de novo sobre os mesmos chunks escreve nada e o índice converge (index-builder.ts:123–158). O leitor default resolve para '' em ENOENT — "a missing index is an empty index" (index-builder.ts:184–199). É exatamente o que alembic embed-index <família> faz por baixo.
alembic search "<consulta>" <família> é este read-path inteiro num comando — embeda a consulta com o mesmo backend offline, lê <data-dir>/embeddings-index/<família>.jsonl via FsPort e imprime os top-K; --json devolve {family, indexPath, query, hits[]}. É o par de leitura do alembic embed-index: um ESCREVE o índice, o outro o LÊ.O kernel puro é queryChunkIndex: só linhas com a MESMA dimensionalidade da consulta competem (índice heterogêneo degrada gracioso, nunca erra — query.ts:77–82, 108–109), vetor de magnitude zero pontua 0 (cosseno indefinido; 0 é o neutro "ortogonal" — query.ts:130–141), e topK/minScore inválidos falham fechado. Em volta dele, searchChunkIndex resolve o arquivo (<dataDir>/embeddings-index/<família>.jsonl, o layout exato que alembic embed-index escreve — query.ts:156–164) e NÃO carrega o índice inteiro: varre linha a linha via openLineStream mantendo um top-K limitado — "memory is O(topK), never O(rows)" — com resultado byte-idêntico ao caminho puro porque a inserção usa o mesmo compareHits e preserva a ordem do stream em empate total (query.ts:196–201, 309–336). Índice ausente? ok([]) — resultado vazio, não erro (query.ts:189–190). E um detalhe de design que liga com a lição 19: a linha do índice não guarda texto — chunk_id + package_path localizam o chunk no pacote, e resolveSnippetTexts lê o chunks.jsonl de cada pacote (uma vez só por pacote) quando o texto é necessário, com fallback à linha-referência determinística (query.ts:38–42; rerank.ts:216–255).
Cosseno puro tem um vício conhecido: os top-K tendem a ser quase-duplicatas — "the same idea, restated" (rerank.ts:7–12). O mmrRerank reordena por Maximal Marginal Relevance, PURO: usa só os vetores que os próprios hits já carregam — sem IO, sem backend, sem clock/RNG. A primeira escolha é SEMPRE o hit mais relevante (para qualquer λ, o líder bate com o top-1 do cosseno); cada escolha seguinte maximiza λ·relevância − (1−λ)·maxSimAosJáEscolhidos (rerank.ts:89–106, 149–167).
Fail-closed nas bordas, como sempre: λ não-finito ou fora de [0, 1] é err; topK não-inteiro/não-positivo é err; entrada vazia é ok([]) (rerank.ts:107–121). E um detalhe de arquitetura fino: os helpers de cosseno são RE-DERIVADOS como funções privadas do módulo — os do query.ts não são exportados de propósito, e rerank.ts nunca o importa (rerank.ts:20–24). Quem consome o reranker é o alembic context-pack --retrieve … --rerank mmr: os hits reordenados viram os relevantSnippets do L3 — e sem --retrieve o pack fica byte-idêntico ao anterior.
mmrRerank(hits, { lambda: 1 }). O que sai?1·relevância − 0·maxSim — e o doc garante: "with lambda = 1 this reproduces the pure relevance order (byte-identical to queryChunkIndex's score-desc/chunk_id-asc)" (rerank.ts:96–99). O intervalo válido é FECHADO, [0, 1]: 1 e 0 são os extremos legítimos, não erros (rerank.ts:112–114).alembic search aponta para uma família cujo índice ainda NÃO foi construído. O que acontece?query.ts:189–193, 271–273). Só um arquivo PRESENTE-mas-ilegível vira err (fail-closed de verdade); e nada constrói índice implicitamente — escrever é papel do embed-index, nunca da busca.O ciclo completo, offline $0 e determinístico:
# 1) escrever o índice da família (append-only, dedupe por chunk_id) alembic embed-index ~/Documents/Resources/Bookmarks # 2) o read-path RAG (rode 2× — a lista volta idêntica) alembic search "agent memory design" Bookmarks --top-k 5 --json # 3) injetar a recuperação no L3 do context-pack, com MMR alembic context-pack README.md --brief "memória de agentes" \ --retrieve "agent memory" --family Bookmarks --rerank mmr --lambda 0.5 --resolve-text # vetores offline na unha (fingerprint estável, não semântica) alembic embed "hello world" --json # as regras de ranking e o dedupe, na fonte sed -n '119,128p' packages/embeddings/src/query.ts sed -n '143,151p' packages/embeddings/src/index-builder.ts sed -n '107,121p' packages/embeddings/src/rerank.ts
Regra de ouro: o backend da CONSULTA tem de ser o mesmo do ÍNDICE — misturar backends produz scores sem significado (query.ts:170–176).
query.ts:170–176).chunk_id; 2ª run appenda 0 (index-builder.ts:123–158).chunk_id+package_path localizam; determinismo total (index-builder.ts:12–15).chunk_id asc; dim ≠ pula; vetor zero = 0 (query.ts:119–141).ok([]) (query.ts:196–201, 271–273).chunk_id (rerank.ts:89–147).@alembic/vision descreve as imagens do acervo e alimenta o vision-index.