「LangchainなしでPDFチャットボットを構築する方法」

「LangchainなしでPDFチャットボットを構築する方法」

はじめに

Chatgptのリリース以来、AI領域では進歩のペースが減速する気配はありません。毎日新しいツールや技術が開発されています。ビジネスやAI領域全般にとっては素晴らしいことですが、プログラマとして、すべてを学んで何かを構築する必要があるでしょうか? 答えはノーです。この場合、より現実的なアプローチは、必要なものについて学ぶことです。ものを簡単にすると約束するツールや技術がたくさんありますが、すべての場合にそれらが必要というわけではありません。単純なユースケースに対して大規模なフレームワークを使用すると、コードが肥大化してしまいます。そこで、この記事では、LangchainなしでCLI PDFチャットボットを構築し、なぜ必ずしもAIフレームワークが必要ではないのかを理解していきます。

学習目標

  • LangchainやLlama IndexのようなAIフレームワークが必要ない理由
  • フレームワークが必要な場合
  • ベクトルデータベースとインデックス作成について学ぶ
  • PythonでゼロからCLI Q&Aチャットボットを構築する

この記事は、Data Science Blogathonの一環として公開されました。

Langchainなしで済むのか?

最近の数ヶ月間、LangchainやLLama Indexなどのフレームワークは、開発者によるLLMアプリの便利な開発を可能にする非凡な能力により、注目を集めています。しかし、多くのユースケースでは、これらのフレームワークは過剰となる場合があります。それは、銃撃戦にバズーカを持ってくるようなものです。

これらのフレームワークには、プロジェクトで必要のないものも含まれています。Pythonはすでに肥大化していることで有名です。その上で、ほとんど必要のない依存関係を追加すると、環境が混乱するだけです。そのようなユースケースの一つがドキュメントのクエリです。プロジェクトがAIエージェントやその他の複雑なものを含まない場合、Langchainを捨ててゼロからワークフローを作成することで、不要な肥大化を減らすことができます。また、LangchainやLlama Indexのようなフレームワークは急速に開発が進んでおり、コードのリファクタリングによってビルドが壊れる可能性があります。

Langchainはいつ必要ですか?

複雑なソフトウェアを自動化するエージェントを構築したり、ゼロから構築するのに長時間のエンジニアリングが必要なプロジェクトなど、より高度なニーズがある場合は、事前に作成されたソリューションを使用することは合理的です。改めて発明する必要はありません、より良い車輪が必要な場合を除いては。その他にも、微調整を加えた既製のソリューションを使用することが絶対に合理的な場合は数多くあります。

QAチャットボットの構築

LLMの最も求められているユースケースの一つは、ドキュメントの質問応答です。そして、OpenAIがChatGPTのエンドポイントを公開した後、テキストデータソースを使用して対話型の会話ボットを構築することがより簡単になりました。この記事では、ゼロからLLM Q&A CLIアプリを構築します。では、問題にどのようにアプローチするのでしょうか? 構築する前に、やるべきことを理解しましょう。

典型的なワークフローは次のようになります

  • 提供されたPDFファイルを処理してテキストを抽出する
  • LLMのコンテキストウィンドウに注意する必要もあります。そのため、テキストをチャンクに分割する必要があります。
  • テキストチャンクの埋め込みをクエリするために、埋め込みモデルが必要です。このプロジェクトでは、Huggingface MiniLM-L6-V2モデルを使用しますが、OpenAI、Cohere、またはGoogle Palmなど、任意のモデルを選択できます。
  • 埋め込みを保存および検索するために、Chromaなどのベクトルデータベースを使用します。Qdrant、Weaviate、Milvusなど、さまざまなベクトルデータベースを選択できます。
  • ユーザがクエリを送信すると、同じモデルによって埋め込みに変換され、クエリと意味が類似したチャンクが取得されます。
  • 取得したチャンクは、クエリの末尾に連結され、LLMにAPI経由で送信されます。
  • モデルから取得した回答がユーザに返されます。

これらのすべての要素には、ユーザーが使用するインターフェースが必要です。この記事では、PythonのArgparseを使用して、シンプルなコマンドラインインターフェースを構築します。

以下は、CLIチャットボットのワークフローダイアグラムです:

コーディングの前に、ベクトルデータベースとインデックスについて少し説明しましょう。

ベクトルデータベースとは何ですか?

その名前が示すように、ベクトルデータベースはベクトルまたは埋め込みを格納します。では、なぜベクトルデータベースが必要なのでしょうか?AIアプリケーションを構築するためには、テキスト、画像、音声などの生のデータを機械学習モデルが直接処理できないため、現実世界のデータの埋め込みが必要です。これらのデータを繰り返し使用する大量のデータを扱う場合、それをどこかに保存する必要があります。では、なぜ従来のデータベースを使用できないのでしょうか?検索ニーズには従来のデータベースを使用できますが、ベクトルデータベースには重要な利点があります:レキシカル検索に加えて、ベクトルの類似度検索を実行できます。

私たちの場合、ユーザーがクエリを送信するたびに、ベクトルDBはすべての埋め込みを対象にベクトルの類似度検索を実行し、K個の最も近い隣人を取得します。この検索メカニズムは、HNSWと呼ばれるアルゴリズムを使用するため、超高速です。

HNSWは、階層的ナビゲーション可能な小世界(Hierarchical Navigable Small World)の略です。これは、近似最近傍探索(ANN)のためのグラフベースのアルゴリズムおよびインデックス付け方法です。ANNは、指定されたアイテムに最も類似しているk個のアイテムを見つけるタイプの検索です。

HNSWは、データポイントのグラフを構築することで動作します。グラフのノードはデータポイントを表し、グラフのエッジはデータポイント間の類似度を表します。その後、グラフをトラバースして、指定されたアイテムに最も類似しているk個のアイテムを見つけます。

HNSWアルゴリズムは高速で信頼性があり、スケーラブルです。ほとんどのベクトルデータベースは、デフォルトの検索アルゴリズムとしてHNSWを使用しています。

それでは、コードの詳細に入ってみましょう。

プロジェクト環境の構築

Pythonプロジェクトと同様に、まず仮想環境を作成します。これにより、開発環境がきれいに保たれます。プロジェクトに適したPython環境の選び方については、この記事を参照してください。

プロジェクトのファイル構造はシンプルで、CLIを定義するための1つのPythonファイルと、データの処理、保存、およびクエリを行うためのもう1つのPythonファイルがあります。また、OpenAI APIキーを保存するための.envファイルを作成します。

以下は、requirements.txtファイルです。開始する前にこれをインストールしてください。

#requiremnets.txt
openai
chromadb
PyPDF2
dotenv

次に、必要なクラスと関数をインポートします。

import os
import openai
import PyPDF2
import re
from chromadb import Client, Settings
from chromadb.utils import embedding_functions
from PyPDF2 import PdfReader
from typing import List, Dict
from dotenv import load_dotenv

OpenAI APIキーを.envファイルから読み込みます。

load_dotenv()
key = os.environ.get('OPENAI_API_KEY')
openai.api_key = key

チャットボットCLIのユーティリティ関数

テキストの埋め込みとそのメタデータを保存するために、ChromaDBでコレクションを作成します。

ef = embedding_functions.ONNXMiniLM_L6_V2()
client = Client(settings = Settings(persist_directory="./", is_persistent=True))
collection_ = client.get_or_create_collection(name="test", embedding_function=ef)

埋め込みモデルとして、ONNXランタイムを使用したMiniLM-L6-V2を使用しています。これは、小さくて能力があり、オープンソースです。

次に、提供されたファイルパスが有効なPDFファイルに属しているかどうかを確認する関数を定義します。

def verify_pdf_path(file_path):
    try:
        # バイナリ読み取りモードでPDFファイルを開こうとしてみる
        with open(file_path, "rb") as pdf_file:
            # PyPDF2を使用してPDFリーダーオブジェクトを作成する
            pdf_reader = PyPDF2.PdfReader(pdf_file)
            
            # PDFに少なくとも1ページあるかどうかをチェックする
            if len(pdf_reader.pages) > 0:
                # ページがある場合、PDFは空ではないため、何もしません(パス)
                pass
            else:
                # ページがない場合、PDFが空であることを示す例外を発生させます
                raise ValueError("PDFファイルは空です")
    except PyPDF2.errors.PdfReadError:
        # PDFを読み取ることができない場合の処理(破損しているか、有効なPDFではない場合など)
        raise PyPDF2.errors.PdfReadError("無効なPDFファイルです")
    except FileNotFoundError:
        # 指定されたファイルが存在しない場合の処理
        raise FileNotFoundError("ファイルが見つかりません。ファイルのアドレスを再確認してください")
    except Exception as e:
        # その他の予期しない例外を処理し、エラーメッセージを表示する
        raise Exception(f"エラー:{e}")

PDF Q&Aアプリの主要な部分の1つはテキストチャンクを取得することです。したがって、必要なテキストチャンクを取得するための関数を定義する必要があります。

def get_text_chunks(text: str, word_limit: int) -> List[str]:
    """
    テキストを指定された単語数のチャンクに分割する関数です。
    各チャンクに完全な文が含まれるようにします。
    
    Parameters:
        text (str): チャンクに分割する全文のテキスト。
        word_limit (int): 各チャンクの単語数の制限。
    
    Returns:
        List[str]: 指定された単語数と完全な文を含むテキストチャンクのリスト。
    """
    sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', text)
    chunks = []
    current_chunk = []

    for sentence in sentences:
        words = sentence.split()
        if len(" ".join(current_chunk + words)) <= word_limit:
            current_chunk.extend(words)
        else:
            chunks.append(" ".join(current_chunk))
            current_chunk = words

    if current_chunk:
        chunks.append(" ".join(current_chunk))

    return chunks

チャンクを取得するための基本的なアルゴリズムが定義されています。ユーザーは1つのテキストチャンクに必要な数の単語を作成できるようにすることがアイデアです。そして、すべてのテキストチャンクは完全な文で終わりますが、制限を超えていてもかまいません。これは単純なアルゴリズムです。独自のものを作成することもできます。

辞書の作成

次に、PDFからテキストをロードし、1つのページに属するテキストチャンクを追跡するための辞書を作成する関数が必要です。

def load_pdf(file: str, word: int) -> Dict[int, List[str]]:
    # 指定されたPDFファイルからPdfReaderオブジェクトを作成
    reader = PdfReader(file)
    
    # 抽出されたテキストチャンクを保持するための空の辞書を初期化
    documents = {}
    
    # PDFの各ページを反復処理
    for page_no in range(len(reader.pages)):
        # 現在のページを取得
        page = reader.pages[page_no]
        
        # 現在のページからテキストを抽出
        texts = page.extract_text()
        
        # get_text_chunks関数を使用して抽出されたテキストを'word'の長さのチャンクに分割
        text_chunks = get_text_chunks(texts, word)
        
        # テキストチャンクをページ番号をキーとしてdocuments辞書に格納
        documents[page_no] = text_chunks
    
    # ページ番号をキー、テキストチャンクを値として含む辞書を返す
    return documents

ChromaDBコレクション

次に、データをChromaDBコレクションに保存する必要があります。

def add_text_to_collection(file: str, word: int = 200) -> None:
    # PDFファイルをロードし、テキストチャンクを抽出
    docs = load_pdf(file, word)
    
    # データを保存するための空のリストを初期化
    docs_strings = []  # テキストチャンクを格納するリスト
    ids = []  # ユニークIDを格納するリスト
    metadatas = []  # 各テキストチャンクのメタデータを格納するリスト
    id = 0  # IDを初期化
    
    # ロードされたPDFの各ページとテキストチャンクを反復処理
    for page_no in docs.keys():
        for doc in docs[page_no]:
            # テキストチャンクをdocs_stringsリストに追加
            docs_strings.append(doc)
            
            # テキストチャンクのメタデータ(ページ番号など)を追加
            metadatas.append({'page_no': page_no})
            
            # テキストチャンク用のユニークIDを追加
            ids.append(id)
            
            # IDを増やす
            id += 1

    # 収集したデータをコレクションに追加
    collection_.add(
        ids=[str(id) for id in ids],  # IDを文字列に変換
        documents=docs_strings,  # テキストチャンク
        metadatas=metadatas,  # メタデータ
    )
    
    # 成功メッセージを返す
    return "PDFの埋め込みがコレクションに正常に追加されました"

ChromaDBでは、メタデータフィールドにドキュメントに関する追加情報を保存します。この場合、テキストチャンクのページ番号がそのメタデータです。各テキストチャンクからメタデータを抽出した後、前に作成したコレクションにそれらを保存することができます。これは、ユーザーが有効なPDFファイルのパスを指定した場合にのみ必要です。

次に、ユーザーのクエリを処理してデータをデータベースから取得する関数を定義します。

def query_collection(texts: str, n: int) -> List[str]:
    result = collection_.query(
                  query_texts = texts,
                  n_results = n,
                 )
    documents = result["documents"][0]
    metadatas = result["metadatas"][0]
    resulting_strings = []
    for page_no, text_chunk in zip(metadatas, documents):
        resulting_strings.append(f"ページ {page_no['page_no']}: {text_chunk}")
    return resulting_strings

上記の関数は、データベースから「n」個の関連データを取得するためにクエリメソッドを使用しています。その後、テキストチャンクのページ番号で始まるフォーマットされた文字列を作成します。

さて、残っている主要なことは、LLMに情報を与えることです。

def get_response(queried_texts: List[str],) -> List[Dict]:
    global messages
    messages = [
                {"role": "system", "content": "あなたは助けになるアシスタントです。\
                 'ques:'で質問された質問に答え、 \
                 また、質問に答える際にはページ番号を引用します。\
                 プロンプトの最初に 'page n'の形式で表示されます。"},
                {"role": "user", "content": ''.join(queried_texts)}
          ]

    response = openai.ChatCompletion.create(
                            model = "gpt-3.5-turbo",
                            messages = messages,
                            temperature=0.2,               
                     )
    response_msg = response.choices[0].message.content
    messages = messages + [{"role":'assistant', 'content': response_msg}]
    return response_msg

グローバル変数messagesは、会話の文脈を保存します。システムメッセージを定義して、LLMが回答を取得するページ番号を表示するようにしています。

最後に、最終的なユーティリティ関数は、取得したテキストチャンクをユーザーのクエリと組み合わせて、get_response()関数にフィードし、結果の回答文字列を返します。

def get_answer(query: str, n: int):
    queried_texts = query_collection(texts = query, n = n)
    queried_string = [''.join(text) for text in queried_texts]
    queried_string = queried_string[0] + f"ques: {query}"
    answer = get_response(queried_texts = queried_string,)
    return answer

ユーティリティ関数は完了しました。次はCLIの構築に移りましょう。

チャットボットCLI

オンデマンドでチャットボットを使用するためには、インターフェースが必要です。これはWebアプリ、モバイルアプリ、またはCLIのいずれかです。この記事では、チャットボットのCLIを構築します。見栄えの良いデモWebアプリを構築したい場合は、GradioやStreamlitなどのツールを使用することもできます。PDF向けのチャットボットの構築に関するこの記事もご覧ください。

Langchainを使用したPDF向けChatGPTの構築

CLIを構築するために、Argparseライブラリが必要です。Argparseは、PythonでCLIを作成するための強力なライブラリであり、コマンド、サブコマンド、およびフラグを作成するためのシンプルで簡単な構文を提供します。それでは、それについての小さなプライマーを紹介します。

Python Argparse

ArgparseモジュールはPython 3.2で初めてリリースされ、サードパーティのインストールに頼らずにPythonでCLIアプリケーションを構築するための迅速で便利な方法を提供します。コマンドライン引数を解析し、CLIでサブコマンドを作成し、その他の多くの機能を提供することができます。これにより、CLIの構築に頼れるツールとなります。

以下は、Argparseの動作例です。

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-f", "--filename", help="読み取るファイルの名前")
parser.add_argument("-n", "--number", help="表示する行数", type=int)
parser.add_argument("-s", "--sort", help="ファイル内の行をソートする", action="store_true")

args = parser.parse_args()

with open(args.filename) as f:
    lines = f.readlines()

if args.sort:
    lines.sort()

for line in lines:
    print(line)

add_argumentメソッドを使用すると、チェックとバランスを備えたサブコマンドを定義できます。引数のタイプや、フラグが指定された場合に実行するアクション、特定のサブコマンドの使用例を説明するヘルプパラメータを定義できます。ヘルプサブコマンドは、すべてのフラグとそれらの使用例を表示します。

同様に、チャットボットCLIに対してもサブコマンドを定義します。

CLIの構築

Argparseと必要なユーティリティ関数をインポートします。

import argparse
from utils import (
    add_text_to_collection, 
    get_answer, 
    verify_pdf_path, 
    clear_coll
  )

引数パーサーを定義し、引数を追加します。

def main():
    # 説明を持つコマンドライン引数パーサーを作成します
    parser = argparse.ArgumentParser(description="PDF処理CLIツール")
    
    # コマンドライン引数を定義します
    parser.add_argument("-f", "--file", help="入力PDFファイルのパス")
    
    parser.add_argument(
        "-c", "--count",
        default=200, 
        type=int, 
        help="1つのチャンク中の単語数のオプションの整数値"
    )
    
    parser.add_argument(
        "-q", "--question", 
        type=str,
        help="質問をする"
    )
    
    parser.add_argument(
        "-cl", "--clear", 
        type=bool, 
        help="現在のコレクションデータをクリアする"
    )
    
    parser.add_argument(
        "-n", "--number", 
        type=int, 
        default=1, 
        help="コレクションから取得する結果の数"
    )

    # コマンドライン引数をパースします
    args = parser.parse_args()

いくつかのサブコマンド(-file、-value、-questionなど)を定義しました。

  • -file:PDFの文字列ファイルパス
  • -value:テキストチャンクの単語数を定義するオプションのパラメーター値(オプション)
  • -question:ユーザーのクエリをパラメーターとして受け取る
  • -number:取得する類似チャンクの数
  • -clear:現在のChromadbコレクションをクリアする

引数を処理します。

 if args.file is not None:
        verify_pdf_path(args.file)
        confirmation = add_text_to_collection(file = args.file, word = args.value)
        print(confirmation)

 if args.question is not None:
        if args.number:
            n = args.number
        answer = get_answer(args.question, n = n)
        print("回答:", answer)

 if args.clear:
        clear_coll()
        return "現在のコレクションが正常にクリアされました"

すべてをまとめます。

import argparse
from app import (
    add_text_to_collection, 
    get_answer, 
    verify_pdf_path, 
    clear_coll
)

def main():
    # 説明を持つコマンドライン引数パーサーを作成します
    parser = argparse.ArgumentParser(description="PDF処理CLIツール")
    
    # コマンドライン引数を定義します
    parser.add_argument("-f", "--file", help="入力PDFファイルのパス")
    
    parser.add_argument(
        "-c", "--count",
        default=200, 
        type=int, 
        help="1つのチャンク中の単語数のオプションの整数値"
    )
    
    parser.add_argument(
        "-q", "--question", 
        type=str,
        help="質問をする"
    )
    
    parser.add_argument(
        "-cl", "--clear", 
        type=bool, 
        help="現在のコレクションデータをクリアする"
    )
    
    parser.add_argument(
        "-n", "--number", 
        type=int, 
        default=1, 
        help="コレクションから取得する結果の数"
    )

    # コマンドライン引数をパースします
    args = parser.parse_args()
    
    # '--file'引数が指定されているかどうかをチェックします
    if args.file is not None:
        # PDFファイルのパスを検証し、そのテキストをコレクションに追加します
        verify_pdf_path(args.file)
        confirmation = add_text_to_collection(file=args.file, word=args.count)
        print(confirmation)

    # '--question'引数が指定されているかどうかをチェックします
    if args.question is not None:
        n = args.number if args.number else 1  # 'n'を指定された数に設定するか、デフォルトで1に設定します
        answer = get_answer(args.question, n=n)
        print("回答:", answer)

    # '--clear'引数が指定されているかどうかをチェックします
    if args.clear:
        clear_coll()
        print("現在のコレクションが正常にクリアされました")

if __name__ == "__main__":
    main()

ターミナルを開き、以下のスクリプトを実行します。

 python cli.py -f "path/to/file.pdf" -v 1000 -n 1  -q "query"

コレクションを削除するには、次のコマンドを入力してください。

python cli.py -cl True

提供されたファイルパスがPDFに属していない場合、FileNotFoundErrorが発生します。

GitHubリポジトリ:https://github.com/sunilkumardash9/pdf-cli-chatbot

実世界の使用例

CLIツールとして実行されるチャットボットは、次のような実世界のアプリケーションで使用することができます。

学術研究:研究者はしばしばPDF形式の多くの研究論文や記事を扱います。CLIチャットボットは、関連情報を抽出し、文献目録を作成し、参考文献を効率的に整理するのに役立つことができます。

言語翻訳:言語専門家は、チャットボットを使用してPDFからテキストを抽出し、翻訳し、コマンドラインから翻訳されたドキュメントを生成することができます。

教育機関:教師や教育関係者は、教育資源からコンテンツを抽出してカスタマイズされた学習教材を作成したり、コースコンテンツを準備したりすることができます。学生は、チャットボットCLIから大きなPDFから有用な情報を抽出することができます。

オープンソースプロジェクトの管理:CLIチャットボットは、オープンソースソフトウェアプロジェクトがドキュメントを管理し、コードスニペットを抽出し、PDFマニュアルからリリースノートを生成するのに役立ちます。

結論

以上が、LangchainやLlama Indexなどのフレームワークを使用せずに構築したコマンドラインインターフェースのPDF Q&Aチャットボットについての内容です。以下にカバーした内容の簡単なまとめを示します。

  • Langchainや他のAIフレームワークは、AI開発を始める良い方法です。ただし、それらは万能解ではないことを覚えておくことが重要です。コードを複雑にし、インフレーションを引き起こす可能性があるため、必要な時にのみ使用してください。
  • フレームワークの使用は、プロジェクトの複雑さがスクラッチからのエンジニアリング時間の増加を必要とする場合に意味があります。
  • Langchainのようなフレームワークなしで、ドキュメントQ&Aのワークフローを最初の原則から設計することができます。

以上です。記事がお気に入りになったことを願っています。

よくある質問

この記事に表示されているメディアはAnalytics Vidhyaによって所有されておらず、著者の裁量で使用されています。

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more