社内規程や設計書をLLMに検索させるRAG(Retrieval-Augmented Generation)を試すと、最初は「文書をベクトル化して検索すれば動く」と考えがちです。しかし、実際に検証すると回答品質を左右するのは、モデルの性能だけではありません。
特に重要だったのが、文書をどの単位で分割するかという「チャンク設計」と、検索結果を絞り込むための「メタデータ設計」です。
本記事では、社内文書を対象にRAGを構築した際の考え方を、Pythonのコード例とともに紹介します。結論から言うと、最初から大きなチャンクを作るのではなく、「意味のまとまり」と「検索後に使う条件」を先に定義することが、安定したRAGへの近道でした。
まず検索単位を決める
検証では、次のような文書を想定しました。
- 就業規則
- 情報セキュリティ規程
- 開発手順書
- 障害対応 runbook
- プロジェクトごとの設計書
最初に文書全体をそのまま埋め込みに渡す方法を試しましたが、文書が長いと検索結果が大きくなり、質問に直接関係しない内容までLLMに渡されます。
そこで、文書を「質問への回答に使える最小単位」に分割しました。
例えば、次の文章を1つのチャンクにします。
第12条 パスワードの管理
パスワードは第三者に共有してはならない。
また、90日ごとに変更し、過去5回に使用したものは再利用しない。
一方で、見出しと本文を別々に分割すると、「何の規則なのか」が失われます。チャンクには、可能な限り見出しや章名も含めるようにしました。
情報セキュリティ規程 > アカウント管理 > パスワードの管理
パスワードは第三者に共有してはならない。
また、90日ごとに変更し、過去5回に使用したものは再利用しない。
チャンク分割の実装
Markdown文書を対象に、見出しを保持しながら分割する簡単な例です。実運用では表や箇条書きの扱いも追加しますが、最初の検証には十分でした。
from pathlib import Path
def split_markdown(text: str, max_chars: int = 800):
chunks = []
headings = []
buffer = []
for line in text.splitlines():
if line.startswith("#"):
headings = [line.lstrip("#").strip()]
continue
if line.strip():
buffer.append(line)
current = "\n".join(buffer)
if len(current) >= max_chars:
prefix = " > ".join(headings)
content = f"{prefix}\n\n{current}" if prefix else current
chunks.append({
"text": content,
"heading": prefix,
})
buffer = []
if buffer:
prefix = " > ".join(headings)
content = f"{prefix}\n\n" + "\n".join(buffer)
chunks.append({
"text": content,
"heading": prefix,
})
return chunks
text = Path("security-policy.md").read_text(encoding="utf-8")
chunks = split_markdown(text)
for i, chunk in enumerate(chunks):
print(i, chunk["heading"], len(chunk["text"]))
max_charsは文書の種類によって調整します。規程の条文は短め、設計書の説明はやや長めにするなど、文書タイプごとに設定を分けると扱いやすくなります。
また、固定長で単純に切るだけでは、段落の途中や表の途中で分割されることがあります。まずは見出し、段落、箇条書きの境界を優先し、それでも長すぎる場合だけ文字数で分割する設計にしました。
メタデータは検索と回答の両方に使う
チャンク本文だけでなく、次の情報をメタデータとして保存しました。
metadata = {
"document_id": "security-policy-v3",
"title": "情報セキュリティ規程",
"department": "コーポレートIT",
"document_type": "policy",
"version": "3.0",
"effective_from": "2026-04-01",
"access_level": "全社員",
"section": "アカウント管理",
"source": "confluence/security-policy",
}
特に重要だったのは、次の3項目です。
1つ目は version です。改訂前の規程が検索結果に混ざると、もっともらしい誤回答になります。
2つ目は effective_from です。「現在のルール」を回答させるには、適用開始日を比較できる必要があります。
3つ目は access_level です。全社員向け、開発部門向け、管理職向けなど、利用者の権限に応じて検索対象を制限できます。
メタデータは後から追加するのが難しいため、最初の段階で「検索時に絞り込みたい条件」を洗い出しておくことをおすすめします。
ベクトル検索の最小構成
文書登録から検索、回答と参照元の表示までの流れを図にすると次の通りです。
検証では、チャンク本文とメタデータをJSONとして保存し、埋め込みベクトルを作成しました。ベクトルDBは何を使っても構いませんが、重要なのは「本文」と「メタデータ」を同じIDで管理することです。
登録データのイメージは次のようになります。
{
"id": "security-policy-v3-0012",
"text": "情報セキュリティ規程 > アカウント管理 ...",
"metadata": {
"document_id": "security-policy-v3",
"version": "3.0",
"access_level": "全社員",
"section": "アカウント管理"
}
}
検索時は、質問をベクトル化するだけでなく、利用者の条件も渡します。
def build_filter(user_department: str):
filters = {
"access_level": ["全社員", user_department]
}
return filters
query = "パスワードは何日ごとに変更しますか?"
filters = build_filter("開発部門")
# 実際のベクトルDBでは、query_vector と filters を指定して検索する
results = vector_store.search(
query=query,
filters=filters,
top_k=5,
)
ここで注意したのは、アクセス制御をLLMへのプロンプトだけに任せないことです。「この情報は回答に使わないでください」と指示するだけでは、検索結果に機密情報が含まれる可能性があります。許可されていない文書は、検索段階で除外する必要があります。
回答品質をどう確認するか
RAGは動作するだけでは不十分なので、質問と期待する根拠を小さなテストセットにしました。
[
{
"question": "パスワードは何日ごとに変更しますか?",
"expected_document": "security-policy-v3",
"expected_section": "アカウント管理"
},
{
"question": "障害発生時の一次連絡先はどこですか?",
"expected_document": "incident-runbook-v2",
"expected_section": "初動対応"
}
]
確認したのは、次の3点です。
- 正しい文書が検索されるか
- 回答に必要な条文や段落が含まれるか
- 改訂前の文書が混ざっていないか
特に、回答文だけを読んで評価すると、誤った根拠でも自然な文章になっていることがあります。そのため、回答と一緒に文書名、章、バージョンを表示するようにしました。
回答:
パスワードは90日ごとに変更します。
参照元:
情報セキュリティ規程 v3.0
アカウント管理 > パスワードの管理
この表示だけでも、利用者が回答の妥当性を確認しやすくなります。
まとめ
社内文書向けRAGを検証して、最も効果が大きかった改善はモデルの変更ではなく、データの持ち方を見直すことでした。
- チャンクは見出しや章名を含め、意味のまとまりで分割する
- 文書の版、適用日、部署、権限をメタデータとして持つ
- 権限制御はプロンプトではなく検索段階で行う
- 回答だけでなく、参照元とバージョンも表示する
- 小さな質問セットを作り、検索結果を継続的に検証する
最初から完璧な分割ルールを作る必要はありません。まずは代表的な文書と質問を数件用意し、どのチャンクが検索され、どの情報が不足するのかを確認します。その結果をもとにチャンクサイズとメタデータを調整する方法が、実装と運用の両面で進めやすいと感じました。