2. Chunking e o prompt embasado
A lógica do lado de recuperação que ainda não conhece o ciclo de vida do modelo.
src/rag.ts faz dois trabalhos: quebrar documentos em chunks e construir
um prompt que embasa o modelo nos trechos recuperados. Os dois são
quase puros. Chamam o ragChunk do QVAC para tokenização;
fora isso só conhecem tipos de domain.ts.
Chunking
import { ragChunk } from "@qvac/sdk";
import type { ChatMessage, LocalDocument, SearchHit, TextChunk } from "./domain.ts";
export type ChunkOptions = {
brainId: string;
chunkSize?: number;
chunkOverlap?: number;
};
export async function chunkDocuments(
documents: LocalDocument[],
options: ChunkOptions,
): Promise<TextChunk[]> {
const chunks: TextChunk[] = [];
for (const document of documents) chunks.push(...(await chunkDocument(document, options)));
return chunks;
}
export async function chunkDocument(
document: LocalDocument,
{ brainId, chunkSize = 220, chunkOverlap = 40 }: ChunkOptions,
): Promise<TextChunk[]> {
if (chunkSize <= chunkOverlap) throw new Error("chunkSize must be greater than chunkOverlap");
const text = document.content.replace(/\r\n/g, "\n").trim();
if (!text) return [];
const qvacChunks = await ragChunk({
documents: text,
chunkOpts: {
chunkSize,
chunkOverlap,
chunkStrategy: "paragraph",
splitStrategy: "token",
},
});
return qvacChunks
.map((chunk) => chunk.content.trim())
.filter(Boolean)
.map((content, chunkIndex) => ({
id: `${document.checksum.slice(0, 12)}-${chunkIndex}`,
brainId,
relativePath: document.relativePath,
chunkIndex,
content,
checksum: document.checksum,
}));
}Tamanho de chunk e overlap
Defaults: 220 tokens com 40 tokens de overlap. Duas razões pelas quais esses números funcionam para conteúdo formato documentação:
- A maior parte dos parágrafos num README ou nota cabem confortavelmente em 220 tokens, então o chunker raramente quebra no meio de uma ideia.
- 40 tokens de overlap mantêm uma sentença que atravessa dois chunks buscável dos dois lados sem inflar o índice.
A checagem em runtime chunkSize > chunkOverlap é uma guarda contra
loops infinitos silenciosos — o QVAC SDK senão produziria
chunks sobrepostos que nunca avançam.
IDs estáveis
O ID do chunk combina o checksum do documento e o índice do chunk:
id: `${document.checksum.slice(0, 12)}-${chunkIndex}`Duas consequências:
- Reindexar o mesmo arquivo produz os mesmos IDs de chunk, o que torna reindexação incremental tratável depois.
- Editar um byte único muda o checksum e portanto todo ID de chunk daquele arquivo. Isso está correto — os embeddings já não são mais válidos.
O prompt embasado
export function buildGroundedHistory(question: string, hits: SearchHit[]): ChatMessage[] {
const context = hits
.map(
(hit, index) => `[${index + 1}] ${hit.relativePath}#chunk-${hit.chunkIndex}\n${hit.content}`,
)
.join("\n\n---\n\n");
return [
{
role: "system",
content: [
"You are LocalLens, a local-first file chat assistant that answers questions strictly from the provided source excerpts.",
"Rules:",
"1. Only use facts that appear in the excerpts. If the answer is not in them, say so plainly.",
"2. Refer to source excerpts inline using bracketed numbers, for example [1] or [2], when you use them.",
"3. Answer in the same language as the user's question.",
"4. Keep answers focused and concrete. No filler.",
"5. Do not include hidden reasoning, chain-of-thought, or thinking tags.",
].join(" "),
},
{
role: "user",
content: `Source excerpts:\n\n${context || "No matching chunks were found."}\n\nQuestion:\n${question}`,
},
];
}Essa é a estratégia anti-alucinação inteira. Cinco regras de sistema, mais um turno de usuário que dispõe os trechos como evidência numerada.
O fallback "no matching chunks"
Se hits é vazio, a mensagem do usuário contém a string literal
"No matching chunks were found." em vez de um bloco de trechos. A
primeira regra do system prompt entra em ação e o modelo diz que
não sabe.
Trechos numerados e citações
Cada hit vira:
[N] relative/path#chunk-<index>
<chunk content>O [N] é o que o modelo ecoa de volta como [1], [2] na resposta.
O caminho relativo permite ao chamador renderizar uma lista de fontes
que linka de volta para o arquivo.
Por que sem histórico de chat?
Cada chamada a buildGroundedHistory produz um histórico fresco de
duas mensagens baseado na pergunta atual e nos hits da busca. Turnos
anteriores não passam para frente. É assim que o LocalLens fica embasado
ao longo de uma sessão — toda pergunta é sua própria rodada de
recuperação de evidência.
O que você pode rodar depois desse passo
Testes vivem em tests/prompt.test.ts e tests/chunker.test.ts.
Os dois passam com só domain.ts e rag.ts no lugar.
A seguir: o gateway QVAC, onde chunking e prompting encontram carregamento de modelo e inferência.