「RAG(検索拡張生成)のプロトタイプを作ってみたけれど、回答がいまいち噛み合わない」「社内ドキュメントを検索させても、ヘッダーやフッターの文字ばかり拾ってきてしまう」
実務の現場では、こうした悩みがよく聞かれます。最新のLLMを使っているのに、なぜか賢くない。その原因の8割は、実はモデルの性能ではなく「読み込ませているデータの質」にあると考えられます。
普段扱っているPDFやPowerPointの資料は、人間が見る分には綺麗ですが、機械にとっては「ノイズの塊」です。文中の改行、ページ番号、装飾用の記号、突然割り込む図表のキャプション……これらが文脈を分断し、ベクターストアの中で「意味のない情報のゴミ」として蓄積されてしまいます。
「Garbage In, Garbage Out(ゴミを入れればゴミが出てくる)」
このデータサイエンスの鉄則は、生成AI時代になっても変わりません。むしろ、LLMが繊細に文脈を読み取るからこそ、ゴミデータの影響はより深刻になります。AIはあくまでビジネス課題を解決するための手段であり、その基盤となるデータ品質の確保はプロジェクト成功の要です。
今回は、そんな「汚いデータ」を、PythonとLLM自身の力を使ってエンジニアリング的に浄化する方法を解説します。PoC(概念実証)で終わらせず、実用的なシステムを構築するための実践的なアプローチとして共有しますので、ぜひエディタを開きながら読み進めてください。
イントロダクション:なぜRAGの精度は「前処理」で8割決まるのか
多くのRAG開発プロジェクトで、エンジニアは「検索アルゴリズム」や「プロンプトエンジニアリング」に時間を使いがちです。しかし、精度のボトルネックになっているのは前処理(Preprocessing)の工程である可能性が高いと考えられます。
PDFのヘッダー・フッターが文脈を分断する問題
例えば、あるマニュアルのページをまたぐ文章を想像してください。「この設定を行うには、管理画面の[設定]メニューから……」でページが終わり、次のページが「(ページ番号)24 / 50 2024年度版マニュアル……(本文続き)[セキュリティ]を選択します」と続いているケースと仮定します。
人間なら無意識にヘッダーやフッターを無視して読み繋げますが、単純なテキスト抽出ツールはこれらをすべて「本文」として取り込みます。その結果、「……メニューから 24 / 50 2024年度版マニュアル [セキュリティ]を選択……」という、意味不明な文章がベクトル化されてしまいます。これでは、どんなに高性能なEmbeddingモデルを使っても、正しい検索結果は得られません。
ルールベース処理の限界とLLM活用の可能性
従来、こうしたクリーニングは正規表現などのルールベースで行われてきました。しかし、非定型なドキュメントや複雑なレイアウトの前では、ルールベースだけでは限界があります。
そこで注目されているのが、LLM自体を前処理プロセッサとして使うアプローチです。LLMに「文章の整形」や「ノイズ除去」を行わせることで、人間が行うような高度なクリーニングを自動化します。コストはかかりますが、RAG全体の精度向上によるROI(投資対効果)を考えれば、十分にペイする戦略です。
本記事では、以下のパイプラインを構築します。
- Raw Data Extraction: PDFからテキストを抽出
- Rule-based Cleaning: 正規表現で明らかなノイズを除去(低コスト)
- Semantic Cleaning: LLMで文脈を整える(高精度)
- Semantic Chunking: 意味のまとまりで分割し、メタデータを付与
- Evaluation: 精度検証
それでは、実際に手を動かしていきましょう。
Step 1: 環境構築と「汚いデータ」の現状分析
まずは現実を直視するところから始めます。Python環境をセットアップし、手元のPDFをそのまま読み込んでみましょう。いかに「そのままでは使えない」かが明確に理解できるはずです。
環境構築のための必要なライブラリ一覧
現在、LangChainのエコシステムは機能のモジュール化が進んでおり、基本機能のlangchainに加え、サードパーティ連携用のlangchain-community、OpenAI専用のlangchain-openaiといった形でパッケージが細分化されています。
また、セキュリティや安定性の観点から、各ライブラリは常に最新版を使用することが重要です。特にOpenAIの環境は大きく変化しており、OpenAI公式サイト(2026年2月時点)によると、GPT-4oなどのレガシーモデルは廃止され、現在は100万トークン級のコンテキストや高度な推論能力を備えた標準モデル「GPT-5.2」や、コーディング特化の「GPT-5.3-Codex」へ移行しています。こうした最新モデルの能力をRAGで最大限に引き出すためにも、以下のコマンドでドキュメント処理に必要な最新環境を整えてください。
# LangChain関連および非構造化データ処理ライブラリのインストール
# langchain-communityはLoader等の機能を利用するために必須です
pip install langchain langchain-community langchain-openai unstructured pdf2image pdfminer.six tiktoken
専門家の視点: LangChainは開発速度が非常に速く、パッケージ構成やインポートパスが変更されることがあります。もし古い記事のコードが動かない場合は、
langchain-communityなどのサブパッケージに必要な機能が移動していないか、公式ドキュメントで確認することをお勧めします。また、OpenAIのAPIを利用する際も、GPT-5.2などの最新モデルに対応したlangchain-openaiのバージョンを維持することがトラブル回避の鍵となります。
未処理のPDFをそのままVectorDBに入れた結果の確認
まずは、何の前処理も行わずにPDFを読み込んでみます。ここでは、一般的なレイアウトを持つ社内規定ドキュメントを読み込む想定で進めます。
import os
# 最新の構成では document_loaders は langchain_community に含まれます
from langchain_community.document_loaders import UnstructuredPDFLoader
# APIキーの設定(実運用では環境変数やシークレット管理機能の利用を推奨)
os.environ["OPENAI_API_KEY"] = "sk-..."
# サンプルPDFのパス(お手持ちのPDFを指定してください)
file_path = "./sample_docs/company_rule_2024.pdf"
# Loaderの初期化
# mode="elements"を指定すると、テキストを要素単位(タイトル、本文など)で解析可能です
loader = UnstructuredPDFLoader(file_path)
docs = loader.load()
# PDFから抽出したテキストの初期確認
print("--- Raw Text Preview ---")
# 最初の500文字を表示して、どのようなノイズが含まれているか確認します
print(docs[0].page_content[:500])
ノイズが検索スコアに与える悪影響の可視化
実行結果を見てみましょう。ヘッダーやフッターが含まれる一般的なビジネス文書では、以下のようなテキストが出力されることが珍しくありません。
--- Raw Text Preview ---
[組織名]
社外秘
202X-04-01 改訂
第1章 総則
第1条(目的)
本規定は、当組織における……
1
[組織名] 就業規則
ご覧の通り、本文の間に「社外秘」「日付」「ページ番号(上記の '1')」「ヘッダータイトル」などが散乱しています。
もしユーザーが「就業規則の目的は?」と検索した際、ベクトル検索エンジンは「[組織名] 就業規則」というヘッダー内の単語に強く反応してしまう可能性があります。たとえGPT-5.2のような高度な推論能力と長文処理能力を持つ最新LLMを使用していたとしても、検索段階で肝心の第1条の内容よりも、ヘッダー情報ばかりが含まれた文脈の薄いチャンク(テキストの塊)を上位に抽出してしまえば、正確な回答を生成することは困難です。
これが、RAGシステムにおいて「検索しても的を射た回答が返ってこない」という現象を引き起こす主要な原因の一つと言えます。いかに優秀なモデルを採用しても、入力されるデータが汚れていては本来のパフォーマンスを発揮できません。
Step 2: ルールベースによる基礎的なクリーニング実装
LLMは強力ですが、すべてのテキストをLLMに投げるとAPIコストが膨大になりますし、処理速度も遅くなります。まずはPythonの標準機能である正規表現(Regex)を使って、明らかに不要なパターンを高速に削ぎ落としましょう。これを「粗ごし」の工程と位置づけます。
正規表現(Regex)を用いたヘッダー・フッター除去
一般的なビジネスドキュメントに含まれるノイズのパターンを定義し、除去関数を作成します。
import re
def basic_cleaner(text: str) -> str:
"""
正規表現を用いた基本的なテキストクリーニング
"""
# 1. ヘッダー・フッターによくあるパターン(ページ番号など)の削除
# 例: "Page 1 of 10", "- 1 -", 単独の数字など
text = re.sub(r'\n\s*\d+\s*/\s*\d+\s*\n', '\n', text)
text = re.sub(r'\n\s*-\s*\d+\s*-\s*\n', '\n', text)
# 2. 連続する改行を1つにまとめる
text = re.sub(r'\n{3,}', '\n\n', text)
# 3. 意味のない記号列の削除(例: __________)
text = re.sub(r'_{3,}', '', text)
# 4. 文中の不自然なスペースの削除(PDF抽出時によく発生)
# 日本語の文字間に挟まった半角スペースを除去する簡易ロジック
# 注意: 英語が含まれる場合はより慎重なロジックが必要
text = re.sub(r'([ぁ-んァ-ン一-龥])\s+([ぁ-んァ-ン一-龥])', r'\1\2', text)
return text.strip()
# クリーニングの適用
cleaned_content = basic_cleaner(docs[0].page_content)
print("--- Cleaned Text Preview ---")
print(cleaned_content[:500])
処理速度と精度のトレードオフ
このbasic_cleaner関数を通すだけで、見た目はかなりスッキリするはずです。特にPDFからテキスト抽出した際に発生しがちな「日本 語 の 単 語 の 間 に 空 白 が 入 る」現象への対策は、検索精度向上に直結します。
ただし、ルールベースは万能ではありません。例えば、文脈として重要な「第1条」という表記と、単なる箇条書きの番号を区別するのは正規表現では困難です。ここで無理に複雑な正規表現を書くと、必要な情報まで消してしまうリスクがあります。
ルールベースはあくまで「明らかなゴミ」を捨てるために使い、文脈判断が必要な処理は次のステップであるLLMに任せるのが有効な戦略です。
Step 3: LLMを活用した「セマンティック・クリーニング」の実装
ここからが本記事のハイライトです。ルールベースでは取りきれなかったノイズや、文章としての体裁が崩れている部分を、LLMを使って整形します。ここでは「セマンティック・クリーニング(意味的浄化)」と呼んでいます。
LLMを「データクリーナー」として使うプロンプト設計
LLMに対して、「内容は変えずに、形式だけを整えろ」という指示を出します。ここでは、コストパフォーマンスと処理速度に優れたGPT-5.2 Instantなどの最新軽量モデルを使用するのがおすすめです。
かつて主流だったGPT-3.5や、2026年2月13日に廃止されたGPT-4o、GPT-4.1 miniなどの旧モデルと比較して、現在の主力であるGPT-5.2系列は、文章の構造化や明確さが大きく改善され、より高いコンテキスト理解能力を持っています。大量のドキュメントを処理する場合、最上位の推論モデル(GPT-5.2 Thinkingなど)を無闇に使うとAPIコストが跳ね上がるため、タスクの難易度に応じた適切なモデル選定が重要です。データ整形のような定型タスクには、軽量かつ高速なInstantモデルが適しています。
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
# コスト重視で最新の軽量モデルを選択
# ※環境に合わせて最新のモデル名(例: "gpt-5.2-instant" など)を指定してください
# 公式ドキュメントで最新のモデルIDを確認することを推奨します
llm = ChatOpenAI(model="gpt-5.2-instant", temperature=0)
# クリーニング用のプロンプト定義
cleaning_prompt = ChatPromptTemplate.from_template(
"""
あなたは熟練した編集者です。以下のテキストはPDFから抽出されたもので、
改行位置がおかしかったり、不要な記号が含まれていたりします。
以下のルールに従ってテキストを整形してください:
1. 文の意味や内容は絶対に変更しないこと。
2. 文中の不自然な改行を削除し、文章をつなげること。
3. 箇条書きや見出しはMarkdown形式で整形すること。
4. 明らかなOCRノイズ(意味不明な文字列)は削除すること。
対象テキスト:
{text}
整形後のテキスト:
"""
)
# チェーンの作成
cleaning_chain = cleaning_prompt | llm | StrOutputParser()
# テスト実行(長いテキストは分割して渡す必要がありますが、ここでは簡易化しています)
# 実際にはToken数制限を考慮してチャンクごとに処理します
sample_chunk = cleaned_content[:1000] # テスト用に先頭1000文字だけ
semantically_cleaned_text = cleaning_chain.invoke({"text": sample_chunk})
print("--- Semantically Cleaned Text ---")
print(semantically_cleaned_text)
バッチ処理によるコスト管理
この処理を全ドキュメントに対して行う場合、逐次処理では多大な時間がかかります。LangChainのバッチ処理機能を活用するか、非同期処理(ainvoke)を用いて並列化すると効率的です。
また、入力トークン数が多すぎるとコンテキストウィンドウの溢れやコスト増を招くため、事前にルールベースである程度分割してからLLMに渡すのが定石です。例えば、RecursiveCharacterTextSplitterで2000文字程度に荒く分割し、それぞれの塊をLLMで整形させてから再結合する、といった手法が有効です。
特に現在の主力モデルであるGPT-5.2系列では、長い文脈理解能力が大きく向上し、巨大なコンテキストにも対応していますが、RAGの前処理としては、検索精度を考慮して適切なサイズ(チャンク)に分割して処理することをお勧めします。なお、OpenAIの公式情報によると、前述の通りGPT-4oやGPT-4.1といった旧モデル群はすでに廃止されています。過去のプロンプトやシステムをそのまま運用している場合は、エラーを防ぐために速やかにGPT-5.2系への移行とコードの書き換えを行う必要があります。
この工程を経ることで、「ページをまたいで分断された文章」が見事に繋がり、Markdownの見出し構造が付与された「構造化データ」に近いテキストが手に入ります。
Step 4: 意味の塊を作る「セマンティック・チャンキング」とメタデータ付与
テキストのクリーニングが完了したら、次はRAGのための「チャンク(検索単位)」への分割処理に入ります。ここでも、単純な文字数による機械的な区切り(例えば500文字ごと)ではなく、文章の意味的なまとまりを意識した分割が検索精度を左右する重要な鍵となります。
Markdownヘッダー構造に基づいた分割戦略
前のステップでテキストをMarkdown形式に整形しておくと、LangChainのMarkdownHeaderTextSplitterを利用して、章や節といった論理的な構造に基づいた分割が容易に実現できます。
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_header_splits = markdown_splitter.split_text(semantically_cleaned_text)
# 分割結果の確認
for split in md_header_splits:
print(f"Content: {split.page_content[:50]}...")
print(f"Metadata: {split.metadata}")
この処理により、各チャンクのメタデータには{'Header 1': '第1章 総則', 'Header 2': '第1条(目的)'}といった階層情報が自動的に付与されます。結果として、検索実行時に「第1章の規定に絞って探したい」といった柔軟なフィルタリングが可能になり、システム全体の精度向上に直結します。
LLMによる要約とキーワード抽出の自動化
さらに検索精度を引き上げるアプローチとして、分割した各チャンクに対してLLMを用いて「要約」や「キーワード」を生成し、メタデータとして埋め込む手法が有効です。このデータ抽出処理には、高度な推論能力と長文の安定処理に優れたGPT-5.2のような最新の標準モデルを活用することをお勧めします。これにより、元の文章には直接含まれていないものの、ユーザーが検索しそうな類義語や概念ベースのクエリでも正確にヒットするようになります。
metadata_prompt = ChatPromptTemplate.from_template(
"""
以下のテキストの内容を簡潔に表す「要約」と、検索されそうな「キーワード」を3つ抽出してください。
JSON形式で出力すること。
テキスト:
{text}
"""
)
# 構造化出力のための定義(LangChainのwith_structured_outputを使用)
# 注: LangChainの最新バージョンと、GPT-5.2などの対応モデルが必要です
structured_llm = llm.with_structured_output(method="json_mode")
# 実装イメージ(簡略化)
# metadata = structured_llm.invoke(metadata_prompt.format(text=split.page_content))
# split.metadata.update(metadata)
このようにして生成された「リッチなメタデータ」を持つチャンク群こそが、ユーザーの曖昧な検索意図をも正確に汲み取る、高精度なRAGシステムを構築するための強力な基盤となります。
Step 5: パイプラインの結合と精度検証
最後に、これまでの工程を一つのパイプラインとして結合し、効果を測定します。「なんとなく良くなった気がする」ではなく、数値で改善を示すことが重要です。プロジェクトマネジメントの観点からも、定量的な評価はROIを証明するために不可欠です。
全工程を繋ぐ処理フローの構築
実際のプロジェクトでは、これらの処理をIngestion Pipelineクラスとしてまとめます。
class RAGIngestionPipeline:
def __init__(self):
self.loader = UnstructuredPDFLoader
self.cleaner = basic_cleaner
self.llm_cleaner = cleaning_chain
self.splitter = markdown_splitter
def process(self, file_path):
# 1. Load
raw_docs = self.loader(file_path).load()
text = raw_docs[0].page_content
# 2. Basic Clean
text = self.cleaner(text)
# 3. Semantic Clean (Batch処理推奨)
# 簡略化のため直接呼び出し
text = self.llm_cleaner.invoke({"text": text})
# 4. Split
chunks = self.splitter.split_text(text)
return chunks
Before/Afterでの検索精度比較(Ragas等での評価)
精度評価には、RAG評価フレームワークであるRagasやLangSmithを活用します。例えば、「就業規則の目的は?」という質問に対し、前処理なしのチャンクと、前処理ありのチャンクのどちらが正解を含んでいるか(Context Recall)を計測します。
Context Recallが向上する可能性があります。特に、表形式のデータや、ページをまたぐ長い文章の検索で改善が見られるかもしれません。
まとめ:データ品質への投資が最強のRAG対策
今回は、PDFという「汚いデータ」を、PythonとLLMの力で「宝の山」に変えるための具体的なエンジニアリング手法を解説しました。
- 現状把握: 生データの汚さを直視する
- ルールベース: 正規表現で低コストにノイズを除去
- LLM活用: 文脈を理解した整形と構造化
- チャンキング: 意味のまとまりとメタデータの付与
この一連のプロセスは、一度構築してしまえば自動化可能です。モデルをGPT-3.5からChatGPTに変えるよりも、まずはデータの足回りを整えることが、実用的なRAGシステムを構築する上で重要です。
とはいえ、実際の業務データは千差万別です。「手書き文字が混ざっている」「特殊な業界用語が多い」「図面データがメインだ」といった固有の課題に直面することもあるでしょう。汎用的なスクリプトだけでは対応しきれないケースも考えられます。
もし、自社のデータ特有の課題でパイプライン構築に行き詰まっていたり、より高度なチューニングが必要だと感じたりした場合は、専門家に相談することをおすすめします。
皆さんのRAGプロジェクトが、ゴミデータに埋もれることなく、真の価値を発揮できることを願っています。
コメント