3. El gateway de QVAC
Cargar modelos, ingestar chunks, buscar, hacer stream de completions — todo detrás de una sola clase.
src/qvac.ts es el único lugar en LocalLens que habla con el SDK
de QVAC. Todo lo demás pasa por QvacGateway. Así es como el resto
de la app se mantiene libre del SDK.
La forma del gateway
import {
close,
completion,
GTE_LARGE_FP16,
loadModel,
QWEN3_1_7B_INST_Q4,
QWEN3_600M_INST_Q4,
ragCloseWorkspace,
ragDeleteWorkspace,
ragIngest,
ragSearch,
} from "@qvac/sdk";
import type { ChatMessage, SearchHit, TextChunk } from "./domain.ts";
const chatModelConfig = { ctx_size: 4096, temp: 0.2, top_p: 0.9 };
export class QvacGateway {
private chatModelId: string | undefined;
private embeddingModelId: string | undefined;
private readyPromise: Promise<void> | undefined;
// … methods below
}Tres campos privados, tres métodos públicos, más helpers de lifecycle. Esa es toda la superficie.
Carga lazy de modelos
private async ensureReady(): Promise<void> {
if (this.chatModelId && this.embeddingModelId) return;
this.readyPromise ??= this.loadModels().finally(() => {
this.readyPromise = undefined;
});
await this.readyPromise;
}
private async loadModels(): Promise<void> {
this.embeddingModelId ??= await loadModel({ modelSrc: GTE_LARGE_FP16 });
if (this.chatModelId) return;
try {
this.chatModelId = await loadModel({
modelSrc: QWEN3_1_7B_INST_Q4,
modelConfig: chatModelConfig,
});
} catch {
this.chatModelId = await loadModel({
modelSrc: QWEN3_600M_INST_Q4,
modelConfig: chatModelConfig,
});
}
}Dos propiedades lazy:
embeddingModelIdcarga primero y nunca hace fallback.GTE_LARGE_FP16es chico como para asumirlo.chatModelIdintentaQWEN3_1_7B_INST_Q4primero y hace fallback aQWEN3_600M_INST_Q4en cualquier falla de carga.
readyPromise hace que las llamadas concurrentes compartan una
sola carga en vuelo. Dos requests que llegan en el mismo tick no
van a llamar ambos a loadModel para la misma fuente.
Ingestando chunks
async ingestChunks(workspace: string, chunks: TextChunk[]): Promise<void> {
await this.ensureReady();
await this.closeWorkspace(workspace, true);
if (chunks.length === 0) return;
await ragIngest({
modelId: required(this.embeddingModelId, "QVAC embedding model is not loaded."),
workspace,
documents: chunks.map((chunk) => formatChunkForRag(workspace, chunk)),
chunk: false,
});
}Dos detalles que importan:
closeWorkspace(workspace, true)se llama antes del ingest. Eso limpia cualquier workspace previo con el mismo nombre, así que re-ingestar es destructivo e idempotente.chunk: falsele dice a QVAC que los documentos ya están chunkeados. La partición pasó enrag.ts. Hacerla dos veces estaría mal.
Los chunks se reformatean en un pequeño envelope de texto:
source:<relativePath>
chunk:<chunkIndex>
id:<workspace>:<chunk.id>
<chunk content>…así que ragSearch los devuelve con la metadata todavía adjunta.
Búsqueda
async search(workspace: string, question: string, topK = 5): Promise<SearchHit[]> {
await this.ensureReady();
const results = await ragSearch({
modelId: required(this.embeddingModelId, "QVAC embedding model is not loaded."),
query: question,
topK,
n: 3,
workspace,
});
return results.map(parseRagHit);
}parseRagHit extrae los headers source: y chunk: que
escribimos durante el ingest y los quita del contenido del chunk:
function parseRagHit(hit: { id: string; content: string; score: number }): SearchHit {
return {
id: hit.id,
relativePath: /^source:(.+)$/m.exec(hit.content)?.[1] ?? "unknown",
chunkIndex: Number(/^chunk:(\d+)$/m.exec(hit.content)?.[1] ?? 0),
content: hit.content.replace(/^(?:workspace:.+\n)?source:.+\nchunk:\d+\nid:.+\n\n/m, "").trim(),
score: hit.score,
};
}Ahí es donde los strings vuelven como objetos SearchHit
estructurados.
Completion en stream
async *answer(history: ChatMessage[]): AsyncGenerator<string> {
await this.ensureReady();
const run = completion({
modelId: required(this.chatModelId, "QVAC chat model is not loaded."),
history,
stream: true,
captureThinking: true,
kvCache: true,
});
for await (const event of run.events) if (event.type === "contentDelta") yield event.text;
await run.final;
}El gateway expone la inferencia como un AsyncGenerator<string>,
así los callers pueden hacer stream directo a una consola, una
response HTTP o una UI.
captureThinking: truemantiene cualquier token de razonamiento fuera del stream de salida visible al usuario.kvCache: truele permite a QVAC reusar el estado de atención del prefix entre preguntas de seguimiento.
Lifecycle
async closeWorkspace(workspace: string, deleteOnClose = false): Promise<void> {
await ragCloseWorkspace({ workspace, deleteOnClose }).catch(async () => {
if (deleteOnClose) await ragDeleteWorkspace({ workspace }).catch(() => undefined);
});
}
async close(): Promise<void> {
await close();
}closeWorkspace es indulgente. Si QVAC dice que el workspace no
existe, está bien — el caller lo quería ido de todos modos.
close() desmonta el runtime completo de QVAC y se llama desde
LocalLensApp.close() en el shutdown.
¿Por qué una clase y no un módulo?
El gateway carga estado — IDs de modelos, una promesa de carga en vuelo. Un módulo de funciones libres empujaría ese estado a cada caller, o lo escondería en mutables a nivel módulo. Una clase chica es la forma más barata de encapsularlo.
Lo que sigue: el store JSON, donde los cerebros y chunks se persisten.