RAG本番2年半で学んだ精度を決める7つの構成要素と2026年の現実解

「全然答えてくれない」「的外れな回答ばかり」——RAG導入初期に同じ経験しませんでしたか?本番2年半の試行錯誤から、チャンキング・リランキング・GraphRAGまで実感を共有します。

RAGシステム本番2年半で学んだ、精度を決定する7つの構成要素と2026年の現実解

先日、社内の勉強会でRAGの話をしたら「チャンキングってどう決めてるの?」「リランキング本当に効く?」という質問が想像以上に来た。うちのチームはRAGを本番に入れてもう2年半になるんだけど、正直最初の半年はひどかった。ユーザーから「全然答えてくれない」「的外れな回答ばかり」という声が続いて、エンジニアとして普通に凹んだ。

そこから試行錯誤を繰り返して、今は「まあ実用に耐えるね」と言えるレベルになった。RAGは仕組み自体はシンプルなんだけど、精度を本当に上げようとすると意外と泥臭い工夫が必要なんですよね。

以前書いたRAG本番運用1年で痛感したことRAG本番2年で「なんか惜しい」を乗り越えた話でも書いてきたけど、今回は2026年時点の最新構成も含めて整理しなおした。特にGraphRAGとAdaptive RAGは去年から本番レベルで使えるようになってきたので、そのあたりの実感も共有したい。

RAGアーキテクチャの全体像と2026年版の変化点

2年前と比べて何が変わったかというと、大きく3つある。

1つ目は埋め込みモデルの多様化。text-embedding-3-largeが標準だった時代から、2026年時点ではマルチリンガル・マルチモーダル対応のモデルが実用レベルになった。日本語ドキュメントを扱うなら埋め込みモデルの選定だけで精度が10〜20%変わる経験をした。

2つ目はクエリ変換技術の成熟。最初の頃は「ユーザーの質問をそのままベクトル検索にかける」ことしかやってなかったけど、HyDE(Hypothetical Document Embeddings)やStep-Back Promptingが実用的になった。

3つ目はGraphRAGの実用化。MicrosoftのGraphRAGが2025年後半から本番ユースケースで使えるようになってきた感触がある。知識グラフを組み合わせることで、複雑な関係性の質問に強くなった。

まずは今の構成図を見てほしい。

flowchart TB
    subgraph Ingestion["📥 データ取り込みパイプライン"]
        D1["ドキュメント (PDF/Word/HTML)"] --> PP["前処理\n(クリーニング・構造化)"]
        PP --> CH["チャンキング\n(Semantic / Fixed / Sliding)"]
        CH --> EM["埋め込みモデル\n(multilingual-e5-large-instruct)"]
        CH --> KG["Knowledge Graph\n(GraphRAG用)"]
    end

    subgraph Storage["💾 ストレージ層"]
        EM --> VDB["ベクトルDB\n(pgvector / Qdrant)"]
        KG --> GDB["グラフDB\n(Neo4j)"]
        D1 --> OBJ["オブジェクトストア\n(原文保存)"]
    end

    subgraph Query["🔍 クエリ処理"]
        Q["ユーザー質問"] --> QA["クエリ分析\n(意図分類)"]
        QA --> QT["クエリ変換\n(HyDE / Step-Back)"]
        QT --> VS["ベクトル検索"]
        QT --> KS["キーワード検索\n(BM25)"]
        QT --> GS["グラフ検索"]
    end

    subgraph Retrieval["📋 検索・統合"]
        VS --> RRF["RRF融合\n(Reciprocal Rank Fusion)"]
        KS --> RRF
        GS --> RRF
        RRF --> RR["リランカー\n(cross-encoder)"]
        RR --> CTX["コンテキスト構築"]
    end

    subgraph Generation["🤖 生成"]
        CTX --> LLM["LLM\n(Claude 3.7 / GPT-4o)"]
        LLM --> ANS["回答"]
    end

    VDB --> VS
    GDB --> GS
    OBJ --> CTX

これが今うちで動いてる構成のベースだ。ひとつひとつ実体験ベースで話していく。

チャンキング戦略:ここで半分決まる

正直、RAGで一番効くのはチャンキングの設計だと思ってる。ベクトルDBの選定とかリランカーの精度とか色々話題になるけど、チャンクが悪いと全部崩れる。

うちのチームでやらかしたのが「とりあえず512トークンで固定分割」。PDFから取り込んだ技術仕様書を512トークンで機械的にブツ切りにしたら、表の途中で切れたり、セクションの文脈が失われたりして、検索ヒットしても答えに使えないチャンクが大量発生した。あれは本当に無駄な時間だった。

今はドキュメントの種類によってチャンキング戦略を変えてる。

from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
from typing import Literal
import re

class AdaptiveChunker:
    """
    ドキュメント種別に応じてチャンキング戦略を切り替える
    2026年時点でうちが本番で使ってる構成
    """
    
    def __init__(self):
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
        
        # 技術ドキュメント用: セクション境界を意識した再帰分割
        self.tech_splitter = RecursiveCharacterTextSplitter(
            chunk_size=800,
            chunk_overlap=100,
            separators=["\n## ", "\n### ", "\n\n", "\n", " "],
            length_function=len,
        )
        
        # FAQ・Q&A形式: 意味的な境界でセマンティック分割
        self.semantic_splitter = SemanticChunker(
            embeddings=self.embeddings,
            breakpoint_threshold_type="percentile",
            breakpoint_threshold_amount=85,
        )
        
        # 契約書・規約: 条項単位で分割
        self.legal_splitter = RecursiveCharacterTextSplitter(
            chunk_size=600,
            chunk_overlap=50,
            separators=["第\\d+条", "\n\n", "\n"],
            is_separator_regex=True,
        )
    
    def chunk(
        self, 
        text: str, 
        doc_type: Literal["technical", "faq", "legal", "general"]
    ) -> list[dict]:
        """
        種別に応じたチャンキングとメタデータ付与
        """
        if doc_type == "technical":
            docs = self.tech_splitter.create_documents([text])
        elif doc_type == "faq":
            docs = self.semantic_splitter.create_documents([text])
        elif doc_type == "legal":
            docs = self.legal_splitter.create_documents([text])
        else:
            # generalは少し大きめのチャンクで意味的まとまりを優先
            splitter = RecursiveCharacterTextSplitter(
                chunk_size=1000,
                chunk_overlap=150,
            )
            docs = splitter.create_documents([text])
        
        # Parent-Child Chunking用の親チャンクIDを付与
        # 検索は小さいチャンクで、回答生成は大きい文脈で使う
        chunks = []
        for i, doc in enumerate(docs):
            chunks.append({
                "content": doc.page_content,
                "metadata": {
                    "doc_type": doc_type,
                    "chunk_index": i,
                    "char_count": len(doc.page_content),
                }
            })
        
        return chunks

もう一個地味に効いてるのがParent-Child Chunking。検索は小さいチャンク(200〜400トークン)でやって、ヒットしたら親チャンク(800〜1200トークン)を回答生成に渡す手法だ。検索精度と文脈量のバランスがとれて、体感で回答品質が上がった。「なんか答えが途中で切れる」みたいな問題がかなり減るので、まだやってないチームにはまず試してほしい。

ハイブリッド検索とリランキング:ベクトル検索だけじゃ足りない理由

これは実際に痛い目を見るまで信じてなかったんだけど、ベクトル検索だけだと固有名詞や製品コード系のクエリが致命的に弱い。「ModelXYZ-2024の仕様を教えて」みたいな質問で、意味的に近いけど違うモデルの情報ばかりヒットする、という問題が起きた。

BM25(キーワード検索)と組み合わせるハイブリッド検索が2026年では定番になってるんだけど、融合方法に地味なノウハウがある。

from rank_bm25 import BM25Okapi
from qdrant_client import QdrantClient
from qdrant_client.models import SearchRequest
import numpy as np
from typing import Any

class HybridRetriever:
    """
    ベクトル検索 + BM25 + RRF融合
    本番で動かして調整したパラメータ込み
    """
    
    def __init__(self, qdrant_client: QdrantClient, collection_name: str):
        self.client = qdrant_client
        self.collection = collection_name
        # RRFのkパラメータ: 60がよく使われるが、
        # うちのドメインは40の方が精度が出た。要チューニング
        self.rrf_k = 40
    
    def vector_search(
        self, 
        query_embedding: list[float], 
        top_k: int = 20
    ) -> list[dict]:
        results = self.client.search(
            collection_name=self.collection,
            query_vector=query_embedding,
            limit=top_k,
            with_payload=True,
        )
        return [
            {"id": r.id, "score": r.score, "payload": r.payload}
            for r in results
        ]
    
    def bm25_search(
        self, 
        query: str, 
        corpus: list[str],
        doc_ids: list[str],
        top_k: int = 20
    ) -> list[dict]:
        tokenized_corpus = [doc.split() for doc in corpus]
        bm25 = BM25Okapi(tokenized_corpus)
        tokenized_query = query.split()
        scores = bm25.get_scores(tokenized_query)
        
        top_indices = np.argsort(scores)[::-1][:top_k]
        return [
            {"id": doc_ids[i], "score": float(scores[i])}
            for i in top_indices if scores[i] > 0
        ]
    
    def reciprocal_rank_fusion(
        self,
        *ranked_lists: list[dict],
    ) -> list[dict]:
        """
        複数のランキングリストをRRFで統合
        スコアの正規化より順位ベースの方が安定してる
        """
        rrf_scores: dict[str, float] = {}
        
        for ranked_list in ranked_lists:
            for rank, item in enumerate(ranked_list):
                doc_id = str(item["id"])
                if doc_id not in rrf_scores:
                    rrf_scores[doc_id] = 0.0
                rrf_scores[doc_id] += 1.0 / (self.rrf_k + rank + 1)
        
        sorted_results = sorted(
            rrf_scores.items(), 
            key=lambda x: x[1], 
            reverse=True
        )
        return [{"id": doc_id, "rrf_score": score} for doc_id, score in sorted_results]
    
    def retrieve(
        self,
        query: str,
        query_embedding: list[float],
        corpus: list[str],
        doc_ids: list[str],
        top_k: int = 5,
    ) -> list[dict]:
        vec_results = self.vector_search(query_embedding, top_k=20)
        bm25_results = self.bm25_search(query, corpus, doc_ids, top_k=20)
        
        fused = self.reciprocal_rank_fusion(vec_results, bm25_results)
        return fused[:top_k]

RRFのkパラメータ(上のコードのrrf_k)は「60が定番」とよく言われるけど、正直ドメインによって最適値がズレる。うちのケースはドキュメントが技術系で固有名詞が多かったから40の方が良かった。ここは実際に評価データセットを作って調整するしかない。「定番値を信じて終わり」は危険だと思う。

リランカーについては、cross-encoder/ms-marco-MiniLM-L-12-v2を長らく使ってたけど、2026年時点では日本語ならcl-nagoya/ruri-reranker-largeの方が精度が出てる。ベンチマークの数値と実際のドメインパフォーマンスがズレることがあるので、自前のテストセットで測ることを強くすすめる。

GraphRAGを本番投入した話:複雑な関係性の質問に強い

正直、最初はGraphRAGに懐疑的だった。グラフDBの運用コストを増やしてまで効果があるのか?って思ってたんだけど、ある種類の質問には明らかに強い。

「A製品とB製品の違いは?」「この規制が影響するのはどのシステム?」みたいな、複数エンティティ間の関係性を問う質問だ。フラットなベクトル検索だとどうしても「なんとなく似てる文書」を返してしまうけど、グラフ構造があると関係を辿れる。

flowchart LR
    subgraph GraphRAG_Flow["GraphRAG 処理フロー"]
        Q["クエリ: 製品AとCの\n共通依存コンポーネントは?"] 
        --> NER["エンティティ抽出\n(製品A, 製品C)"] 
        --> GQ["グラフクエリ生成\n(Cypher / SPARQL)"]
        GQ --> NEO["Neo4j Graph DB"]
        NEO --> GR["グラフ検索結果\n(共通ノード・エッジ)"]
        GR --> CTX["コンテキスト統合"]
        CTX --> LLM["LLM回答生成"]
    end

    subgraph Knowledge_Graph["ナレッジグラフ構造"]
        PA["製品A"] --"依存"--> CM["共通モジュール"]
        PC["製品C"] --"依存"--> CM
        CM --"バージョン"--> V1["v2.3"]
        PA --"互換性"--> OS["OS要件"]
    end

MicrosoftがOSSで公開してるGraphRAGライブラリが2025年後半から安定してきた。ただ、グラフ構築のコストがそれなりに高い。ドキュメント量が多いと初期インデックス構築で数時間かかることもある。正直まだ「万能ではない」と感じてて、複雑な関係性クエリが多いドメインに限定して使うのが現実解かなと思ってる。なんでもGraphRAGにすれば解決、ではないので注意してほしい。

Adaptive RAGとクエリルーティング:質問によって戦略を変える

2026年で個人的に一番気に入ってるのがAdaptive RAGだ。「全部のクエリに同じRAGパイプラインを通す」のをやめて、質問の種類によって処理を変える。

from enum import Enum
from anthropic import Anthropic
from pydantic import BaseModel

class QueryType(str, Enum):
    SIMPLE_FACTUAL = "simple_factual"      # 単純な事実確認 → シンプルなベクトル検索
    MULTI_HOP = "multi_hop"                # 複数ステップ推論 → GraphRAG
    COMPARATIVE = "comparative"            # 比較質問 → 複数ドキュメント統合
    CONVERSATIONAL = "conversational"      # 会話継続 → 会話履歴 + 検索
    GENERATIVE = "generative"              # 生成・要約 → 検索なしでLLM直接

class QueryClassification(BaseModel):
    query_type: QueryType
    confidence: float
    reasoning: str

class AdaptiveRAGRouter:
    """
    クエリの種類に応じてRAGパイプラインをルーティング
    LLM分類 + ルールベースのハイブリッドで精度向上
    """
    
    def __init__(self):
        self.client = Anthropic()
        
        # シンプルなルールベース事前フィルタ(コスト削減のため)
        self.generative_patterns = [
            r"要約して", r"まとめて", r"説明して",
            r"summarize", r"explain in detail"
        ]
        self.multi_hop_patterns = [
            r".*の関係", r"なぜ.*影響", r"どのように.*つながる"
        ]
    
    async def classify(self, query: str, conversation_history: list = None) -> QueryClassification:
        """
        クエリ分類。まずルールベース、次にLLM分類
        """
        import re
        
        # ルールベースチェック(LLM呼び出しを節約)
        for pattern in self.generative_patterns:
            if re.search(pattern, query):
                return QueryClassification(
                    query_type=QueryType.GENERATIVE,
                    confidence=0.8,
                    reasoning="キーワードマッチ: 生成系クエリ"
                )
        
        # 会話継続の検出
        if conversation_history and len(conversation_history) > 2:
            if any(word in query for word in ["それ", "その", "これ", "前述"]):
                return QueryClassification(
                    query_type=QueryType.CONVERSATIONAL,
                    confidence=0.85,
                    reasoning="代名詞検出: 会話継続クエリ"
                )
        
        # 複雑な分類はLLMに任せる
        response = self.client.messages.create(
            model="claude-3-5-haiku-20241022",  # 分類は軽量モデルで十分
            max_tokens=200,
            messages=[{
                "role": "user",
                "content": f"""以下のクエリを分類してください。

クエリ: {query}

タイプ:
- simple_factual: 単純な事実確認
- multi_hop: 複数エンティティ間の関係・比較
- comparative: 2つ以上のものの比較
- conversational: 前の会話を参照
- generative: 要約・生成・説明

JSON形式で: {{"query_type": "...", "confidence": 0.0-1.0, "reasoning": "..."}}"""
            }]
        )
        
        import json
        result = json.loads(response.content[0].text)
        return QueryClassification(**result)
    
    async def route(self, query: str, conversation_history: list = None) -> str:
        """
        分類結果に応じてパイプラインを選択
        """
        classification = await self.classify(query, conversation_history)
        
        routing_map = {
            QueryType.SIMPLE_FACTUAL: "standard_rag",
            QueryType.MULTI_HOP: "graph_rag",
            QueryType.COMPARATIVE: "multi_doc_rag",
            QueryType.CONVERSATIONAL: "conversational_rag",
            QueryType.GENERATIVE: "direct_llm",
        }
        
        pipeline = routing_map[classification.query_type]
        print(f"[Router] {query[:50]}... → {pipeline} (confidence: {classification.confidence:.2f})")
        return pipeline

direct_llm(検索をスキップしてLLMに直接投げる)を入れてるのは地味に重要なポイントで、「要約して」系のクエリで毎回ベクトル検索を走らせるのはムダなんですよね。うちのシステムでは全クエリの約20%がdirect_llmルートになってて、これだけでレイテンシとコストが改善した。「全部RAGに通せばいい」という思い込みを捨てるだけで、けっこう変わる。

評価と継続改善:定性的な「なんとなく良い」から脱する

RAGを本番で運用してて一番しんどいのが評価だ。「なんか良くなった気がする」だけじゃ改善を積み上げられない。これで半年くらい無駄にした自覚がある。

うちで今使ってる評価指標はこんな感じ。

指標測定方法目標値現在値
Answer RelevancyRAGASフレームワーク> 0.800.84
FaithfulnessLLM-as-Judge> 0.850.87
Context Recall人手ラベル付きテストセット> 0.750.79
Context PrecisionRAGASフレームワーク> 0.700.73
Latency P95実ユーザーリクエスト< 3.0s2.4s
Latency P99実ユーザーリクエスト< 6.0s5.1s

RAGASは2026年時点でもよく使われてるんだけど、LLM-as-Judgeを組み合わせると精度が上がる。人手評価のコストが高いので、小さなテストセット(うちは200問)を作って定期的に自動評価してる。

xychart-beta
    title "RAG評価指標の改善推移(月次)"
    x-axis ["2025-06", "2025-09", "2025-12", "2026-03", "2026-06"]
    y-axis "スコア" 0.5 --> 1.0
    line [0.61, 0.68, 0.74, 0.81, 0.84]
    line [0.58, 0.65, 0.72, 0.83, 0.87]

改善が急に上がった2026年3月は、Parent-Child ChunkingとGraphRAGを同時に入れた時期だ。あとリランカーを日本語特化モデルに変えたのも効いた。本当はすべて同時に入れたくなるんだけど、1つ変えたら何が効いたか分からなくなるので、できるだけ1変数ずつ変更するのを徹底してる。それが面倒でも結果的に近道だと思ってる。

ちなみにベクトルDB選定は別記事のベクトルDB比較2026ベクトルDB本番1年で学んだ、Embedding戦略の地味で重い落とし穴で書いてるのでそっちも参考にしてほしい。ベクトルDBはpgvectorかQdrantかで長いこと悩んだけど、今はスケール要件次第という結論になってる。

あと、LLMエージェントとRAGを組み合わせる設計についてはAIエージェント本番運用6ヶ月で火を噴いた話にも書いたけど、エージェントにRAGを組み込む時は状態管理が複雑になるので別途設計を考えた方がいい。

まとめ

2年半のRAG運用で得た知見を整理するとこうなる。

  1. チャンキングはドキュメント種別で戦略を分けること。固定分割は楽だけど精度の天井が低い。Parent-Child Chunkingは地味に効く
  2. ベクトル検索だけは固有名詞に弱い。BM25とのハイブリッド+RRF融合が2026年の実質的な標準構成
  3. GraphRAGは万能じゃない。複雑な関係性クエリが多いドメインに限定して使うのが現実的
  4. Adaptive RAGで処理を分岐させる。全クエリに同じパイプラインは非効率。クエリ種別に応じたルーティングでコスト・レイテンシが改善
  5. 定量評価なしの改善は沼。小さくてもテストセットを作ってRAGAS等で継続測定する

次のアクション案:

  • まずはRAGASで現状の評価指標を測る(どこが弱いか分かるだけで次手が見える)
  • チャンキング戦略をドキュメント種別で分けることから始める
  • テストセットを最低50問作って改善のベースラインを確立する

皆さんのRAGで「これが一番効いた」という施策があれば教えてほしい。あとGraphRAGの本番事例、もっと知りたい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事