LocalLens
Walkthrough

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

src/qvac.ts
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:

  • embeddingModelId carga primero y nunca hace fallback. GTE_LARGE_FP16 es chico como para asumirlo.
  • chatModelId intenta QWEN3_1_7B_INST_Q4 primero y hace fallback a QWEN3_600M_INST_Q4 en 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: false le dice a QVAC que los documentos ya están chunkeados. La partición pasó en rag.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: true mantiene cualquier token de razonamiento fuera del stream de salida visible al usuario.
  • kvCache: true le 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.

On this page