卒業研究でRAGパイプラインを作った
ローカルLLMにRAGとCoT Promptingを組み合わせて精度を上げた
もうすぐ卒業研究の発表があるので、自分の整理も兼ねてブログに書いておく。予稿のタイトルは「RAGとPromptingによるLLMの精度向上」で、要はローカルで動くLLMにRetrieval-Augmented Generation(RAG)とChain-of-Thought(CoT)Promptingを組み合わせて、回答の精度を上げようっていう研究。
卒論の予稿ってフォーマットが決まってるから堅い文章で書いたんだけど、中身自体はそんなに難しくない。やってることはシンプルで、「LLMに質問を投げる前に、関連する文書を探してきてプロンプトに突っ込む」っていうパイプラインを作った。

なぜローカルLLMなのか
まず前提として、なぜクラウドのAPIじゃなくてローカルなのかっていう話がある。
企業内部で使うことを想定すると、社内文書とか顧客情報をOpenAIのAPIに流すのはセキュリティ的にまずい場合がある。クラウドに送れないデータがある。だからローカルで動くLLMが必要になる。
ただ、ローカルLLMは当然ながらGPT-4とかClaude比べるとモデルサイズが小さい。知識量も限られる。そこをRAGで補おうっていうのが基本的なアイデア。
従来のフロー vs 提案フロー
従来のやり方はこう。
ユーザーの質問をそのままLLMに投げて、LLMが学習済みの知識だけで答える。これだと文脈情報も最新情報も参照できないし、ハルシネーション(もっともらしい嘘)のリスクも高い。
僕が作ったシステムのフローはこう。
3段階のパイプライン。これをそれぞれ説明する。
Stage 1: Dense Retrieval
最初のステージは、ユーザーの質問に関連する文書をベクトルデータベースから引っ張ってくるところ。
事前に文書群をチャンクに分割して、各チャンクをembeddingモデルでベクトル化してVector DBに入れておく。質問が来たら、質問文もベクトル化して、コサイン類似度で近い文書を検索する。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer('all-MiniLM-L6-v2')
# 文書のembedding(事前に計算してDBに保存)
doc_embeddings = model.encode(documents)
# 質問のembedding
query_embedding = model.encode(query)
# コサイン類似度で検索
similarities = np.dot(doc_embeddings, query_embedding) / (
np.linalg.norm(doc_embeddings, axis=1) * np.linalg.norm(query_embedding)
)
top_k_indices = np.argsort(similarities)[-k:][::-1]これがDense Retrievalの基本。BM25みたいなスパースな手法と違って、意味的な類似性で検索できるのが強み。「犬」と「ワンコ」が近いベクトルになる。
ただ、Dense Retrievalだけだと精度に限界がある。embeddingモデルはbi-encoderだから、質問と文書を独立にベクトル化する。質問と文書の細かい相互作用を捉えきれない。
Stage 2: Cross Encoder Rerank
だからStage 2でCross Encoderを使って再ランキングする。
Cross Encoderはbi-encoderと違って、質問と文書を連結した状態でTransformerに通す。質問と文書のトークン同士がattentionで直接やり取りするから、より精密な関連性スコアが出る。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# Stage 1で取得した候補文書を再ランキング
pairs = [[query, doc] for doc in candidate_docs]
scores = reranker.predict(pairs)
# スコア順にソート
reranked_indices = np.argsort(scores)[::-1]
top_docs = [candidate_docs[i] for i in reranked_indices[:top_n]]なぜ最初からCross Encoderを使わないのかっていうと、遅いから。Cross Encoderは質問×文書のペアごとにforwardパスを走らせるから、文書が1万件あったら1万回推論することになる。だから最初にDense Retrievalで候補を絞って(たとえば上位50件)、その50件だけをCross Encoderで精密にランキングする。粗い検索→精密なランキングの2段構え。情報検索ではよくあるパターン。
Stage 3: CoT Prompting
最後のステージ。再ランキングで選ばれた上位文書と、ユーザーの質問を統合してプロンプトを作り、LLMに渡す。
ここでChain-of-Thought(CoT)Promptingを使う。CoTは「段階的に考えてから答えてね」ってLLMに指示するテクニック。Zero-shot CoTなら "Let's think step by step" を付けるだけ。
def build_prompt(query, retrieved_docs, user_file=None):
context = "\n\n".join([
f"[Document {i+1}]\n{doc}"
for i, doc in enumerate(retrieved_docs)
])
prompt = f"""以下の参考情報を踏まえて、質問に回答してください。
回答する際は、段階的に考えてから結論を述べてください。
## 参考情報
{context}
## 質問
{query}
## 回答
まず、段階的に考えます。
"""
if user_file:
prompt += f"\n## 追加資料\n{user_file}\n"
return promptこれだけ?って思うかもしれないけど、これだけ。CoTの本質は「考える過程を出力させる」ことで、中間ステップを生成させることでLLMの推論精度が上がる。Wei et al. (2022) の論文で有名になったやつ。
ポイントは、RAGで取得した文書をcontextとしてプロンプトに入れて、さらにCoTで段階的に推論させること。文書という「外部知識」と、CoTという「推論の補助」を同時にやる。
システムアーキテクチャ
全体のアーキテクチャとしては、AIモデルへのアクセスをFastAPIで実装して、Webインターフェースから使えるようにした。
ユーザーは追加でファイルをアップロードすることもできて、その内容も質問と一緒にプロンプトに入る。
結果
評価は6人のユーザーに使ってもらって、5段階評価のアンケートを取った。評価項目は正確性、自然さ、思考力、網羅性、明瞭性の5つ。
結果として、RAG + CoT Promptingを実装したv3モデルは、何も実装してないv1モデルより全項目で高評価だった。特に思考力と網羅性で大きな差が出た。
思考力が上がったのはCoTの効果。段階的に推論するから、論理の飛躍が減る。網羅性が上がったのはRAGの効果。外部文書から関連情報を引っ張ってくるから、LLM単体の知識だけに依存しない。
正直な感想
卒論として書くとこういう感じになるんだけど、正直に言うと、このパイプライン自体は2024年の時点でそんなに新しくない。LangChainとかLlamaIndexを使えば似たようなことはできる。
僕がやったことの価値があるとしたら、ローカル環境で動くように全部組んだことと、Cross Encoder Rerankを挟んだことで検索精度を上げたこと。あとFastAPIでAPIとして切り出したから、フロントエンドを差し替えやすい設計にしたこと。
RAGの限界も感じた。検索で引っかからない情報は当然ながら使えないし、Vector DBに入れる文書の質がそのまま回答の質に直結する。ゴミを入れたらゴミが出る。あとembeddingモデルの性能にも依存する。日本語のembeddingは英語に比べるとまだ弱い。
それでも、追加学習なしでLLMの出力精度を上げられるっていうのはRAGの強み。ファインチューニングはコストが高いし、データも大量に要る。RAGならVector DBに文書を突っ込むだけでいい。手軽さが全然違う。

ハルシネーションの低減にも効く。根拠となる文書をプロンプトに含めるから、LLMが完全に創作する余地が減る。ゼロにはならないけど。
まあ、卒論のテーマとしてはちょうどいい粒度だったと思う。