0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAGで文が切れて検索できない — 固定長チャンクをやめた3つの理由と親子チャンクの完全実装

0
Posted at

「社内規程チャットボットのRAGが、ある質問だけ答えられない——この不可解な失敗に、私は半日を溶かしました。」先週、私が作った社内規程ボットで「在宅勤務の申請はどうやるの?」と聞くと、**「規程に記載がありません」**と返してきたのです。在宅勤務規程に「申請は前日の17時までに勤怠システムで行うこと」と、ちゃんと書いてあるのにです。原因を追って詰まりながらたどり着いたのは、チャンク分割でした。固定長60文字で機械的に切ったせいで、文の途中で断絶したチャンクが検索に掛からなくなっていたのです。この記事では、私が固定長チャンクをやめた3つの理由と、子チャンクで検索して親チャンクを渡す「親子チャンク(Small-to-Big)」の完全実装を、コピペで動く形で載せます。

結論: 「子で検索、親で回答」の2層に分ける

RAGの精度がチャンク分割で決まることはよく知られています。しかし盲点なのは**「検索用のチャンク」と「LLMに渡すチャンク」は別物**だという点です。

親子チャンク(Small-to-Big)は、この2つを分離する設計です。

  • 子チャンク = 文単位(検索に使う。短い方がベクトルの意味が濃くなる)
  • 親チャンク = 文書単位(LLMに渡す。長い方が文脈が保たれる)

検索は文単位でピンポイントに当て、回答は文書単位で文脈ごと渡す。私の環境では、この変更だけで「記載がありません」の誤答が消えました。

以下が完全形のコードです。依存はnumpyだけで、この記事のコードを上から1ファイルに貼れば動きます(LLM呼び出し部分のみAPIキーが必要。それ以外はキーなしで検索まで確認できます)。

1. サンプル文書と親子インデックス構築

import os
import numpy as np

# ============================================================
# 1. サンプル文書(社内規程の抜粋を想定した4文書)
# ============================================================
DOCS = [
    {
        "id": "D001",
        "title": "経費精算規程",
        "text": (
            "交通費は月額1万円を上限として精算できる。"
            "領収書は電子データでの提出を認めるが、紙の領収書も併せて保管すること。"
            "宿泊費は1泊あたり1万5千円までを会社負担とする。"
            "超過分は自己負担となるため、事前の上司承認を得た場合は例外とする。"
            "精算の締め日は毎月25日で、翌月10日の給与に合算して支払われる。"
        ),
    },
    {
        "id": "D002",
        "title": "在宅勤務規程",
        "text": (
            "在宅勤務は週3日まで申請できる。"
            "申請は前日の17時までに勤怠システムで行うこと。"
            "在宅勤務中も就業時間はオフィス勤務と同一とする。"
            "通信費として月額3千円の一律支給があり、別途申請は不要である。"
            "セキュリティのため、顧客情報を扱う業務は在宅では禁止する。"
        ),
    },
    {
        "id": "D003",
        "title": "情報セキュリティ規程",
        "text": (
            "パスワードは12文字以上で、90日ごとに変更する。"
            "顧客情報を扱う端末は会社支給の端末に限る。"
            "クラウドサービスの利用は、事前に情報システム部の承認を得ること。"
            "機密情報の外部送信は、送信先の確認記録を残すこと。"
            "違反が確認された場合は、アクセス権の即時停止を行う。"
        ),
    },
    {
        "id": "D004",
        "title": "備品管理規程",
        "text": (
            "備品の購入は所属長の承認後に資材部へ依頼する。"
            "1件5万円以上の購入は稟議書の起案が必要となる。"
            "備品の紛失は、30日以内に資材部へ報告する義務がある。"
            "ノートPCの貸出期間は原則3年で、更新時は再申請を行う。"
        ),
    },
]


# ============================================================
# 2. チャンク分割 — 子=文、親=文書(Small-to-Bigの核心)
# ============================================================
def split_sentences(text: str) -> list:
    """文単位に分割する(。区切り・区切り文字は文末に戻す)"""
    parts = [p.strip() for p in text.replace("。", "。\u0000").split("\u0000") if p.strip()]
    return parts


def build_parent_child(docs: list) -> dict:
    """親チャンク(文書)と子チャンク(文)の2層インデックスを作る"""
    parents, children = [], []
    for d in docs:
        p_idx = len(parents)
        parents.append({"parent_id": d["id"], "title": d["title"], "text": d["text"]})
        for sent in split_sentences(d["text"]):
            children.append({"parent_id": d["id"], "p_idx": p_idx, "text": sent})
    return {"parents": parents, "children": children}

p_idx(親の行番号)を子に持たせるのが肝です。検索で子がヒットしたら、この番号で親を引きます。

2. 検索エンジン(文字bigramのTF-IDF・依存はnumpyのみ)

# ============================================================
# 3. 検索 — 文字bigramのTF-IDF(依存はnumpyのみ)
# ============================================================
def bigrams(t: str) -> list:
    return [t[i:i + 2] for i in range(len(t) - 1)]


def build_matrix(chunks: list) -> dict:
    vocab = sorted({g for c in chunks for g in bigrams(c["text"])})
    gid = {g: i for i, g in enumerate(vocab)}
    N, V = len(chunks), len(vocab)
    tf = np.zeros((N, V))
    for n, c in enumerate(chunks):
        for g in bigrams(c["text"]):
            tf[n, gid[g]] += 1.0
    df = (tf > 0).sum(axis=0)
    idf = np.log((N + 1.0) / (df + 1.0)) + 1.0
    W = tf * idf
    norm = np.linalg.norm(W, axis=1, keepdims=True)
    norm[norm == 0] = 1.0
    return {"W": W / norm, "idf": idf, "gid": gid}


def search(query: str, matrix: dict, children: list, k: int = 3) -> list:
    q = np.zeros((1, len(matrix["idf"])))
    for g in bigrams(query):
        if g in matrix["gid"]:
            q[0, matrix["gid"][g]] += 1.0
    q = q * matrix["idf"]
    nq = np.linalg.norm(q)
    if nq == 0:
        return []
    scores = (matrix["W"] @ (q / nq).flatten())
    top = np.argsort(-scores)[:k]
    return [{"child": children[i], "score": float(scores[i])} for i in top]

ここではTF-IDFを使っていますが、Embeddingモデルに差し替えても構造は同じです(searchが返す「子チャンクとスコア」の形さえ守れば、検索器は何でもいい)。

3. Small-to-Big: 子で当てて、親を畳んで渡す

# ============================================================
# 4. small-to-big: 子で当てて、親を渡す
# ============================================================
def retrieve_context(question: str, index: dict, k: int = 3, max_parents: int = 2) -> dict:
    hits = search(question, index["matrix"], index["children"], k=k)
    seen, ranked_parents = set(), []
    for h in hits:
        p = h["child"]["p_idx"]
        if p not in seen:
            seen.add(p)
            ranked_parents.append((p, h["score"], h["child"]["text"]))
    top_parents = ranked_parents[:max_parents]
    context = "\n".join(
        f"【{index['parents'][p]['title']}】\n{index['parents'][p]['text']}" for p, _, _ in top_parents
    )
    return {"hits": hits, "context": context,
            "parent_titles": [index["parents"][p]["title"] for p, _, _ in top_parents]}


def answer_with_llm(question: str, context: str) -> str:
    """検索結果(親チャンク)を文脈としてLLMに渡す完全形"""
    from openai import OpenAI
    client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "文脈に根拠がある場合のみ回答し、根拠を【】付きで示せ。根拠がなければ「規程に記載がありません」と答えよ。"},
            {"role": "user", "content": f"文脈:\n{context}\n\n質問: {question}"},
        ],
        temperature=0,
    )
    return resp.choices[0].message.content

retrieve_contextのseenがポイントです。ヒットした子が同じ親に集中しても、親は1回だけ採用します(これが後述の「重複の無駄」の解消です)。

4. 実行 — 検索から回答まで

# ============================================================
# 5. 実行: 3方式の比較(子のみ vs 親子)
# ============================================================
if __name__ == "__main__":
    index = build_parent_child(DOCS)
    index["matrix"] = build_matrix(index["children"])

    q = "顧客情報を扱う端末のルールは?"
    r = retrieve_context(q, index, k=3, max_parents=2)

    print("=== 検索にヒットした子チャンク(文) ===")
    for h in r["hits"]:
        print(f"  {h['score']:.3f} [{h['child']['parent_id']}] {h['child']['text']}")

    print("\n=== small-to-big後のコンテキスト(親チャンク) ===")
    print(r["context"])

    print("\n=== 渡す文脈の長さの比較 ===")
    child_ctx = "\n".join(h["child"]["text"] for h in r["hits"])
    print(f"  子チャンクのみ渡す場合 : {len(child_ctx)}文字(文脈が切り刻まれる)")
    print(f"  親チャンクで渡す場合   : {len(r['context'])}文字(規程全体の文脈が残る)")
    print(f"  採用された親: {r['parent_titles']}")

    if os.environ.get("OPENAI_API_KEY"):
        print("\n=== LLM回答 ===")
        print(answer_with_llm(q, r["context"]))
    else:
        print("\n(OPENAI_API_KEY 未設定のため LLM 回答はスキップ)")

私の環境での実行結果はこうなりました。

=== 検索にヒットした子チャンク(文) ===
  0.669 [D003] 顧客情報を扱う端末は会社支給の端末に限る。
  0.355 [D002] セキュリティのため、顧客情報を扱う業務は在宅では禁止する。
  0.042 [D003] 機密情報の外部送信は、送信先の確認記録を残すこと。

=== small-to-big後のコンテキスト(親チャンク) ===
【情報セキュリティ規程】
パスワードは12文字以上で、90日ごとに変更する。顧客情報を扱う端末は会社支給の端末に限る。…

=== 渡す文脈の長さの比較 ===
  子チャンクのみ渡す場合 : 77文字(文脈が切り刻まれる)
  親チャンクで渡す場合   : 276文字(規程全体の文脈が残る)
  採用された親: ['情報セキュリティ規程', '在宅勤務規程']

検索は「顧客情報を扱う端末は会社支給の端末に限る。」という1文だけにヒットしますが、LLMに渡るのは情報セキュリティ規程の全文です。この差が回答の品質を決めます。

失敗した設計: 固定長チャンクをやめた3つの理由

私が最初に書いたのは、よくある「固定長で切る」実装でした。これをやめた理由が3つあります。すべて実測に基づくものです。

理由1: 文の途中で切れて、検索に掛からなくなる

情報セキュリティ規程を固定長60文字で切ると、こうなりました。

chunk[0] パスワードは12文字以上で、90日ごとに変更する。顧客情報を扱う端末は会社支給の端末に限る。クラウドサービスの利用は、事
chunk[1] 前に情報システム部の承認を得ること。機密情報の外部送信は、送信先の確認記録を残すこと。違反が確認された場合は、アクセス権
chunk[2] の即時停止を行う。

見てください。「事前に情報システム部の承認を得ること」という一文が、「事」で切れて前後に分断されています。冒頭の「申請はどうやるの?」に答えられなかったのは、このパターンで質問と一致するはずの文が壊れていたためでした。文の境界で切るだけで、この事故は構造的に起きなくなります。

理由2: 子チャンクをそのまま渡すと、文脈が切り刻まれる

「検索にヒットしたチャンクをそのままLLMに渡す」実装も失敗でした。k=5で検索した実測では、子だけを渡すと130文字でしたが、親チャンクで渡すと290文字。規程は前後の文に依存するのに、断片だけではLLMが判断できない場面が出ます。「例外」の条件が次の文に書かれていたら、前の文だけ渡してもLLMは例外を知らないまま答えます。

理由3: トップkが「同じ文書の類似文」で埋まる

これが一番直感的に分かりにくい罠です。k=5で検索したら、内訳が同じ規程から4文でした。

0.328 [D002] 在宅勤務は週3日まで申請できる。
0.266 [D002] 在宅勤務中も就業時間はオフィス勤務と同一とする。
0.231 [D001] 精算の締め日は毎月25日で、翌月10日の給与に合算して支払われる。
0.054 [D002] セキュリティのため、顧客情報を扱う業務は在宅では禁止する。
0.049 [D002] 申請は前日の17時までに勤怠システムで行うこと。

5枠のうち4枠を在宅勤務規程が占めています。似た話題の文は似たスコアになるため、トップkは特定文書の類似文で埋まり、他の文書の重要な1文がk枠から押し出されます。親に畳んで(1親1票にして)空いた枠で別の親を追加すると、文脈の多様性が回復します。

比較: 3つのチャンク戦略

戦略 検索精度 文脈の完全性 実装の手間 私の評価
固定長チャンク(1層) × 文断絶で事故る × 断片だけ 低い 失敗した設計。使わない
文チャンク(1層・子をそのまま渡す) ◎ ピンポイント × 130文字で文脈不足 低い 検索には最適、回答には不十分
親子チャンク(2層・Small-to-Big) ◎ 子で検索 ◎ 親で文書全体 中程度 本番運用の正解

「実装の手間」が1つ増える以外、デメリットがありません。親チャンクのサイズが大きすぎる場合は、親を「セクション」に、子を「文」にすれば調整できます。

実務で使う時の3つのコツ

1. 親のサイズに上限を設ける

親=文書全体は、文書が長いとトークンを食います。実務では**親を800〜1,000字程度の「セクション」**に切り、その中で子(文)を作る2層にしています。私の規程ボットは、この構成で運用が安定しています。

2. テーブルやリストは「文」で切らない

表形式のデータは「。」が無いため、文分割すると1行ずつバラけます。テーブルは親に属する付属物として、親側のテキストに含めたまま子にしないのが安全です。

3. ヒット件数ではなく「親の採用数」を見る

検索のk(子のヒット数)と、LLMに渡す親の数(max_parents)は別パラメータです。私の運用ではk=5、親は2〜3が丁度よかったです。親を増やすほど文脈は濃くなりますが、ノイズも増えるため、まず2から始めて質問ログで調整するのがおすすめです。

まとめ

  • 検索用チャンクと回答用チャンクは別物。子=文で検索、親=文書で渡す(Small-to-Big)
  • 固定長分割は文断絶の事故源。切るなら文の境界。理由1の実測参照
  • 子をそのまま渡すと文脈が130文字に切り刻まれる。親で渡すと290文字で文脈が残る(理由2)
  • トップkは同一文書の類似文で埋まる。親に畳んで1親1票にする(理由3)

冒頭の「在宅勤務の申請」質問も、親子チャンクに変えた後は「前日の17時までに勤怠システムで申請する(在宅勤務規程)」と正しく答えるようになりました。RAGの精度に悩んだ時、真っ先に疑うべきはモデルでもプロンプトでもなく、チャンク分割の設計です。この記事のコードはnumpyだけで動くので、自分のデータで10分で試せます。同じ「記載がありません」に悩む人の時間が、この記事で1時間でも節約されれば幸いです。

🎁 読者特典を受け取る(無料・メール登録)

参考書籍: RAG構築入門 — LLMに「知識」を与える検索拡張生成の実践(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?