LocalLens
Walkthrough

3. O gateway QVAC

Carregar modelos, ingerir chunks, buscar, fazer stream de completions — tudo atrás de uma classe.

src/qvac.ts é o único lugar no LocalLens que conversa com o QVAC SDK. Todo o resto passa pelo QvacGateway. É assim que o resto do app fica livre do SDK.

A forma do 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
}

Três campos privados, três métodos públicos, mais helpers de ciclo de vida. Essa é a superfície inteira.

Carregamento lazy do modelo

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,
    });
  }
}

Duas propriedades lazy:

  • embeddingModelId carrega primeiro e nunca cai para fallback. GTE_LARGE_FP16 é pequeno o suficiente para assumir.
  • chatModelId tenta QWEN3_1_7B_INST_Q4 primeiro e cai para QWEN3_600M_INST_Q4 em qualquer falha de carregamento.

readyPromise faz chamadas concorrentes compartilharem um load em voo. Dois requests chegando no mesmo tick não chamam loadModel para o mesmo source duas vezes.

Ingerindo 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,
  });
}

Dois detalhes que importam:

  • closeWorkspace(workspace, true) é chamado antes do ingest. Isso limpa qualquer workspace anterior com o mesmo nome, então reingerir é destrutivo e idempotente.
  • chunk: false avisa ao QVAC que os documentos já estão chunked. O splitting aconteceu em rag.ts. Fazer duas vezes seria errado.

Os chunks são reformatados num envelope de texto pequeno:

source:<relativePath>
chunk:<chunkIndex>
id:<workspace>:<chunk.id>

<chunk content>

…para o ragSearch retornar com a metadata ainda anexada.

Buscando

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 extrai os headers source: e chunk: que escrevemos durante o ingest e remove eles do conteúdo do 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,
  };
}

É onde strings voltam como objetos SearchHit estruturados.

Streaming completion

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;
}

O gateway expõe inferência como AsyncGenerator<string>, para chamadores fazerem stream direto para um console, uma resposta HTTP ou uma UI.

  • captureThinking: true mantém qualquer token de raciocínio fora do stream de saída visível ao usuário.
  • kvCache: true deixa o QVAC reutilizar estado de atenção de prefixo entre perguntas de follow-up.

Ciclo de vida

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 é tolerante. Se o QVAC diz que o workspace não existe, tudo bem — o chamador queria que ele sumisse mesmo. close() desmonta o runtime QVAC inteiro e é chamado de LocalLensApp.close() no shutdown.

Por que uma classe e não um módulo?

O gateway segura estado — IDs de modelo, uma promise de load em voo. Um módulo de funções livres empurraria esse estado pra todo chamador, ou esconderia em mutáveis no nível de módulo. Uma classe pequena é o jeito mais barato de encapsular.

A seguir: o JSON store, onde brains e chunks ficam persistidos.

On this page