🔰はじめに
本記事は、Python 初心者である筆者がコンペに参加しながら学んだことを整理し、理解を深めるためにまとめたものです。
技術的な内容については正確性を心がけていますが、一部に初学者なりの解釈や誤りが含まれる可能性があることをあらかじめご了承ください。
🚀参加動機
普段の仕事では、技術報告書や社内規定など、いわゆる「構造化されていない情報(PDFなど)」をうまく活かしきれていないと感じることがよくあります。
そんな中で、PDFから情報を抽出して、自然言語で答えを返すRAG(Retrieval-Augmented Generation) という技術を知り、「これはめちゃくちゃ便利ではないか?」と興味を持ちました。
今回のコンペは、製薬会社のPDF資料という実務にも近いテーマで、RAGの仕組みを一から学ぶにはぴったりの内容だと思い、思い切って参加してみました。
🗒️本記事の要旨
- 工夫ポイント:テキスト構造が複雑なPDFに対して、LLM(GPT-4o-mini)で整形したこと
- ベクトル検索+全文検索について理解したこと
- Reciprocal Rank Fusionというシンプルで強力なランキング融合アルゴリズムについて理解したこと
🏆参加したコンペ
本コンペティションでは、製薬企業の多様なドキュメント(Well-beingレポート、財務諸表、商品紹介資料、研究論文など)を対象とした高度なRAG(Retrieval Augmented Generation)システムの構築に挑戦していただきます。
参加者には、以下の課題に取り組んでいただきます:
- 提供された各種ドキュメントを効率的に処理し、適切なインデックスを構築する
- ユーザーからの質問に対して、関連性の高い情報を正確に抽出する
- 抽出した情報を基に、的確で自然な回答を生成する
まずは、下記の記事を参考にベースとなるアプリケーションの構築から始めましょう!
https://zenn.dev/galirage/articles/raggle_quickstart
💬 たとえば、こんな質問に答える必要があります:
Q. 存在意義(パーパス)はなんですか?
A. 世界の人々に商品やサービスを通じて「健康」をお届けすることによって、
当社を取り巻くすべての人や社会を「Well-being」へと導き、明日の世界を元気にすることです。
Q. 肌ラボ 極潤ヒアルロン液の詰め替え用には、何mLが入っていますか?
A. 170mLが入っています。
このように、企業理念や製品仕様などをPDFから自動で抽出し、自然言語で回答するRAGシステムを構築していきます。
🧱RAGシステムの構成と実装フロー
📦使用技術スタック
- 言語・環境: Python, LangChain
- LLM: GPT-4o-mini
- ベクトル検索: Chroma(OpenAI Embeddings)
- 全文検索: BM25
- PDF読み取り: pymupdf4llm, pdfplumber, pypdf, easyocr
- その他: ChatPromptTemplate, RunnableParallel(LangChain)
🪛実装の流れと学び
1. PDFの取得と前処理
- ファイルごと異なるPDF構造に対応:
- テキスト構造が整ったもの → pymupdf4llm
- OCRが必要な画像PDF → fitz + easyocr
- レイアウト抽出に適したもの → pdfplumber
- 1ファイルごとに最適な処理手法を切り替えられるよう分岐設計を実装
2. 文書の分割とベクトルストア作成
- RecursiveCharacterTextSplitterで適切にチャンク分割
- OpenAIEmbeddingsを用いてChromaへ登録
- メタデータ(source)を保持して、後から回答根拠に使えるように設計
3. ベクトル検索+全文検索のハイブリッド化
- ベクトル検索(Chroma)とBM25検索の両方を使い、検索結果を Reciprocal Rank Fusion(RRF) で統合
- 特徴:
- ベクトル検索の精度
- BM25の補完力
- RRFによるバランスのよいランキング
4. LangChainによるRAGチェーンの構築
- RunnableParallelでRetriever群を同時呼び出し
- RunnablePassthrough → ChatPromptTemplate → ChatOpenAI のチェーン構成
- シンプルかつ再利用性の高いRAGパイプラインを構築できた
🧪 工夫ポイント
通常、PDFからテキストを抽出すると、レイアウト崩れや改行乱れが生じ、そのままでは意味の通る段落として扱えないことが多くあります。今回も一部そのようなPDFがあったため、構造が乱れたPDFの生テキストをLLM(GPT-4o-mini)で段落整形して再構成する手法を行いました。
reader = PdfReader(tmp_path)
texts = [page.extract_text(...) for page in reader.pages if page.extract_text(...)]
# LLMで段落整形を行うChainを構築
chat_model = ChatOpenAI(model=model, temperature=0)
formatting_prompt = ChatPromptTemplate.from_messages([
("system", "PDFから抽出された生のテキストを段落構成に整形してください。"),
("human", "{text}")
])
formatting_chain = formatting_prompt | chat_model | StrOutputParser()
# 各ページの生テキストを LLM に通して整形済みのテキストに変換
for page_text in texts:
result = formatting_chain.invoke({"text": page_text})
documents.append(Document(page_content=result, metadata={"source": url}))
📚 学んだこと
🧠 なぜベクトル検索と全文検索を組み合わせたのか?
PDFのような非構造データから質問に対する正確な回答を得るには、「意味」も「表現」も両方が大切です。
- 🔍 ベクトル検索 は「意味ベース」で強力ですが、完全に見落とす情報もある(例:数字や専門用語など)。
- 🧾 全文検索(BM25) は「文字ベース」で表現の一致に強いが、意味が通らない場合もある(例:単語は一致してるけど文脈がずれている)。
そこで今回は、
両者の“いいとこ取り”をするために、Reciprocal Rank Fusion(RRF)で検索結果を統合という設計を取りました。
①ベクトル検索
ベクトル検索は、「文章の意味」を数値ベクトルに変換し、そのベクトル同士の距離(類似度)で検索対象を探す手法です。
先ほども説明したように全文検索(BM25)が「文字の一致度」に注目するのに対し、ベクトル検索は「意味の近さ」に注目するのが大きな特徴です。
たとえば:
クエリ | 文書 | ベクトル検索の判断 |
---|---|---|
「肌の潤いを保ちたい」 | 「保湿効果がある化粧水です」 | 意味が近い → 高スコア |
「肌の潤いを保ちたい」 | 「効果音を追加するには」 | 意味が遠い → 低スコア |
この「意味」を数値化するために使うのが 埋め込み(embedding) です。
今回の実装では、OpenAI Embeddings を使って文書やクエリをベクトル化し、Chroma に登録することで類似検索できるようにしました。
# OpenAI Embeddingsでベクトル化 → Chromaに登録
embedding_function = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(splitted_docs, embedding_function)
②全文検索(BM25)
✅ BM25がやっていること(ざっくり)
BM25は、検索クエリと文書の 「キーワードマッチ度」 を、出現頻度・希少性・文書長の3軸でスコア化し、関連性の高い文書をランキングする全文検索手法です。
要素 | 意味 |
---|---|
単語の出現頻度(TF) | 文書内で検索語がたくさん出るほどスコアが上がる(ただし上限あり) |
単語の希少性(IDF) | どの文書にも出てくるような一般語(例:は、の、する)はスコアが下がる |
文書の長さの補正 | 長い文書は多くの単語を含みがちなので、スコアを正規化して公平にする |
✅直感的なイメージ(クエリとの一致を見ている)
- 🧪 クエリ:「保湿効果」
- 📄 文書A:「保湿成分が豊富です」
→ 部分一致(1語だけマッチ) - 📄 文書B:「保湿効果のある製品です」
→ 完全一致(2語マッチ+組み合わせも一致)
このとき、BM25は文書Bの方が「関連性が高い」 と判断します。
「どのくらいマッチしたか」「どのくらい珍しい単語か」「文書が長すぎないか」 という観点から、点数(スコア)をつけてくれる仕組みです。
ところが、日本語では英語のようにスペースで単語を区切ることができません。
そのため、「保湿効果」は“保湿”と“効果”という2単語なのか、“保湿効果”という1単語なのかを機械的に判断しづらいという問題があります。そこで登場するのが、「n-gram」という手法です。
これは、文章を連続した文字のかたまりで切り出していく方法で、BM25ではこのn-gramでクエリと文書の“部分一致”を評価できるようになります。
🔍 n-gramとは? キーワード分割のための手法
コード例:1~3文字のn-gramを生成
def generate_character_ngrams(text, i, j):
ngrams = []
for n in range(i, j + 1):
for k in range(len(text) - n + 1):
ngrams.append(text[k:k + n])
return list(set(ngrams))
📌 例:テキスト「保湿効果」→ 1〜3gram
n-gram種 | 内容 |
---|---|
1-gram | 保, 湿, 効, 果 |
2-gram | 保湿, 湿効, 効果 |
3-gram | 保湿効, 湿効果 |
このようにして、「保湿効果」「保湿」「効果」といった部分一致を評価できるようになります。
🔢 BM25スコア計算(簡易例)
では、このn-gramを使用して、BMスコアを計算するとどのようになるか見ていきます。
🧪 クエリ:「保湿効果」
📄 文書A:「この化粧水には保湿成分が豊富に含まれており、しっとり感が続きます。」
📄 文書B:「本製品は保湿効果に優れており、乾燥肌の方にもおすすめです。」
📄 文書C:「この乳液は肌を守るバリア機能をサポートします。」
→ この3文書に対して BM25 がスコアをどのように計算するかを、下記にまとめました:
文書 | マッチしたn-gram | 出現回数(TF) | 文書長補正あり | IDF考慮 | BM25スコア(模擬) |
---|---|---|---|---|---|
A | 保湿 | 1 | ✅ | ✅ | 0.010〜0.020 |
B | 保湿, 効果, 保湿効果 | 3 | ✅ | ✅ | 0.030〜0.045 |
C | なし | 0 | - | - | 0.000 |
🧠この例で伝わること:
- 文書AとBの両方に「保湿」はあるが、Bの方がクエリに近いフレーズ(保湿効果) が登場
- 文書Cのように全くクエリと関係ない文書はスコアが0になる
- n-gramによって部分一致(保湿・効果)+複合一致(保湿効果) が評価される
BM25まとめ
BM25 は「文字がどれだけマッチしたか」「その文字がどれくらい珍しいか」「文書がどれだけ長いか」の3つの観点から文書同士の関連性をスコア化してくれます。また、n-gramを使えば、日本語特有の形態素分かち書きなしでも、効果的な検索が可能になります。
③ Reciprocal Rank Fusion(RRF)
💡 複数の検索器をどう組み合わせる?
ベクトル検索(意味ベース)と全文検索(文字ベース)は、それぞれに強みがあります。
しかし、現実には「どちらを使えば最も関連性が高い文書が取れるのか?」と迷う場面も多くあります。
そこで登場するのが RRF(Reciprocal Rank Fusion) という手法です。
これは、異なる検索器の結果を“順位情報”だけで統合する軽量なアルゴリズムです。
⚙️ RRF(Reciprocal Rank Fusion)とは?
RRFは、検索器ごとに出された文書の順位(rank)に注目し、それぞれの文書に次のようなスコアを与えて合算します:
スコア = 1 / (k + rank)
-
rank
: 各検索器における文書の順位(0が1位) -
k
: スコアの暴走を防ぐ定数(一般的には 60 など)
このスコアをすべての検索器で加算し、最終的にスコアの高い文書が最上位となります。
🔢 コード例(簡略版)
for docs in retriever_outputs: # ChromaとBM25の結果(それぞれ上位k件)
for rank, doc in enumerate(docs):
content = doc.page_content
if content not in content_score_mapping:
content_score_mapping[content] = 0
content_score_mapping[content] += 1 / (rank + k)
🧪 スコア計算例(k = 60)
以下のように、ベクトル検索(A)とBM25検索(B)がそれぞれ上位文書を返したとします:
文書 | ベクトル順位 | BM25順位 |
---|---|---|
doc1 | 0(1位) | 2(3位) |
doc2 | 1(2位) | 0(1位) |
doc3 | 2(3位) | -(ランク外) |
doc4 | -(ランク外) | 1(2位) |
RRFでは、以下のようなスコア計算になります:
文書 | ベクトル順位 | BM25順位 | スコア計算式 | RRFスコア | 内訳 |
---|---|---|---|---|---|
doc1 | 0 | 2 | 1/60 + 1/62 ≒ 0.0167 + 0.0161 | 0.0328 | ベクトル1位+BM25 3位 |
doc2 | 1 | 0 | 1/61 + 1/60 ≒ 0.0164 + 0.0167 | 0.0331 | ベクトル2位+BM25 1位 |
doc3 | 2 | - | 1/62 | 0.0161 | 0.0161(ベクトル3位のみ) |
doc4 | - | 1 | 1/61 | 0.0164 | 0.0164(BM25 2位のみ) |
RRFでは「両方の検索で評価された文書」が自然と上位に来る
RRFまとめ
RRF(Reciprocal Rank Fusion)は、異なる検索器の順位情報を統合して、バランスよく文書の関連度を評価する手法です。スコアの標準化などを必要とせず、軽量かつ効果的な融合が可能です。
注意
リランキングには他にも、言語モデルを使って文書同士の意味を深く評価する手法(例えば、re-ranker BERTやColBERTなど)も存在します。
ただし、そういった手法は計算コストが高くなりやすいため、RRFは「低コストでそれなりに強い融合」を実現できる実用的な選択肢です。
✅ まとめ
今回の取り組みを通じて、「非構造データ × LLM」における情報検索・応答の仕組み(RAG) を実装ベースで学ぶことができました。
特に、PDFごとの構造差への対応や、ベクトル検索+全文検索のハイブリッド構成とランキング融合(RRF) といった実践的な工夫を取り入れられた点が、大きな学びとなりました。
🔍 コンペ結果(暫定)
- スコア: 320(評価方法については割愛させてください。)
- 順位: 69位 / 155チーム中
- 初参加ながら、RAGの基本構成・評価指標・精度改善の要点を掴むことができたのは大きな成果でした。
🔧 改善点と今後の課題
- 検索精度のチューニング(ベクトルとBM25の重みづけ、chunking設計など)はまだ改善余地あり
- 回答の品質向上には、生成部分(プロンプト設計・再質問処理など)の工夫も今後必要
- 画像中心のPDFや表の扱い、メタ情報の活用(文書名など)も課題として残る
💡 補足(GitHub連携・実装コード)
コードはこちらに公開しています:GitHubリンク