2. Chunking y el prompt fundamentado
La lógica del lado de recuperación que todavía no sabe sobre el lifecycle del modelo.
src/rag.ts hace dos trabajos: partir documentos en chunks y
construir un prompt que fundamente al modelo en extractos
recuperados. Ambos son mayormente puros. Llaman a ragChunk de
QVAC para tokenización; aparte de eso solo conocen 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,
}));
}Tamaño de chunk y solapamiento
Defaults: 220 tokens con 40 tokens de solapamiento. Dos razones por las que esos números funcionan para contenido con forma de documentación:
- La mayoría de los párrafos en un README o una nota caben cómodamente en 220 tokens, así que el chunker rara vez parte en medio de una idea.
- 40 tokens de solapamiento mantienen una oración que cruza dos chunks buscable desde cualquier lado sin inflar el índice.
El chequeo de runtime chunkSize > chunkOverlap es una protección
contra loops infinitos silenciosos — de otra manera el SDK de QVAC
produciría chunks solapados que nunca avanzan.
IDs estables
El ID del chunk combina el checksum del documento y el índice del chunk:
id: `${document.checksum.slice(0, 12)}-${chunkIndex}`Dos consecuencias:
- Reindexar el mismo archivo produce los mismos IDs de chunk, lo que hace tratable la reindexación incremental más adelante.
- Editar un solo byte cambia el checksum y por lo tanto cada ID de chunk para ese archivo. Eso es correcto — los embeddings ya no son válidos.
El prompt fundamentado
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}`,
},
];
}Esa es toda la estrategia anti-alucinación. Cinco reglas de system, más un turno de user que dispone los extractos como evidencia numerada.
El fallback "no matching chunks"
Si hits está vacío, el mensaje de user contiene el string literal
"No matching chunks were found." en lugar de un bloque de
extractos. La primera regla del prompt del system toma el control y
el modelo dice que no sabe.
Extractos numerados y citas
Cada hit se vuelve:
[N] relative/path#chunk-<index>
<chunk content>El [N] es lo que el modelo devuelve como [1], [2] en su
respuesta. La ruta relativa le permite al caller renderizar una
lista de fuentes que enlace de vuelta al archivo.
¿Por qué sin historial de chat?
Cada llamada a buildGroundedHistory produce un historial fresco
de dos mensajes basado en la pregunta actual y los hits de
búsqueda. Los turnos anteriores no se cargan. Así es como
LocalLens se mantiene fundamentado a lo largo de una sesión —
cada pregunta es su propia ronda de recuperación de evidencia.
Qué puedes correr después de este paso
Los tests viven en tests/prompt.test.ts y tests/chunker.test.ts.
Ambos pasan con solo domain.ts y rag.ts en su lugar.
Lo que sigue: el gateway de QVAC, donde chunking y prompting se encuentran con la carga de modelos y la inferencia.