はじめに
OllamaとChromaDBを使い、PDFを検索対象としたローカルRAGを構築しました。
当初精度が悪かったのですが、PDFの表データの扱い方を見直すことで改善できました。
嬉しかったのでQiitaに書き残しておきます。
作ったもの
就業規則PDFを対象としたローカルRAGです。
イメージとしては以下のような質問に回答することを目指しました。
質問:入社二年目の社員の有給付与日数を教えて
回答:N日
また、回答時には参照元PDFのページ番号も表示するようにしました。
技術スタック
| 分類 | 技術・ライブラリ | 用途 |
|---|---|---|
| 開発言語 | Python 3.12 | アプリケーション開発 |
| LLM実行環境 | Ollama | ローカル環境でEmbeddingモデルとLLMを実行 |
| Embeddingモデル | mxbai-embed-large |
文書と質問をベクトル化 |
| LLM | llama3.1:8b |
検索結果をもとに回答を生成 |
| ベクトルデータベース | ChromaDB | 文書ベクトルとメタデータを永続保存 |
| PDF処理 | PyMuPDF (fitz) |
PDF本文の抽出、表の検出・抽出 |
| PDFローダー | pypdf | PDFテキストの読み込み |
| Word文書処理 | python-docx |
.docxファイルの読み込み |
| テキスト分割 | LangChain Text Splitters | 文書を検索用チャンクに分割 |
| 分割方式 | RecursiveCharacterTextSplitter |
見出し、段落、改行、句読点などを考慮して分割 |
| RAG検索 | ベクトル類似度検索 | 質問に関連する文書チャンクを検索 |
| 回答生成 | Ollama + llama3.1:8b
|
検索コンテキストをもとに日本語で回答 |
使用モデル
| モデル種別 | モデル名 | 用途 |
|---|---|---|
| Embeddingモデル | mxbai-embed-large |
文書および質問をベクトル化 |
| LLM | llama3.1:8b |
検索結果をもとに回答を生成 |
LangChainはRAGフレームワーク全体ではなく、RecursiveCharacterTextSplitterによるテキスト分割に利用しています。
PDFの表抽出、ベクトル検索、LLM呼び出しには、それぞれPyMuPDF、ChromaDB、Ollamaを直接利用しています。
システム構成
本システムでは文書をチャンクに分割してベクトル化し、ChromaDBに保存します。
質問時には質問文もベクトル化し、類似する文書チャンクを検索します。
検索結果をLLMに渡して回答を生成するRAG構成です。
処理の流れ
- PDFなどの文書からテキストや表を抽出
-
RecursiveCharacterTextSplitterで文書をチャンクに分割 - Ollamaの
mxbai-embed-largeで各チャンクをベクトル化 - ベクトル、本文、メタデータをChromaDBに保存
- ユーザーの質問をEmbeddingモデルでベクトル化
- ChromaDBから質問に類似するチャンクを検索
- 質問と検索結果を
llama3.1:8bに渡す - LLMが検索結果をもとに回答を生成
補足
- PDFの本文・表の抽出には主にPyMuPDFを使用
- テキスト分割にはLangChain Text Splittersを使用
- ベクトル検索にはChromaDBを使用
- OllamaはEmbeddingとLLMの両方で使用
-
pypdfもローダーとして実装しているが、現在のPDFインデックス処理では主にPyMuPDFを使用
ディレクトリ構成
rag-ollama/
│
├─ app.py # ingest / askの実行
├─ indexer.py # 文書分割・Embedding・ChromaDBへの登録
├─ retriever.py # ベクトル検索・回答生成
├─ loaders.py # PDF / DOCX / TXT / Markdownの読み込み
├─ utils.py # パス・ChromaDB・状態管理
│
├─ docs/
│ └─ 就業規則.pdf
│
├─ chroma_db/ # ChromaDBの永続データ
│
├─ .ingest_state.json # インデックス済みファイルのハッシュ情報
└─ venv/ # Python仮想環境
各ファイルの役割
各ファイルの役割は以下の通りです。
| ファイル | 役割 |
|---|---|
app.py |
アプリケーションのエントリポイント。文書のインデックス作成や質問処理を実行 |
indexer.py |
PDFなどの文書からテキスト・表を抽出し、チャンク分割、Embedding生成、ChromaDBへの登録を担当 |
retriever.py |
質問文のEmbedding生成、ChromaDBによるベクトル検索、検索結果を使ったLLM回答生成を担当 |
loaders.py |
PDF、DOCX、Markdown、TXTファイルの読み込み処理を担当 |
utils.py |
ChromaDBの接続、ファイルハッシュ計算、インデックス状態の読み書きなど共通処理を担当 |
検証対象
改善効果を確認するため、以下の観点で質問を作成しました。
| 分類 | 質問 | 期待値 |
|---|---|---|
| 単純な質問 | 就業規則におけるリフレッシュ休暇とは何ですか? | リフレッシュ休暇について回答 |
| 条件分岐 | 父母が亡くなった場合の忌引の日数は、本人喪主の時とその他の場合でそれぞれ何日ですか? | 本人喪主の時とその他の場合それぞれの日数を回答 |
| 表の読み取り | 入社二年目の社員の有給付与日数を教えて | 年次毎の有給付与日数の表を読み取り回答 |
| 表を含む複数箇所の参照 | 勤続11年目の社員について、年次有給休暇とリフレッシュ休暇の付与日数を合計すると何日ですか? | 年次毎の有給付与日数の表を読み取り、リフレッシュ休暇の付与日数と加算して回答 |
| 答えがない | 就業規則における、今年の新入社員の採用人数は分かりますか? | 回答できない旨の回答 |
初期実装
まずはPDFの内容を検索できるようにするため、以下のシンプルな構成から実装を開始しました。
- PDFからテキストを抽出
- テキストをチャンクに分割
- Embeddingを生成
- ChromaDBに登録
この構成ではPDFの各チャンクをEmbeddingモデルでベクトル化し、元のテキストやメタデータとともにChromaDBへ保存します。
質問時には質問文もEmbeddingに変換し、ChromaDBから関連するチャンクを検索します。
検索結果をLLMに渡すことで、文書の内容に基づいた回答を生成します。
発生した問題
例えば、入社二年目の社員の有給付与日数を教えてという質問に対して、下記のような回答が返ってきました。
> 入社二年目の社員の有給付与日数を教えて
------------------------------------------------------------
この質問には、入社二年目の正社員の有給付与日数に関する情報が必要です。
以下は回答の箇条書きと出力フォーマットに従った回答です。
- 正社員の有給付与日数については規則に明確な記載がないため、不明です。
- ただし、正社員は労基法その他の法令の範囲内で、その時々の会社が決定することとされています (第1条)。
参照: source: 就業規則.pdf, page:6, chunk:0, source: 就業規則.pdf, page:17, chunk:2, source: 就業規則.pdf, page:16, chunk:0, source: 就業規則.pdf, page:6, chunk:2, source: 就業規則.pdf, page:9, chunk:1
------------------------------------------------------------
元の就業規則.pdfには、次のような表形式で有給付与日数の記載があることを確認しています。
| 勤続年数 | 1年目 | 2年目 | 3年目 | 後略 |
|---|---|---|---|---|
| 休暇日数 | N日 | N+1日 | N+2日 | 後略 |
しかし、RAGはこの情報を正しく利用できていませんでした。
評価結果まとめ
評価質問で検証した結果、以下の傾向が見られました。
| 項目 | 結果 |
|---|---|
| 単純な質問 | 期待値通り |
| 条件分岐 | 期待値通り |
| 表の読み取り | 苦手 |
| 表を含む複数箇所の参照 | 苦手 |
| 答えがない | 期待値通り |
この結果から今回のケースではLLMの能力そのものよりも、PDF内の表データの取り扱いに問題がありそうと考えました。
改善方針
対応として、本文チャンクと表チャンクを分離して管理することにしました。
改善前
改善前は、PDFから抽出したテキストを本文と同じように扱っていました。
そのため、表データが次のような連続したテキストとして取り込まれる場合がありました。
勤続年数 付与日数 1年目 N日 2年目 N+1日 3年目 N+2日 ...
この状態では、以下のような問題が発生します。
- 行と列の関係が分かりにくくなる
- ヘッダと値の対応関係が崩れる
- ベクトル検索時に表の意味を正しく捉えにくい
- LLMが表の数値や項目を誤って解釈する可能性がある
改善後
PDF内の表を本文とは別に検出し、独立したチャンクとして扱うように変更しました。
表はMarkdown形式に変換して保存します。
| 勤続年数 | 付与日数 |
|---|---|
| 1年目 | N日 |
| 2年目 | N+1日 |
| 3年目 | N+2日 |
これにより、表の行・列構造やヘッダと値の関係を維持したまま検索できるようになりました。
また、表の直前にある見出しや説明文も文脈情報として付加しています。
実装のポイント
PDFの表を独立チャンク化
table_bboxes = [t.bbox for t in page_tables]
body_text = _extract_body_text(page, table_bboxes)
(略)
# 本文から表領域を除外し、表を別チャンクとして登録
all_chunks.append({
"id": f"{source_name}::p{page_no:04d}::table::t{table_idx:04d}",
"text": text,
"metadata": {
"source": source_name,
"page": page_no,
"chunk_type": "table",
"table_index": table_idx
}
})
表のMarkdown変換
lines.append("| " + " | ".join(header) + " |")
lines.append("| " + " | ".join(["---"] * n_cols) + " |")
(略)
# 2列の表については、キーと値の形式も追加
lines.append("[キーと値のペア]")
for row in rows[1:]:
lines.append(f"{cells[0]}: {cells[1]}")
改善結果
上記の修正を実施することで、回答の改善が確認できました。
例:
> 入社二年目の社員の有給付与日数を教えて
------------------------------------------------------------
- N+1日
参照: source: 就業規則.pdf, page:12, table:1, source: 就業規則.pdf, page:13, chunk:1, source: 就業規則.pdf, page:16, chunk:0, source: 就業規則.pdf, page:17, chunk:2, source: 就業規則.pdf, page:12, chunk:1, source: 就業規則.pdf, page:15, chunk:0, source: 就業規則.pdf, page:16, chunk:1, source: 就業規則.pdf, page:13, table:2
------------------------------------------------------------
評価結果まとめ
今回の改善により、表形式データの読み取りの改善が確認できました。
| 項目 | 改善前結果 | 改善後結果 |
|---|---|---|
| 単純な質問 | 期待値通り | 期待値通り |
| 条件分岐 | 期待値通り | 期待値通り |
| 表の読み取り | 苦手 | 期待値通り |
| 表を含む複数箇所の参照 | 苦手 | 期待値通り |
| 答えがない | 期待値通り | 期待値通り |
おわりに
今回は就業規則.pdfをサンプルにRAGの実装と性能改善の取り組みを行いました。
他のPDFでも同様の結果になるとは限りませんが、今回のケースでは表データの構造化による結果の改善を体感できました。
RAGの精度改善というとモデルの性能に目が向きがちですが、検索対象データの品質やチャンク設計を見直すことも忘れないようにしたいです。