3. QVAC ゲートウェイ
モデルの読み込み、チャンクの取り込み、検索、ストリーミング生成 — すべてを 1 つのクラスの裏側で完結させる。
src/qvac.ts は LocalLens の中で唯一 QVAC SDK と話す場所です。それ以外は
すべて QvacGateway を経由します。これがアプリの残りを SDK フリーに
保つ仕組みです。
ゲートウェイの形
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 とチャンクの 永続化を扱います。