「RAGは簡単だ」という幻想を粉々に粉砕された3ヶ月間の記録。〜LangChain、LlamaIndex、Chromadbを巡る泥沼のデバッグ記〜
「とりあえずLangChainを叩いて、ベクトルDBに突っ込んで、RAG(Retrieval-Augmented Generation)を構築すれば、自社データを使った最強のAIチャットボットができる」
…そんな甘い言葉を信じていた過去の自分を、今すぐタイムマシンで殴りに行きたい。
今回、社内のマニュアルや技術ドキュメントを学習させたRAGシステムを構築するプロジェクトに着手した際、私は「実装」よりも「データの前処理と検索精度のチューニング」という地獄の門を叩くことになった。この記事では、私がLangChainとLlamaIndexを交互に行き来し、ChromaDBと格闘しながら学んだ「RAG構築のリアルな苦しみ」を、忖度なしの技術的知見とともに共有する。
1. 「ただのベクトル検索」が通用しない理由
まず最初に言っておきたいのは、「ベクトル検索(Similarity Search)=正解への近道」ではないということだ。
最初は単純な構成で良いと思っていた。
- 社内PDFをテキスト抽出
- LangChainの`RecursiveCharacterTextSplitter`で分割
- OpenAIの`text-embedding-3-small`でベクトル化
- ChromaDBに格納
- ユーザーの問いに対し、類似度の高いものをTop-K個取り出してLLMに渡す
このフロー自体は教科書通りだ。しかし、実際に動かしてみると絶望的な結果が返ってきた。例えば「社内規定の経費精算について教えて」と聞くと、システムは関連性の低い「福利厚生に関する古いマニュアル」を引っ張ってきて、LLMが自信満々に間違った回答をするのである。
なぜこうなったのか。原因はチャンク(Chunk)の設計不足にある。
単に文字数で区切るだけでは、文章の文脈が断片化され、ベクトル空間上で「意味のまとまり」として正しく認識されないケースが多々あるからだ。
2. チャンク分割とオーバーラップの地獄
RAGにおいて最も重要なのは、LLMに渡すための「情報の最小単位(Chunk)」をいかに美しく切り取るかである。
私は最初、単純な固定長でカットしていた。しかしこれでは、ある一文が途中でぶった斬られ、前後の文脈が失われる。
LangChainの`RecursiveCharacterTextSplitter`は優秀だが、それを使いこなすには「適切なchunk_size」と「十分なchunk_overlap」の設計が必要になる。
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 失敗例:ただの固定長分割(文脈が切れて検索精度が低下する)
# 成功への道:意味のまとまりを意識した設定
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 文字数ではなく、トークンを意識した適切なサイズ
chunk_overlap=100, # 前後の文脈を保持するためのオーバーラップ
length_function=len,
is_separator_for_text_split=True
)
# 実際にドキュメントを処理する際の苦悩
docs = text_splitter.split_documents(raw_documents)
print(f"Generated {len(docs)} chunks.")
ここでいう「オーバーラップ」は、前のチャンクの末尾と次のチャンクの先頭を重ねることだ。これにより、分割された境界線で意味が断片化するのを防ぐ。しかし、これだけでは不十分なケースがある。例えば、表形式のデータや箇条書きが含まれる場合、単純な文字数ベースの分割では構造が崩壊し、ベクトル検索時に「何についての項目なのか」というメタ情報が欠落するのだ。
これを解決するために、私は「Semantic Chunking」の概念を取り入れた。しかし、これには計算コストと実装の複雑さが伴う。最終的には、あえて「タイトルや見出しを付与した上で分割する」という泥臭い手法を採用し、検索時にコンテキストを補強するアプローチに落ち着いた。
3. ChromaDBとベクトル空間の落とし穴
次に直面した壁は、ベクターデータベース(ChromaDB)の実装における「データの正規化」と「インデックスの挙動」である。
ChromaDB自体は非常に使い勝手が良いが、「何を検索クエリとして投げているか」によって結果が劇的に変わる。
例えば、ユーザーが「経費精算はどうやるの?」と入力した際、そのままベクトル化して検索すると、時としてノイズに反応してしまう。
ここで重要になるのが、ハイブリッド検索(Hybrid Search)だ。
純粋なベクトル類似度だけでなく、BM25などのキーワードベースの検索を組み合わせることで、固有名詞や特定の製品番号が含まれる場合に精度が劇的に向上する。LangChainを使う場合でも、この「キーワードとベクトルの融合」をどう実装するかで、システムの安定性が変わる。
また、CheraDBを使用する際に注意すべきは距離計算のアルゴリズム(Distance Metric)である。
多くの場合デフォルトのコサイン類似度で問題ないが、埋め込みモデル(Embedding Model)の種類によっては、L2(ユークリッド距離)の方が適している場合もある。これを無視して「とりあえず動くもの」を作ると、後から精度を追い込む際にデータの再インデックスが必要になり、工数が跳ね上がる。
4. LangChain vs LlamaIndex:結局どっちがいいの?
開発を進める中で、私はLangChainとLlamaIndexの両方を試した。どちらも素晴らしいフレームワークだが、設計思想が異なるため、「使いどころ」を間違えると苦労する。
以下の表に、私が実務で感じた両者の特性の違いをまとめた。
| 特徴 | LangChain | LlamaIndex |
|---|---|---|
| 主な用途 | 汎用的なLLMアプリケーションのオーケストレーション | データ構造のインデックスと高度な検索に特化 |
| 得意なこと | Chainの構築、多様なツールとの統合、複雑なAgentの制御 | RAGに特化したデータコネクタ、高度なリトリーバル手法 |
| 学習コスト | 高い(抽象度が高く、内部構造を理解するのに時間がかかる) | 中程度(RAGに焦点を絞っているため、習得が早い) |
| 柔軟性 | 非常に高い(ほぼ全てのLLM系機能を組み込める) | 特定のドメイン(特にデータ検索)において強力な最適化がある |
結論として、「複雑なエージェントを動かしたいならLangChain」、「とにかく精度の高いRAGシステムを最短で作りたいならLlamaIndex」という使い分けが推奨される。私は結局のところ、データのインデックス構築部分にLlamaIndex的な思想を取り入れつつ、フロントエンドに近いロジックをLangChainで制御するハイブリッドな構成を採用した。
5. 再検索(Reranking)による「最後の一押し」
そして、最も「効いた」のがReranking(再順位付け)の導入である。
ベクトル検索で上位10件を取り出した後、それをそのままLLMに渡すのではなく、Cohere RerankやBGE-Rerankerなどの専用モデルを使って再度ランク付けを行う。これにより、ノイズを削ぎ落とし、本当にLLMが回答に必要な情報だけを抽出する精度が格段に向上した。
この工程を入れるだけで、プロンプトの「迷い」が消え、ハルシネーション(嘘をつくこと)が劇的に減る。しかし、これはAPIコストとレイテンシを犠牲にするトレードオフである。この「コスト vs 精度の天秤」をどう取るかが、実用的なRAGシステム構築における最も重要な意思決定の一つとなる。
まとめ:RAGは「検索の技術」ではなく「データの整理術」
この3ヶ月で私が学んだ最大の教訓は、RAGの精度の8割は、LLMのプロンプトやモデルの性能ではなく、データの前処理とリトリーバルの設計で決まるということだ。
- チャンクをただ切るのではなく、意味の塊として管理する
- ベクトル検索だけに頼らず、ハイブリッド検索を検討する
- Rerankingを組み込んで、情報の「純度」を高める
もしあなたが今、「LangChainを入れたのに全然いい答えが返ってこない」と嘆いているのなら、それはLLMのせいではない。データの切り方、検索する仕組み、そして情報の取捨選択という「エンジニアリングの基礎」を見直すタイミングかもしれない。