LangChain.js の RAG で回答精度を上げるチャンク分割テクニック 3 選
TL;DR
- RAG の回答精度はチャンク分割の品質に大きく左右される
- LangChain.js の
RecursiveCharacterTextSplitterで区切り文字を調整するだけで精度が変わる - Overlap・メタデータ付与・親子チャンクの 3 テクニックを紹介
動作確認環境
- Node.js 22.x
- langchain 0.3.x / @langchain/openai 0.3.x / @langchain/community 0.3.x
- TypeScript 5.x
npm install langchain @langchain/openai @langchain/community hnswlib-node
問題: RAG が的外れな回答を返す
LangChain.js で RAG を組んだものの、関連するドキュメントを取得しているはずなのに回答が的外れ。原因の多くはチャンクの切り方にある。
// ❌ よくあるNG例: 固定長で機械的に分割
const splitter = new CharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 0, // オーバーラップなし
separator: "\n",
});
文の途中でぶった切られたチャンクでは、ベクトル検索のマッチ精度が落ちる。
テクニック 1: RecursiveCharacterTextSplitter の区切り文字を調整
RecursiveCharacterTextSplitter は複数の区切り文字を優先度付きで試す。日本語ドキュメントの場合、デフォルトの区切り文字だとうまく分割できないことがある。
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 200,
separators: [
"\n## ", // H2見出し(最優先)
"\n### ", // H3見出し
"\n\n", // 段落区切り
"\n", // 改行
"。", // 日本語の句点
".", // 英語のピリオド
" ", // スペース
"", // 最終手段:1文字ずつ
],
});
const docs = await splitter.createDocuments([markdownText]);
ポイント: Markdown の見出し(## 、### )を最優先にすることで、意味的にまとまったチャンクを作る。見出し単位で分割されるため、ベクトル検索で「○○について」と聞いたときに、該当セクション全体が取得される。
テクニック 2: チャンクにメタデータを付与
チャンクにメタデータ(ソースファイル名、セクション見出し、ページ番号など)を付与すると、リトリーバル後のフィルタリングやソース表示が可能になる。
import { Document } from "langchain/document";
// Markdown からセクション単位でチャンクを作成
function splitMarkdownWithMetadata(
markdown: string,
source: string
): Document[] {
const sections = markdown.split(/(?=^## )/gm);
return sections.map((section, index) => {
// セクション見出しを抽出
const headingMatch = section.match(/^## (.+)/);
const heading = headingMatch ? headingMatch[1].trim() : `section-${index}`;
return new Document({
pageContent: section.trim(),
metadata: {
source,
section: heading,
index,
charCount: section.length,
},
});
});
}
const docs = splitMarkdownWithMetadata(content, "api-reference.md");
メタデータがあると、検索結果を「このドキュメントの○○セクションより」と表示でき、ユーザーが原文を確認しやすくなる。
テクニック 3: 親子チャンク(Parent-Child Chunking)
小さなチャンクで精度よく検索し、回答生成時には大きなチャンクのコンテキストを使う。LangChain.js の ParentDocumentRetriever で実現できる。
import { ParentDocumentRetriever } from "langchain/retrievers/parent_document";
import { HNSWLib } from "@langchain/community/vectorstores/hnswlib";
import { OpenAIEmbeddings } from "@langchain/openai";
import { InMemoryStore } from "langchain/storage/in_memory";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
// 子チャンク(検索用): 小さめ
const childSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 200,
chunkOverlap: 50,
});
// 親チャンク(生成用): 大きめ
const parentSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const vectorstore = await HNSWLib.fromDocuments([], new OpenAIEmbeddings());
const docstore = new InMemoryStore();
const retriever = new ParentDocumentRetriever({
vectorstore,
docstore,
childSplitter,
parentSplitter,
});
// ドキュメントを追加(自動的に親子チャンクが作成される)
await retriever.addDocuments(documents);
// 検索時は子チャンクでマッチ → 親チャンクを返す
const results = await retriever.invoke("認証の実装方法は?");
// results: 親チャンク(1000文字)が返る
なぜ効果的か: 小さなチャンク(200文字)はピンポイントでマッチしやすい。でも 200 文字だけでは LLM が回答を生成するのに文脈が足りない。親チャンク(1000文字)を返すことで、検索精度と文脈の両方を確保できる。
まとめ
| テクニック | 効果 | 実装コスト |
|---|---|---|
| 区切り文字調整 | 意味的にまとまったチャンク | 低(設定変更のみ) |
| メタデータ付与 | フィルタリング+ソース表示 | 中(カスタム分割関数) |
| 親子チャンク | 検索精度+文脈保持の両立 | 中(ParentDocumentRetriever) |
RAG の回答品質に悩んでいるなら、まずチャンク分割を見直すのが最もコスパが良い。
ベクトルストアの選定やリトリーバル戦略、本番運用時のパフォーマンス最適化など、LangChain.js RAG の全体像は LangChain.js RAG実装ガイド — ベクトル検索から本番運用まで でまとめています。