0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMで社内文書を検索できるRAGを構築する:チャンク分割とメタデータ設計の実践

0
Last updated at Posted at 2026-10-16

社内規程や設計書を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を検証して、最も効果が大きかった改善はモデルの変更ではなく、データの持ち方を見直すことでした。

  • チャンクは見出しや章名を含め、意味のまとまりで分割する
  • 文書の版、適用日、部署、権限をメタデータとして持つ
  • 権限制御はプロンプトではなく検索段階で行う
  • 回答だけでなく、参照元とバージョンも表示する
  • 小さな質問セットを作り、検索結果を継続的に検証する

最初から完璧な分割ルールを作る必要はありません。まずは代表的な文書と質問を数件用意し、どのチャンクが検索され、どの情報が不足するのかを確認します。その結果をもとにチャンクサイズとメタデータを調整する方法が、実装と運用の両面で進めやすいと感じました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?