PDFファイルを使ったRAGに挑戦(3)LangChain

この記事では、LangChainライブラリを使って、前回変換したMarkdownファイルをデータベース化する方法を紹介します。これにより、前回のようにMarkdownコンテンツを直接LLM(大規模言語モデル)に送る方法に比べ、圧倒的に処理速度を向上させることができました。

LangChainを用いたRAGの基本実装例

以下に、LangChainを使用した実装コードを示します。このコードでは、Markdownファイルをデータベース化し、質問に対する回答を生成する仕組みを構築しています。

from langchain.chains import RetrievalQA
from langchain.chains.question_answering import load_qa_chain
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.text_splitter import CharacterTextSplitter
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain.document_loaders import TextLoader
from langchain.document_loaders import UnstructuredMarkdownLoader
from langchain.document_loaders import PDFPlumberLoader
from langchain.document_loaders import UnstructuredPDFLoader
from langchain.chat_models import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain.prompts import HumanMessagePromptTemplate, SystemMessagePromptTemplate, ChatPromptTemplate

import os


class QASystem:

    def __init__(self, persist_directory="/content/drive/MyDrive/work/chroma_db"):
        # Anthropic APIキーの設定
        os.environ["ANTHROPIC_API_KEY"] = claude_api_key
        os.environ["OPENAI_API_KEY"] = openai_api_key

        self.embeddings = OpenAIEmbeddings(
            model="text-embedding-3-large",
            openai_api_key=openai_api_key,
            chunk_size=1000,
            max_retries=3
        )

        self.llm = ChatAnthropic(
            model="claude-3-5-sonnet-20241022",
            temperature=0.0,
            anthropic_api_key=claude_api_key,
            max_tokens = 1000
        )

        self.persist_directory = persist_directory

        # 既存のDBがあれば読み込み、なければ新規作成
        if os.path.exists(persist_directory):
            self.vectorstore = Chroma(
                persist_directory=persist_directory,
                embedding_function=self.embeddings
            )
        else:
            self.vectorstore = None

    def add_documents(self, file_path):
        loader = UnstructuredMarkdownLoader(file_path)
        documents = loader.load()

        headers_to_split_on = [
            ("#", "header1"),
            ("##", "header2"),
            ("###", "header3"),
        ]
        markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

        filename = os.path.basename(file_path)

        # メタデータを保持しながらドキュメントを分割
        md_splits = markdown_splitter.split_text(documents[0].page_content)
        for split in md_splits:
            split.metadata.update({
                'filename': filename,
                'header1': split.metadata.get('header1', ''),
                'header2': split.metadata.get('header2', ''),
                'header3': split.metadata.get('header3', '')
            })

        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000, 
            chunk_overlap=100,  
            separators=["\n\n", "\n", "。", "、", " ", ""],
            keep_separator=True
        )

        texts = text_splitter.split_documents(md_splits)

        if self.vectorstore is None:
            self.vectorstore = Chroma.from_documents(
                documents=texts,
                embedding=self.embeddings,
                persist_directory=self.persist_directory
            )
        else:
            self.vectorstore.add_documents(texts)


    def ask_question(self, filename, question: str) -> str:


        if self.vectorstore is None:
            return "ドキュメントが登録されていません。"

        # システムメッセージとヒューマンメッセージを作成
        system_template = """あなたは優秀なアナリストです。与えられた企業レポートを読みユーザからの質問に回答します。
        企業レポートはmarkdown形式で記述されています。セクションや表の構造を意識しながら注意深く分析してください。
        
        <<システムプロンプト 手順と制約事項>>
     
        企業レポート:
        {context}"""

        human_template = "{question}"

        system_message_prompt = SystemMessagePromptTemplate.from_template(system_template)
        human_message_prompt = HumanMessagePromptTemplate.from_template(human_template)

        # チャットプロンプトテンプレートの作成
        chat_prompt = ChatPromptTemplate.from_messages([
            system_message_prompt,
            human_message_prompt
        ])


        retriever = self.vectorstore.as_retriever(
            search_type="mmr",  # Maximum Marginal Relevance
            search_kwargs={
                "k": 5,
                "filter": {"filename": filename},
                "fetch_k": 30,  # より多くの候補から選択
                "lambda_mult": 0.7  # 関連性と多様性のバランス
            }
        )

        qa_chain = RetrievalQA.from_chain_type(
            llm=self.llm,
            chain_type="stuff",
            retriever=retriever,
            return_source_documents=True,
            chain_type_kwargs={"prompt": chat_prompt}
        )

        result = qa_chain.invoke({"query": question})
        answer = result['result']
        sources = [doc.page_content[:100] + "..." for doc in result['source_documents']]


        return f"回答: {answer}\n"
        #return f"回答: {answer}\n\参照元:\n" + "\n".join(sources)

コード説明

__init__()

  • QASystemの初期化を行なっています。
  • 使用する埋め込みモデルやLLMモデルを設定します。
  • 既存のdbがある場合は読み込み、なければ新規作成します。

add_documents(file_path)

  • 指定されたMarkdownファイルを分割・埋め込みし、データベースに追加します。
  • Markdownの構造(ヘッダー情報など)を保持するようにファイル分割します。
  • メタ情報として、ファイル名を追加します。

ask_question(filename, question)

  • 指定したファイル名と質問を元に、関連情報を検索し、回答を生成します。
  • MMR(Maximum Marginal Relevance)を使用して、関連性と多様性を両立した検索を実現します。

使用方法

最初に、add_documents()で、全てのMarkdownファイルをデータベース化します。

qa_system = QASystem()

# ドキュメントの追加
qa_system.add_documents("/content/drive/MyDrive/work/doc/1.pdf")

回答を得るには、ファイル名と質問とをask_question()に渡します。

answer = qa_system.ask_question(pdf_name,question)

今回は、ここでのanswerは中間情報として関連するすべての情報を出力するように指示しました。

このあと、質問とanswerとを入力として、LLM(gpt-4o-mini)に最終的な回答を生成させています。

LangChain(PDF)

PDFファイルを使った場合、add_documents()は以下のようになります。


    def add_documents(self, file_path):
        loader = PDFPlumberLoader(file_path)
        documents = loader.load()

        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=100,
            separators=["\n\n", "\n", "。", "、", " ", ""],
            keep_separator=True
        )

        filename = os.path.basename(file_path)

        # PDFの各ページにメタデータを追加
        for doc in documents:
            doc.metadata.update({
                'filename': filename,
                'page_number': doc.metadata.get('page_number', ''),
            })

        texts = text_splitter.split_documents(documents)

        if self.vectorstore is None:
            self.vectorstore = Chroma.from_documents(
                documents=texts,
                embedding=self.embeddings,
                persist_directory=self.persist_directory
            )
        else:
            self.vectorstore.add_documents(texts)

PDFのLoaderにはいくつか種類がありましたが、今回は、表の情報が重要かと思いましたので、PDFPlumberLoaderを使っています。
参考までに、各Loaderの特徴を記載しておきます。

各Loaderの比較表

Loader使用ライブラリ特徴適用場面
PyPDFLoaderPyPDF2シンプルで軽量、精度は中程度簡単なテキスト中心のPDF
PDFMinerLoaderPDFMinerレイアウト情報をある程度保持レイアウトが重要なPDF
PyMuPDFLoaderPyMuPDF (fitz)高速で複雑なPDFにも対応可能高速処理やマルチメディアPDF
UnstructuredPDFLoaderunstructured構造化データの解析が可能複雑なPDFや高度な内容解析が必要な場合
PDFPlumberLoaderPDFPlumber表や画像も正確に処理可能表や複雑なレイアウトが重要な場合
OnlinePDFLoaderPyPDF2などURLから直接処理可能WebからPDFを取得して処理する場合