PDFについて回答してくれるAIアプリをLangChain+Streamlitで作る

この記事の内容

  • PDFをアップロードし、その内容に対して質問できるAIアプリの構築方法を紹介します
  • LangChain・Streamlit・PyPDF2・Qdrant(ベクターDB)を組み合わせた実装手順を解説します
  • テキストをエンベディング(ベクトル化)してベクターDBに保存する仕組みを説明します
  • LangChainのRetrievalQAを使ってPDFの内容を参照した質問応答を実現する方法を紹介します
  • ローカル環境からクラウド(Qdrant Cloud)への移行手順もカバーします

PDFに質問できるAIアプリとは

大量のPDFドキュメントを読み込んで、その内容に対してチャットで質問できるアプリを作ります。ChatGPTは2021年9月までのデータで学習されているため、独自のPDFに書かれた情報については直接答えることができません。

そこで、PDFのテキストをデータベースに格納し、質問に関連する内容を取り出してプロンプトに埋め込むという手法を使います。プロンプトの中に情報を与えることで、ChatGPTがその内容をもとに回答を生成できるようになります。


全体の処理フロー

アプリの処理は大きく「保存フェーズ」と「質問応答フェーズ」に分かれます。

保存フェーズ(PDFアップロード時)

  1. StreamlitのUIでPDFをアップロード
  2. PyPDF2でPDFのテキストを読み取る
  3. LangChainのRecursiveCharacterTextSplitterでテキストをチャンクに分割
  4. OpenAI Embeddings APIで各チャンクをベクトル化(エンベディング)
  5. QdrantベクターDBにエンベディングを保存

質問応答フェーズ(ユーザーが質問したとき)

  1. 質問文をOpenAI Embeddings APIでベクトル化
  2. ベクターDBから類似度の高いチャンクを検索
  3. 取得した関連テキストをプロンプトに埋め込む
  4. ChatGPTに回答を生成させて表示

必要なライブラリのインストール

以下のライブラリを事前にインストールしておきます。

pip install pypdf2
pip install qdrant-client
pip install langchain
pip install streamlit
pip install openai
pip install tiktoken

ページ切り替えの実装

Streamlitでは、サイドバーのセレクトボックスを使ってページを切り替えることができます。

import streamlit as st

selection = st.sidebar.selectbox("ページを選択", ["PDFアップロード", "質問"])

if selection == "PDFアップロード":
    show_upload_page()
elif selection == "質問":
    show_qa_page()

PDFのアップロードとテキスト分割

StreamlitのファイルアップローダーとPyPDF2を組み合わせて、PDFを読み込みテキストを抽出します。

import streamlit as st
import PyPDF2
import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter

uploaded_file = st.file_uploader("PDFをアップロードしてください", type="pdf")

if uploaded_file is not None:
    pdf_reader = PyPDF2.PdfReader(uploaded_file)
    text = "\n".join([page.extract_text() for page in pdf_reader.pages])

    tokenizer = tiktoken.get_encoding("cl100k_base")
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=250,
        chunk_overlap=0,
        length_function=lambda t: len(tokenizer.encode(t))
    )
    chunks = text_splitter.split_text(text)

チャンクサイズは質問対象のPDFによって適切な値が変わります。今回はサンプルとして250トークンを固定値として設定しています。

ファイルアップローダーにはさまざまなオプションがあり、複数ファイルの許可やデフォルトの最大アップロードサイズ(200MB)の変更なども可能です。


エンベディングとは

エンベディングとは、単語やフレーズを数値ベクトル(連続した数字のリスト)として表現することを指します。「高次元空間に埋め込む」という意味から「エンベディング(埋め込み)」と呼ばれます。

OpenAI Embeddings APIを使うと、テキストを1536次元のベクトルに変換できます。テキストの長さに関わらず、常に1536個の数値で表現されます。このベクトル表現を使うことで、文章同士のコサイン類似度などを計算し、意味的な近さを数値として扱えるようになります。

from langchain.embeddings import OpenAIEmbeddings

embeddings = OpenAIEmbeddings()
vector = embeddings.embed_documents(["こんにちは世界"])
print(f"次元数: {len(vector[0])}")  # 1536

ベクターDBへの保存(Qdrant)

今回はベクターDBとしてQdrantを使用します。Qdrantはローカル開発環境とクラウドマネージドサービスの両方で利用でき、ローカルで動作確認したコードをほぼ変更せずにクラウドに移行できる点が特徴です。

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
from langchain.vectorstores import Qdrant

COLLECTION_NAME = "pdf_collection"
QDRANT_PATH = "./qdrant_local"

def load_qdrant():
    client = QdrantClient(path=QDRANT_PATH)

    collections = [c.name for c in client.get_collections().collections]
    if COLLECTION_NAME not in collections:
        client.create_collection(
            collection_name=COLLECTION_NAME,
            vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
        )

    return Qdrant(
        client=client,
        collection_name=COLLECTION_NAME,
        embeddings=OpenAIEmbeddings()
    )

def build_vector_store(text):
    qdrant = load_qdrant()
    qdrant.add_texts([text])

Qdrantのローカル実装の実態はSQLiteです。コレクションが存在しない場合のみ新規作成し、2回目以降はそのまま使います。ベクトルサイズ(1536)はOpenAI Embeddingsの次元数に合わせて設定します。


質問応答の実装(RetrievalQA)

LangChainのRetrievalQAを使うと、ベクターDBから関連テキストを取得して回答を生成する処理を非常に少ないコードで実装できます。

from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA

def build_qa_model(llm):
    qdrant = load_qdrant()
    retriever = qdrant.as_retriever(
        search_type="similarity",
        search_kwargs={"k": 10}
    )
    return RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=retriever,
        return_source_documents=True,
        verbose=True
    )

def ask(qa, query):
    answer = qa({"query": query})
    return answer

k=10は類似チャンクを10件取得する設定です。return_source_documents=Trueにすることで、ChatGPTが参照した元テキストも確認できます。

リトリーバーには以下のようなパラメータで検索動作を調整できます。

パラメータ説明
k取得する文章のヒット数
score_thresholdスコアのしきい値
filterフィルター条件

ローカルからクラウド(Qdrant Cloud)への移行

Qdrant Cloudのダッシュボードでクラスター(フリーティア:1GB RAM / 0.5vCPU / 20GBディスク)を作成し、APIキーを取得します。

クライアントの接続部分を以下のように変更するだけでクラウドに切り替えられます。

# ローカル
client = QdrantClient(path=QDRANT_PATH)

# クラウド
client = QdrantClient(
    url="https://your-cluster-url.qdrant.io",
    api_key="your-api-key"
)

実際に動かしてみた結果

日本の政府情報システムのクラウドサービス利用に関するPDFをアップロードして質問してみました。

質問: 政府情報システムにおけるクラウドサービスの利用について対応を教えてください

回答例: 政府情報システムにおけるクラウドサービスの利用は、最新技術を合理的かつ効率的に利用するために非常に有効な手段とされています。クラウドバイデフォルト原則を採用しており、クラウドサービスの利用が第一候補として考えられ、特別な事情がある場合はオンプレミスという方針です。

関連ドキュメント(元のPDFチャンク)も10件返ってきており、セマンティック検索が正しく機能していることが確認できました。

コスト面の注意点

1回の質問あたりおよそ0.14ドル(約20円)のAPIコストがかかります。エンベディングの保存時と質問時の両方でOpenAI APIへの料金が発生するため、頻繁に使用する場合はコストに注意が必要です。


運用上の課題

RAGベースのアプリを実際に運用する際には以下のような課題があります。

チャンクサイズの調整が難しい 適切なチャンクサイズはPDFの内容によって変わります。固定値では最適な結果が得られないケースがあります。

関連文脈の取得精度 「カビゴンに似ているポケモンは?」という質問に対してはカビゴン自身の情報ばかりが取得されてしまうなど、質問の意図に沿った類似チャンクを取得することは容易ではありません。

ベクターDBの更新管理 社内規定が変更された際に古い文章を適切に削除しなければ、相反する情報が同時にベクターDBに存在してしまい、回答精度が低下します。


まとめ

LangChain・Streamlit・PyPDF2・Qdrantを組み合わせることで、PDFの内容に質問できるAIアプリを比較的少ないコードで実装できることが確認できました。

処理の流れは「PDFのテキスト抽出 → チャンク分割 → エンベディング → ベクターDB保存 → 類似チャンク検索 → プロンプト埋め込み → ChatGPTで回答生成」という手順です。LangChainのRetrievalQAがこのパイプラインの大部分を抽象化してくれるため、開発者はデータの準備とUI実装に集中できます。

ローカル環境での動作確認後、QdrantのURLとAPIキーを変更するだけでクラウドへの移行も簡単に行えます。単体のPDFドキュメントを対象にした用途であれば、実用的なアプリとして十分機能します。チャンクサイズの最適化やベクターDBの更新管理など、本番運用に向けた課題もありますが、RAGの基本的な仕組みを理解・実装する出発点として非常に有効なアプローチです。