Lição 20 · Fusão v3 · Conhecimento e ingestão · @alembic/embeddings ← índice
Alembic × Hermes — O Curso de Fusão v3 · Conhecimento e ingestão

@alembic/embeddings: o caminho de leitura RAG

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.

1

A grande ideia: indexa E recupera — as duas metades

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.

harness · 0006-contexto-memoria-rag · s1
2

A metade da escrita: vetores determinísticos, append com dedupe

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).

packages/embeddings/src/index-builder.ts:143–151 (o dedupe do writeChunkIndex)
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.

3

A metade da leitura: cosseno, desempate e stream O(topK)

"consulta" vazia → err MESMO backend do índice (senão score sem sentido) · offline $0 query.ts:170–176 stream do índice openLineStream · linha ruim pula dim ≠ consulta? pula (gracioso) vetor zero → score 0 (neutro) memória O(topK), nunca O(linhas) top-K hits score desc · empate: chunk_id ↑ a ordenação determinística (compareHits, query.ts:119–128) 1º: cosseno MAIOR primeiro · 2º: chunk_id lexicográfico ASCENDENTE · empate total: ordem do stream ⇒ mesma consulta + mesmo índice = MESMA lista, byte a byte — sem clock, sem RNG índice AUSENTE = ok([]) — vazio, não erro (:271–273)
searchChunkIndex: embeda → streama → ranqueia. O resultado do stream limitado é provadamente idêntico ao sort estável do caminho puro (query.ts:196–201).
Na superfície CLI: 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 textochunk_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).

4

mmrRerank: λ entre relevância e diversidade

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).

candidatos hits do search (dim ≠ do 1º hit é pulada, gracioso) laço de seleção (até topK) 1ª escolha: maior relevância (sempre) score = λ·rel − (1−λ)·maxSim maxSim = cosseno ao conjunto JÁ escolhido empate no score → chunk_id ascendente λ fora de [0,1] ou topK inválido → err λ = 1 · relevância pura byte-idêntico à ordem do cosseno (score desc, chunk_id asc) λ = 0 · diversidade pura após o 1º, sempre o MENOS parecido com o que já foi escolhido default λ = 0.5 (DEFAULT_LAMBDA, rerank.ts:56) — meio a meio entre os dois extremos
mmrRerank é puro e determinístico: mesmos hits + mesmo λ = mesma ordem. O context-pack usa isto no L3 via --rerank mmr --lambda <n>.

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.

16
dimensões do vetor offline (OFFLINE_DIMENSION)
10
DEFAULT_TOP_K do read-path (query.ts:67)
0.5
DEFAULT_LAMBDA do MMR (rerank.ts:56)
$0
custo: offline determinístico dos dois lados
5

Cheque seu modelo mental

Você roda mmrRerank(hits, { lambda: 1 }). O que sai?
Correto: λ=1 zera o termo de diversidade — o score vira 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).
O alembic search aponta para uma família cujo índice ainda NÃO foi construído. O que acontece?
Correto: "A MISSING index file is not an error — it is an empty result ([]), matching the builder's 'a missing index is an empty index' stance" (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.
Camada técnica — comandos copy-paste

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).

O que levar desta lição
Pergunta de acompanhamento sugerida: "com o backend offline sendo fingerprint e não semântica, o que exatamente melhora ao injetar um backend de embeddings real — e o que já está pronto para recebê-lo?" — Na próxima lição, o motor ganha OLHOS: @alembic/vision descreve as imagens do acervo e alimenta o vision-index.