LocalLens
ウォークスルー

3. QVAC ゲートウェイ

モデルの読み込み、チャンクの取り込み、検索、ストリーミング生成 — すべてを 1 つのクラスの裏側で完結させる。

src/qvac.ts は LocalLens の中で唯一 QVAC SDK と話す場所です。それ以外は すべて QvacGateway を経由します。これがアプリの残りを SDK フリーに 保つ仕組みです。

ゲートウェイの形

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
}

private なフィールドが 3 つ、public なメソッドが 3 つ、加えてライフ サイクル用のヘルパー。表面はこれだけです。

モデルの遅延読み込み

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

遅延プロパティが 2 つあります。

  • embeddingModelId は最初に読み込まれ、フォールバックはしません。 GTE_LARGE_FP16 は小さいので、読めると仮定して問題ありません。
  • chatModelId はまず QWEN3_1_7B_INST_Q4 を試し、読み込みに失敗したら QWEN3_600M_INST_Q4 にフォールバックします。

readyPromise によって同時呼び出しが 1 つの読み込みを共有します。同じ ティックに 2 つのリクエストが来ても、同じソースに対して loadModel が 二重に走ることはありません。

チャンクの取り込み

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

押さえておくべき点が 2 つあります。

  • closeWorkspace(workspace, true) は ingest のに呼ばれます。同名の 既存ワークスペースをクリアするので、再取り込みは破壊的かつ冪等です。
  • chunk: false は QVAC に「ドキュメントはすでに分割済み」と伝えます。 分割は rag.ts で済ませています。二重にやるのは間違いになります。

チャンクは小さなテキスト封筒の形に再整形されます。

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

<chunk content>

…こうしておくと、ragSearch が返すときにメタデータが付いたまま戻って きます。

検索

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 は、ingest 時に書いた source: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,
  };
}

これで文字列が構造化された SearchHit オブジェクトとして戻ってきます。

ストリーミング生成

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

ゲートウェイは推論を AsyncGenerator<string> として公開します。 呼び出し元は、コンソール、HTTP レスポンス、UI に直接ストリームできます。

  • captureThinking: true は思考トークンをユーザー向け出力ストリームから 分離します。
  • kvCache: true を有効にすると、QVAC はフォローアップの質問に対して プレフィックスのアテンション状態を再利用できます。

ライフサイクル

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 は寛容です。QVAC がワークスペースは存在しないと言って きても問題ありません。呼び出し元はそれを消したかっただけです。close() は QVAC ランタイム全体を片付けるもので、シャットダウン時に LocalLensApp.close() から呼ばれます。

なぜモジュールでなくクラスにしているのか?

ゲートウェイは状態を持ちます: モデル ID、進行中の読み込み Promise。 フリー関数のモジュールにすると、その状態をすべての呼び出し元に押し付けるか、 モジュールレベルの可変領域に隠すことになります。小さなクラスがその カプセル化を最も安く達成できる方法です。

次は JSON ストアです。brain とチャンクの 永続化を扱います。

On this page