CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

LLMアプリケーション開発フレームワーク"Haystack"を試してみる。

こんにちは、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がそれぞれどんな用途に向いているのかを調べていたところ、次の記事が見つかり、目を通してみました。

medium.com

ざっとまとめるとLangChainは豊富な機能があり複雑な処理が必要なアプリケーションの開発に向いている一方、Haystackは優れた検索機能によって特に情報検索処理が必要なアプリケーションの開発に向いているとのことです。記事を読んでいると、これまで開発に関わってきたアプリケーションの中で、もしかしたらこれはLangChainよりもHaystackの方が向いていたのかも?と思うケースがいくつか思い浮かんできました。

Haystack

Haystackのドキュメントにはチュートリアルが用意されており、そちらを目を通すことでHaystackで出来ることを大まかに理解することが出来ます。

haystack.deepset.ai

Haystackでアプリケーションの処理を組むためのコアとなる概念が"Component"と"Pipeline"です。ComponentはHaystackの処理を組むための基本部品で、Pipelineは複数のComponentを組み合わせて作られた一連の処理プロセスを指します。ComponentはあらかじめHaystackに組み込まれている実装済みのものを使うことで大抵のケースに対応可能ですが、必要に応じて独自のカスタムComponentを作ることも出来ます。

今回Haystackを使って参考ドキュメントを読み込んで埋め込みベクトルを取得しデータベースに格納する処理と、ユーザーからの質問を受け取り、データベースから質問に関連する情報を取得し、その情報を使って回答を生成する処理をPipelineとして組んでみました。

作成した2つのPipelineについて

今回作成したのは以下の2つのPipelineです。

Indexing PipelineとRAG 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のサーバに送信する設定があることが明記されています。

docs.haystack.deepset.ai

関連するコードを見るとこの設定がデフォルトでは"ON"になっているようです。この設定を無効化する場合はドキュメントに従い、環境変数HAYSTACK_TELEMETRY_ENABLEDFalseを設定する必要があります。

セットアップ

次のコマンドで必要なライブラリをインストールしました。

pip install haystack-ai chroma-haystack lxml_html_clean trafilatura  chromadb==0.5.3

haystack-aiがHaystackです。lxml_html_cleantrafilaturaはHTMLファイルを読み込む際に使用します。chromadbは何も指定しないと私の環境では0.5.5のバージョンがインストールされたのですが、DB書き込み時に次のエラーが出てしまいました。

AttributeError: 'Collection' object has no attribute 'model_fields'

これはHaystackを使用した時だけ出るエラーではないようで、LangChainを使っても同様のエラーが出るという報告があがっています。LangChainのissueを参考にすると、chromadb0.5.3にダウングレードするとエラーが解消した、というコメントがあり、そのように対応すると私の環境でもエラーが解消されました。

github.com

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の実行に必要となるパラメータを渡してあげます。今回はHTMLToDocumentconverterに読み込み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_pipelineindexing_pipelineの時とは異なり、実行する時はtext_embedderprompt_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つのフレームワークで完結するのではなく、用途に応じて適切なフレームワークを選択出来るようになりたいと思いました。