2. チャンク化と根拠付きプロンプト
モデルライフサイクルをまだ知らない検索側のロジック。
src/rag.ts は 2 つの仕事をします。ドキュメントをチャンクに分割すること、
そして取得した抜粋にモデルを根拠付けるプロンプトを構築すること。どちらも
ほぼ純粋関数です。トークン化のために QVAC の ragChunk を呼ぶ以外は、
domain.ts の型しか知りません。
チャンク化
import { ragChunk } from "@qvac/sdk";
import type { ChatMessage, LocalDocument, SearchHit, TextChunk } from "./domain.ts";
export type ChunkOptions = {
brainId: string;
chunkSize?: number;
chunkOverlap?: number;
};
export async function chunkDocuments(
documents: LocalDocument[],
options: ChunkOptions,
): Promise<TextChunk[]> {
const chunks: TextChunk[] = [];
for (const document of documents) chunks.push(...(await chunkDocument(document, options)));
return chunks;
}
export async function chunkDocument(
document: LocalDocument,
{ brainId, chunkSize = 220, chunkOverlap = 40 }: ChunkOptions,
): Promise<TextChunk[]> {
if (chunkSize <= chunkOverlap) throw new Error("chunkSize must be greater than chunkOverlap");
const text = document.content.replace(/\r\n/g, "\n").trim();
if (!text) return [];
const qvacChunks = await ragChunk({
documents: text,
chunkOpts: {
chunkSize,
chunkOverlap,
chunkStrategy: "paragraph",
splitStrategy: "token",
},
});
return qvacChunks
.map((chunk) => chunk.content.trim())
.filter(Boolean)
.map((content, chunkIndex) => ({
id: `${document.checksum.slice(0, 12)}-${chunkIndex}`,
brainId,
relativePath: document.relativePath,
chunkIndex,
content,
checksum: document.checksum,
}));
}チャンクサイズとオーバーラップ
デフォルト: 220 トークン、40 トークンのオーバーラップ。ドキュメント ライクなコンテンツでこの値が機能する理由が 2 つあります。
- README やノートの段落の多くは 220 トークンに余裕で収まるので、 チャンク分割が文章の途中で起きにくい。
- 40 トークンのオーバーラップによって、2 つのチャンクをまたぐ文も どちらの側からも検索可能になり、それでいてインデックスを膨張させない。
実行時チェック chunkSize > chunkOverlap は無言の無限ループへのガードです。
これがないと、QVAC SDK が前に進まないオーバーラップチャンクを生成する
可能性があります。
安定した ID
チャンク ID はドキュメントの checksum とチャンクインデックスを組み合わせ ます。
id: `${document.checksum.slice(0, 12)}-${chunkIndex}`これにより 2 つのことが言えます。
- 同じファイルを再インデックスしても同じチャンク ID が生成されるので、 あとから差分再インデックスを扱いやすくなります。
- 1 バイトでも編集すれば checksum が変わり、そのファイルのすべての チャンク ID も変わります。これは正しい挙動です。古い embedding は もはや有効ではないからです。
根拠付きプロンプト
export function buildGroundedHistory(question: string, hits: SearchHit[]): ChatMessage[] {
const context = hits
.map(
(hit, index) => `[${index + 1}] ${hit.relativePath}#chunk-${hit.chunkIndex}\n${hit.content}`,
)
.join("\n\n---\n\n");
return [
{
role: "system",
content: [
"You are LocalLens, a local-first file chat assistant that answers questions strictly from the provided source excerpts.",
"Rules:",
"1. Only use facts that appear in the excerpts. If the answer is not in them, say so plainly.",
"2. Refer to source excerpts inline using bracketed numbers, for example [1] or [2], when you use them.",
"3. Answer in the same language as the user's question.",
"4. Keep answers focused and concrete. No filler.",
"5. Do not include hidden reasoning, chain-of-thought, or thinking tags.",
].join(" "),
},
{
role: "user",
content: `Source excerpts:\n\n${context || "No matching chunks were found."}\n\nQuestion:\n${question}`,
},
];
}これがハルシネーション対策の全てです。5 つのシステムルールと、抜粋を 番号付き証拠として並べた user ターン。
「マッチなし」フォールバック
hits が空のとき、user メッセージには抜粋ブロックの代わりに文字列
"No matching chunks were found." が入ります。そうなるとシステム
プロンプトの第 1 ルールが効いて、モデルは「分からない」と答えます。
番号付き抜粋と引用
各ヒットは次のようになります。
[N] relative/path#chunk-<index>
<chunk content>[N] が、モデルが回答中で [1]、[2] として返してくる部分です。
相対パスがあれば、呼び出し元はファイルへリンクするソース一覧を描画
できます。
なぜチャット履歴を持たないのか?
buildGroundedHistory を呼ぶたびに、現在の質問と検索ヒットに基づく
新しい 2 メッセージ履歴が生成されます。過去のターンは引き継がれません。
これが LocalLens がセッション全体で根拠付きを保てる理由です。各質問は
独立した証拠検索のラウンドになります。
このステップ後に動かせるもの
テストは tests/prompt.test.ts と tests/chunker.test.ts にあります。
domain.ts と rag.ts だけで両方とも通ります。
次は QVAC ゲートウェイです。チャンク化と プロンプト構築が、モデル読み込みと推論に出会います。