こんにちは、CCCMKホールディングスTECH LABの三浦です。
最近は手書きのメッセージを送ることが少なくなりましたが、1年に2回、今でも手書きのメッセージを書くのが年末年始の年賀状と敬老の日です。先日久しぶりに手紙を書いたのですが、「頭で書きたい」と思った文章を書くのに、手が追い付いてくれないなんだかふわふわした感覚を味わいました。自分にとってキーボードを使って文章を書くことの方が自然なことになってしまったんだな、とちょっとびっくりした気持ちになりました。
さて、今回は大規模言語モデル(LLM)を組み込んだアプリケーション開発用のフレームワークの1つである"Haystack"について調べ、実際に使ってみたのでその内容についてまとめてみたいと思います。
LLMアプリケーション開発フレームワーク
LLMを組み込んだアプリケーションを開発するためのフレームワークは色々あって、有名なものだと"LangChain"や"LlamaIndex"が挙げられます。この2つはLLMアプリケーション開発フレームワークとしては黎明期からあった印象を持っています。他にもLLMに計画に基づき必要なツールを実行させる"Agent"システムを開発するフレームワークでMicrosoftの"AutoGen"やLangChainの"LangGraph"などもあります。そして今回取り上げる"Haystack"はLLMに外部知識を参照させて回答させる"Retrieval-augmented generation(RAG)"を利用したアプリケーション開発フレームワークです。
私はよくLangChainとLangGraphを使うのですが、最近触っていた別のソフトウェアのサンプルプログラムでHaystackを利用していてHaystackに興味を持ちました。そこでLangChainとHaystackがそれぞれどんな用途に向いているのかを調べていたところ、次の記事が見つかり、目を通してみました。
ざっとまとめるとLangChainは豊富な機能があり複雑な処理が必要なアプリケーションの開発に向いている一方、Haystackは優れた検索機能によって特に情報検索処理が必要なアプリケーションの開発に向いているとのことです。記事を読んでいると、これまで開発に関わってきたアプリケーションの中で、もしかしたらこれはLangChainよりもHaystackの方が向いていたのかも?と思うケースがいくつか思い浮かんできました。
Haystack
Haystackのドキュメントにはチュートリアルが用意されており、そちらを目を通すことでHaystackで出来ることを大まかに理解することが出来ます。
Haystackでアプリケーションの処理を組むためのコアとなる概念が"Component"と"Pipeline"です。ComponentはHaystackの処理を組むための基本部品で、Pipelineは複数のComponentを組み合わせて作られた一連の処理プロセスを指します。ComponentはあらかじめHaystackに組み込まれている実装済みのものを使うことで大抵のケースに対応可能ですが、必要に応じて独自のカスタムComponentを作ることも出来ます。
今回Haystackを使って参考ドキュメントを読み込んで埋め込みベクトルを取得しデータベースに格納する処理と、ユーザーからの質問を受け取り、データベースから質問に関連する情報を取得し、その情報を使って回答を生成する処理をPipelineとして組んでみました。
作成した2つのPipelineについて
今回作成したのは以下の2つのPipelineです。
Indexing Pipelineはドキュメントを読み込んでデータベース(ベクトルDB)に書き込む処理を行うPipeline、RAG Pipelineはユーザーの質問を受け取り、ベクトルDBから関連する情報を取得しLLMに与えるプロンプトを生成しLLMに参考情報を用いてユーザーの質問への回答を生成するPipelineです。
元になるドキュメントはこのブログの前回の記事を次のコマンドでHTMLファイルにしたものを使いました。
curl -o /path/to/blog.html https://techblog.cccmkhd.co.jp/entry/2024/09/10/151937
ベクトルDBはベクトルDBとして良く使われる"Chroma"を使用し、EmbeddingやLLMはAzure OpenAI Serviceのモデルを利用しました。
セットアップ・・・の前に
Haystackのドキュメントを見ると、Haystackの機能改善のため一部のデータをTelemetryとしてHaystackのサーバに送信する設定があることが明記されています。
関連するコードを見るとこの設定がデフォルトでは"ON"になっているようです。この設定を無効化する場合はドキュメントに従い、環境変数HAYSTACK_TELEMETRY_ENABLED
にFalse
を設定する必要があります。
セットアップ
次のコマンドで必要なライブラリをインストールしました。
pip install haystack-ai chroma-haystack lxml_html_clean trafilatura chromadb==0.5.3
haystack-ai
がHaystackです。lxml_html_clean
とtrafilatura
はHTMLファイルを読み込む際に使用します。chromadb
は何も指定しないと私の環境では0.5.5
のバージョンがインストールされたのですが、DB書き込み時に次のエラーが出てしまいました。
AttributeError: 'Collection' object has no attribute 'model_fields'
これはHaystackを使用した時だけ出るエラーではないようで、LangChainを使っても同様のエラーが出るという報告があがっています。LangChainのissueを参考にすると、chromadb
を0.5.3
にダウングレードするとエラーが解消した、というコメントがあり、そのように対応すると私の環境でもエラーが解消されました。
Indexing Pipelineの構築
Converter Component
Indexing Pipelineの処理はHTMLファイルを読むConverter Componentから始まります。Haystackでは様々なファイルタイプに応じたConverter Componentが用意されており、そのうちHTMLファイルに対応したHTMLToDocument
を使用しました。
from pathlib import Path from haystack.components.converters import HTMLToDocument converter = HTMLToDocument() html_doc_path = "/path/to/blog.html" docs = converter.run(sources=[Path(html_doc_path)])
docsの中身を見ると次のようなdict
型の値が格納されていました。
{'documents': [Document(id=3dcdab..., content: 'こんにちは、CCCMKホールディングスTECH LABの三浦です。 この前初めて訪れた街を朝早起きしてジョギングしたのですが、普段見られない景色や雰囲気を感じられてとても楽しかったです。いつか色々なと...', meta: {'file_path': '/path/to/blog.html'})]}
まずdocuments
に配列の値が格納されています。配列の要素はDocument
となっており、これはHaystackのテキスト情報を格納するためのクラスです。それぞれid
, content
, meta
という属性を持っており、id
は一意に振られるID, content
はHTMLファイルから抽出されたテキスト情報、meta
はファイルのメタ情報でここではファイルパスが設定されています。
もとのHTMLファイルはテキストエディタで見るとHTMLのタグなどの不要なテキストが含まれていますが、HTMLToDocument
によってそれらがクリーニングされています。
Preprocessor Components
読み込んだテキストから不要な空白を除去するDocumentCleaner
、テキストを単語や文章に基づいて細かく分割するDocumentSplitter
がHaystackでは用意されています。HTMLToDocument
によって不要な空白等は取り除かれているのでDocumentCleaner
は使わず、DocumentSplitter
のみを使ってみようと思いました。
・・・ところが実行してもまったく分割されないため、DocumentSplitter
がテキスト分割処理を実行するrun
メソッドのソースコードを確認したところ、次のようなコメントが書かれており、どうも日本語には対応していないことが分かりました。
:param split_by: The unit for splitting your documents. Choose from `word` for splitting by spaces (" "), `sentence` for splitting by periods ("."), `page` for splitting by form feed ("\\f"), or `passage` for splitting by double line breaks ("\\n\\n"). ...
そこで取り急ぎ日本語の1文の終わりを示す"。"でテキストを分割するComponentをカスタムComponentとして作ってみることにしました。カスタムComponentはPythonのクラスで定義し、クラスに@component
デコレータを付けること、run
メソッドを実装すること、そしてrun
メソッドに@component.output_types
デコレータを付け、出力される結果の型を明示するようにします。
具体的には次のようになります。
from typing import List from haystack import Document,component @component class JapaneseDocumentSplitter(): @component.output_types(documents=List[Document]) def run(self, documents: List[Document]): splitted_docs = [] for doc in documents: doc_meta = doc.meta doc_content = doc.content splitted_doc_contents = doc_content.split("。") for partial_doc in splitted_doc_contents: if not partial_doc: continue splitted_docs.append( Document( content=partial_doc, meta=doc_meta ) ) return {"documents":splitted_docs} ja_splitter = JapaneseDocumentSplitter()
Embedder Component
次はテキストに対する埋め込みベクトルを取得するComponentです。HaystackではAzure OpenAI Serviceに対応したComponentが提供されています。あらかじめ環境変数AZURE_OPENAI_API_KEY
に利用するAzure OpenAI ServiceのAPIキーをセットしておく必要があります。
import os from haystack.components.embedders import AzureOpenAIDocumentEmbedder # api_keyは環境変数"AZURE_OPENAI_API_KEY"にセットされたものが使われる embedder = AzureOpenAIDocumentEmbedder( azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE",""), azure_deployment="text-embedding-ada-002" )
Writer Component
Indexing Pipelineの最後のComponentです。今回はベクトルDBにChromaを利用するため、Haystackに用意されているChroma用のWriter Componentを使用します。ChromaDocumentStore
の初期化時にpersist_path
を指定すると、そのパスにDBファイルが生成されデータの永続化が可能になります。
from haystack_integrations.document_stores.chroma import ChromaDocumentStore from haystack.components.writers import DocumentWriter chroma_db = ChromaDocumentStore( collection_name="blog_content", persist_path="./chroma_db", distance_function="cosine" #default: l2 )
Pipelineの組み立て
以上でIndexing Pipelineに必要なComponentが準備出来たので、これらをPipelineに加え、連続するComponent同士を接続してPipelineを構築します。この辺りはLangGraphのGraph構築時の手続きと似ているな、と思いました。
from haystack import Pipeline indexing_pipeline = Pipeline() # PipelineにComponentを追加する indexing_pipeline.add_component("converter",converter) indexing_pipeline.add_component("ja_splitter",ja_splitter) indexing_pipeline.add_component("embedder",embedder) indexing_pipeline.add_component("document_writer",document_writer) # 連続するComponentを接続する(第一引数→第二引数のComponentに処理が進む) indexing_pipeline.connect("converter","ja_splitter") indexing_pipeline.connect("ja_splitter","embedder") indexing_pipeline.connect("embedder","document_writer")
実行する時はPipelineの起点となるComponentの実行に必要となるパラメータを渡してあげます。今回はHTMLToDocument
のconverter
に読み込みHTMLファイルのパスを与えると実行することが出来ます。
indexing_pipeline.run({"converter":{"sources":[Path(html_doc_path)]}})
次のように実行結果が表示されました。これでベクトルDBへの書き込みが完了です。
Embedding Texts: 100%|██████████| 2/2 [00:00<00:00, 7.42it/s] {'embedder': {'meta': {'model': 'text-embedding-ada-002', 'usage': {'prompt_tokens': 4588, 'total_tokens': 4588}}}, 'document_writer': {'documents_written': 64}}
RAG Pipelineの構築
Text Embedder Component
今後はRAG Pipelineの構築を進めていきます。ベーシックなRAGではユーザーが入力した質問に関連するドキュメントをベクトルDBから取得するので、まず質問を埋め込みベクトルに変換する処理を行います。
from haystack.components.embedders import AzureOpenAITextEmbedder # api_keyは環境変数"AZURE_OPENAI_API_KEY"にセットされたものが使われる text_embedder = AzureOpenAITextEmbedder( azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE",""), azure_deployment="text-embedding-ada-002" )
Retriever Component
次は質問の埋め込みベクトルと近い距離を持つドキュメントをベクトルDBから取得するRetriever Componentです。
from haystack_integrations.components.retrievers.chroma import ChromaEmbeddingRetriever retriever = ChromaEmbeddingRetriever(chroma_db)
Prompt Builder Component
プロンプトのテンプレートをベースにLLMに渡すプロンプトを作成するのがPrompt Builder Componentです。Haystackではプロンプトのテンプレートにjinjaテンプレートを使用します。
from haystack.components.builders import PromptBuilder template = """ 与えられた次の参考情報を元に、質問に回答してください. 参考情報: {% for document in documents %} {{ document.content }} {% endfor %} 質問: {{question}} 回答: """ prompt_builder = PromptBuilder(template=template)
Generator Component
RAG Pipelineの最後はLLMにプロンプトを渡し、回答を生成させるGenerator Componentです。今回はAzure OpenAI Serviceで提供されているgpt-4o
を使用しました。
from haystack.components.generators import AzureOpenAIGenerator generator = AzureOpenAIGenerator( azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE",""), azure_deployment="gpt-4o" ) # テスト用 print(generator.run("こんにちは"))
Pipelineの組み立て
RAG Pipelineを組み立てていきます。
# Construct RAG Pipeline rag_pipeline = Pipeline() rag_pipeline.add_component("text_embedder", text_embedder) rag_pipeline.add_component("retriever", retriever) rag_pipeline.add_component("prompt_builder", prompt_builder) rag_pipeline.add_component("generator",generator) rag_pipeline.connect("text_embedder.embedding", "retriever.query_embedding") rag_pipeline.connect("retriever", "prompt_builder.documents") rag_pipeline.connect("prompt_builder", "generator")
注意点はretriever
の接続先がprompt_builder.documents
になっている点です。prompt_builder
はユーザの質問を受け付けるquestion
と関連情報を受け付けるdocuments
の2つのパラメータが必要で、retriever
の出力はdocuments
に紐づける必要があるため、このような設定になっています。
そのためrag_pipeline
はindexing_pipeline
の時とは異なり、実行する時はtext_embedder
とprompt_builder
両方にユーザの質問を渡す必要があります。合わせてretriever
のオプションパラメータtop_k
を設定して取得する関連ドキュメント数も指定することが可能です。
question = "LLMの可視化に知識グラフを使うメリットは?" rag_result = rag_pipeline.run({ "text_embedder":{"text":question}, "retriever":{"top_k":10}, "prompt_builder":{"question":question} }) print(rag_result)
次のような結果が得られました。
{'text_embedder': {'meta': {'model': 'text-embedding-ada-002', 'usage': {'prompt_tokens': 22, 'total_tokens': 22}}}, 'generator': {'replies': ['LLMの可視化に知識グラフを使うメリットは以下の点にあります:\n\n1. **事実関係の明確な表現**:\n - 知識グラフを使用することで、LLMが持つ事前知識や入力テキストに対する事実関係を構造化して可視化することができます。これにより、特定の知識がどのように使われているかを明確に理解できます。\n\n2. **層ごとの知識利用の分析**:\n ...(省略)'], 'meta': [{'model': 'gpt-4o-2024-05-13', 'index': 0, 'finish_reason': 'stop', 'usage': {'completion_tokens': 549, 'prompt_tokens': 650, 'total_tokens': 1199}}]}}
結果を見ると、前回のブログの内容を元に回答しようとしていることが伺えます。今回取り急ぎ作ったカスタムComponentが単純に"。"でテキストを分断するものなので、関連情報があまりとれておらず、全体としては少しハルシネーションを感じる結果になってしまいました。
Haystackの使用感
これまでLangChainを使うことが多く、今回初めてHaystackを使ってみた使用感についてまとめたいと思います。
まずHaystackの中心となるPipelineの構築はシンプルで理解しやすいように感じました。また比較的1つのComponentの中で処理が閉じている印象で、そのためあちこちのソースを見なくても内部処理が理解しやすかったです。それと今回は触れなかったのですが、Indexingでは他にも複数のファイルをファイル形式に応じて対応した読み込み処理を実現するためのルーティング用のComponentなども用意されており、特にファイルを読み込んでベクトルDB化する処理の関連機能は充実している印象を受けました。日本語文章に対応していないComponentもありましたが、比較的容易にカスタムComponentを作って対応することが出来ることも確認出来ました。
LangChainのようにRAGだけでなくコードやSQLを生成するような機能はありませんでしたが、RAG用途であればLangChainよりもシンプルに使えそうなので、今後も活用していければ、と思います。
まとめ
ということで、今回はLLMアプリケーション開発フレームワークの1つHaystackを使ってみた話をまとめてみました。いつもと違うフレームワークを使うことで、タスクに対してこういうアプローチもあるんだな、と別の視点で物事を見ることが出来、新鮮な気持ちになりました。1つのフレームワークで完結するのではなく、用途に応じて適切なフレームワークを選択出来るようになりたいと思いました。