LangChainとmilvusで全文検索を使う
全文検索は、テキスト内のキーワードを直接マッチさせることにより、特定の用語やフレーズを含む文書を検索する伝統的な方法です。検索結果は関連性に基づいてランク付けされ、一般的には用語の頻度や近接度などの要素によって決定されます。セマンティック検索が意図や文脈を理解することに優れているのに対し、フルテキスト検索は正確なキーワードマッチングを行うため、補完的なツールとして価値がある。BM25アルゴリズムは全文検索でよく使われるランキング方法で、特にRAG(Retrieval-Augmented Generation)で有用である。
Milvus 2.5以降、BM25アルゴリズムをスパースベクトルとして表現することで、Sparse-BM25アプローチによる全文検索がネイティブでサポートされています。Milvusは生テキストを入力として受け入れ、指定されたフィールドに格納されたスパースベクトルに自動的に変換するため、手動でスパース埋め込みを生成する必要がありません。
LangChainとMilvusの統合もこの機能を導入し、RAGアプリケーションに全文検索を組み込むプロセスを簡素化しました。全文検索と密なベクトルによるセマンティック検索を組み合わせることで、密な埋め込みによるセマンティックコンテキストと単語マッチングによる正確なキーワード関連性の両方を活用するハイブリッドアプローチを実現することができます。この統合により、検索システムの精度、関連性、ユーザーエクスペリエンスが向上します。
このチュートリアルでは、LangChainとMilvusを使ってアプリケーションに全文検索を実装する方法を紹介します。
全文検索はMilvus StandaloneとMilvus Distributedで利用可能ですが、Milvus Liteでは利用できません。また、Milvus Cloud(フルマネージドMilvus)でも近日中に利用可能になる予定です。詳細はsupport@zilliz.comまでお問い合わせください。
前提条件
このノートブックを実行する前に、以下の依存関係がインストールされていることを確認してください:
$ pip install --upgrade --quiet langchain langchain-core langchain-community langchain-text-splitters langchain-milvus langchain-openai bs4 #langchain-voyageai
Google Colabを使用している場合、インストールしたばかりの依存関係を有効にするには、ランタイムを再起動する必要があるかもしれません(画面上部の "Runtime "メニューをクリックし、ドロップダウンメニューから "Restart session "を選択してください)。
OpenAIのモデルを使います。OpenAIの環境変数OPENAI_API_KEY
。
import os
os.environ["OPENAI_API_KEY"] = "sk-***********"
Milvus サーバURI
(オプションでTOKEN
) を指定してください。Milvusサーバのインストールと起動方法についてはこちらを参照してください。
URI = "http://localhost:19530"
# TOKEN = ...
サンプルドキュメントを用意する:
from langchain_core.documents import Document
docs = [
Document(page_content="I like this apple", metadata={"category": "fruit"}),
Document(page_content="I like swimming", metadata={"category": "sport"}),
Document(page_content="I like dogs", metadata={"category": "pets"}),
]
BM25関数による初期化
ハイブリッド検索
Milvus VectorStoreは全文検索のためにbuiltin_function
パラメータを受け付けます。このパラメータを通して、BM25BuiltInFunction
のインスタンスを渡すことができます。これは通常、VectorStore
に密な埋め込みを渡すセマンティック検索とは異なります、
Milvusで、セマンティック検索にOpenAIのdense embedding、全文検索にBM25を使ったハイブリッド検索の簡単な例を示します:
from langchain_milvus import Milvus, BM25BuiltInFunction
from langchain_openai import OpenAIEmbeddings
vectorstore = Milvus.from_documents(
documents=docs,
embedding=OpenAIEmbeddings(),
builtin_function=BM25BuiltInFunction(),
# `dense` is for OpenAI embeddings, `sparse` is the output field of BM25 function
vector_field=["dense", "sparse"],
connection_args={
"uri": URI,
},
consistency_level="Strong",
drop_old=True,
)
上のコードでは、BM25BuiltInFunction
のインスタンスを定義し、Milvus
オブジェクトに渡しています。BM25BuiltInFunction
は、Milvusにおける Function
の軽量なラッパークラスです。
この関数の入力フィールドと出力フィールドはBM25BuiltInFunction
のパラメータで指定できます:
input_field_names
(str)で指定します:入力フィールドの名前、デフォルトは です。 この関数がどのフィールドを入力として読み込むかを示します。text
output_field_names
(str):(str):出力フィールド名, デフォルトは . この関数が計算結果を出力するフィールドを示します.sparse
前述のMilvus初期化パラメータでは、vector_field=["dense", "sparse"]
も指定していることに注意してください。BM25BuiltInFunction
によって定義された出力フィールドとしてsparse
フィールドが取られるため、他のdense
フィールドは自動的に OpenAIEmbeddings の出力フィールドに割り当てられます。
実際には、特に複数の埋め込みや関数を組み合わせる場合には、曖昧さを避けるために、関数ごとに入力フィールドと出力フィールドを明示的に指定することをお勧めします。
以下の例では、BM25BuiltInFunction
の入力フィールドと出力フィールドを明示的に指定し、組み込み関数がどのフィールドに対するものかを明確にしています。
# from langchain_voyageai import VoyageAIEmbeddings
embedding1 = OpenAIEmbeddings(model="text-embedding-ada-002")
embedding2 = OpenAIEmbeddings(model="text-embedding-3-large")
# embedding2 = VoyageAIEmbeddings(model="voyage-3") # You can also use embedding from other embedding model providers, e.g VoyageAIEmbeddings
vectorstore = Milvus.from_documents(
documents=docs,
embedding=[embedding1, embedding2],
builtin_function=BM25BuiltInFunction(
input_field_names="text", output_field_names="sparse"
),
text_field="text", # `text` is the input field name of BM25BuiltInFunction
# `sparse` is the output field name of BM25BuiltInFunction, and `dense1` and `dense2` are the output field names of embedding1 and embedding2
vector_field=["dense1", "dense2", "sparse"],
connection_args={
"uri": URI,
},
consistency_level="Strong",
drop_old=True,
)
vectorstore.vector_fields
['dense1', 'dense2', 'sparse']
この例では、3つのベクトル・フィールドがあります。その中で、sparse
はBM25BuiltInFunction
の出力フィールドとして使用され、他の2つ、dense1
とdense2
は、(順番に基づいて)2つのOpenAIEmbeddings
モデルの出力フィールドとして自動的に割り当てられます。
このように、複数のベクトル・フィールドを定義し、それらに異なる埋め込みや関数の組み合わせを割り当てることで、ハイブリッド検索を実行することができる。
ハイブリッド検索を実行する際には、クエリーテキストを渡し、オプションでtopKとrerankerパラメータを設定するだけでよい。vectorstore
インスタンスは自動的にベクトル埋め込みと組み込み関数を処理し、最後にリランカーを使って結果を絞り込む。検索プロセスの基本的な実装の詳細は、ユーザーからは見えないようになっている。
vectorstore.similarity_search(
"Do I like apples?", k=1
) # , ranker_type="weighted", ranker_params={"weights":[0.3, 0.3, 0.4]})
[Document(metadata={'category': 'fruit', 'pk': 454646931479251897}, page_content='I like this apple')]
ハイブリッド検索の詳細については、ハイブリッド検索入門と LangChain Milvusハイブリッド検索チュートリアルを参照してください。
エンベッディングなしのBM25検索
埋め込みベースのセマンティック検索を使わず、BM25関数で全文検索のみを行いたい場合、埋め込みパラメータをNone
に設定し、BM25関数インスタンスとして指定されたbuiltin_function
のみを保持することができます。ベクトル・フィールドは "sparse "フィールドのみを持つ。例えば
vectorstore = Milvus.from_documents(
documents=docs,
embedding=None,
builtin_function=BM25BuiltInFunction(
output_field_names="sparse",
),
vector_field="sparse",
connection_args={
"uri": URI,
},
consistency_level="Strong",
drop_old=True,
)
vectorstore.vector_fields
['sparse']
アナライザーのカスタマイズ
アナライザーは、文章をトークンに分割し、ステミングやストップワード除去のような字句解析を行うことで、全文検索には欠かせないものです。アナライザーは通常、言語固有です。Milvusのアナライザについてはこちらのガイドをご参照ください。
Milvusは2種類のアナライザをサポートしています:ビルトイン アナライザと カスタム アナライザです。デフォルトでは、BM25BuiltInFunction
標準の内蔵アナライザが使用されます。これは、句読点を含むテキストをトークン化する最も基本的なアナライザです。
別のアナライザーを使いたい場合や、アナライザーをカスタマイズしたい場合は、BM25BuiltInFunction
の初期化でanalyzer_params
パラメーターを渡します。
analyzer_params_custom = {
"tokenizer": "standard",
"filter": [
"lowercase", # Built-in filter
{"type": "length", "max": 40}, # Custom filter
{"type": "stop", "stop_words": ["of", "to"]}, # Custom filter
],
}
vectorstore = Milvus.from_documents(
documents=docs,
embedding=OpenAIEmbeddings(),
builtin_function=BM25BuiltInFunction(
output_field_names="sparse",
enable_match=True,
analyzer_params=analyzer_params_custom,
),
vector_field=["dense", "sparse"],
connection_args={
"uri": URI,
},
consistency_level="Strong",
drop_old=True,
)
Milvusコレクションのスキーマを見て、カスタマイズされた解析器が正しくセットアップされていることを確認することができます。
vectorstore.col.schema
{'auto_id': True, 'description': '', 'fields': [{'name': 'text', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 65535, 'enable_match': True, 'enable_analyzer': True, 'analyzer_params': {'tokenizer': 'standard', 'filter': ['lowercase', {'type': 'length', 'max': 40}, {'type': 'stop', 'stop_words': ['of', 'to']}]}}}, {'name': 'pk', 'description': '', 'type': <DataType.INT64: 5>, 'is_primary': True, 'auto_id': True}, {'name': 'dense', 'description': '', 'type': <DataType.FLOAT_VECTOR: 101>, 'params': {'dim': 1536}}, {'name': 'sparse', 'description': '', 'type': <DataType.SPARSE_FLOAT_VECTOR: 104>, 'is_function_output': True}, {'name': 'category', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 65535}}], 'enable_dynamic_field': False, 'functions': [{'name': 'bm25_function_de368e79', 'description': '', 'type': <FunctionType.BM25: 1>, 'input_field_names': ['text'], 'output_field_names': ['sparse'], 'params': {}}]}
コンセプトの詳細、例えば、analyzer
,tokenizer
,filter
,enable_match
,analyzer_params
については、アナライザーのドキュメントを参照してください。
RAGでのハイブリッド検索と再ランク付けの使用
ここまでLangChainとmilvusの基本的なBM25ビルドイン関数の使い方を学んできた。ハイブリッド検索とリランキングで最適化されたRAG実装を紹介しましょう。
この図はハイブリッド検索と再ランク付けのプロセスを示しており、キーワードマッチングのためのBM25と意味検索のためのベクトル検索を組み合わせている。両方の方法からの結果はマージされ、再ランク付けされ、最終的な答えを生成するためにLLMに渡される。
ハイブリッド検索は精度と意味理解のバランスをとり、多様なクエリに対する精度と頑健性を向上させる。BM25全文検索とベクトル検索で候補を検索し、セマンティックでコンテキストを考慮した正確な検索を実現します。
それでは例題をご覧ください。
データの準備
Langchain WebBaseLoaderを使ってウェブソースからドキュメントをロードし、RecursiveCharacterTextSplitterを使ってチャンクに分割する。
import bs4
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Create a WebBaseLoader instance to load documents from web sources
loader = WebBaseLoader(
web_paths=(
"https://lilianweng.github.io/posts/2023-06-23-agent/",
"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/",
),
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-content", "post-title", "post-header")
)
),
)
# Load documents from web sources using the loader
documents = loader.load()
# Initialize a RecursiveCharacterTextSplitter for splitting text into chunks
text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)
# Split the documents into chunks using the text_splitter
docs = text_splitter.split_documents(documents)
# Let's take a look at the first document
docs[1]
Document(metadata={'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}, page_content='Fig. 1. Overview of a LLM-powered autonomous agent system.\nComponent One: Planning#\nA complicated task usually involves many steps. An agent needs to know what they are and plan ahead.\nTask Decomposition#\nChain of thought (CoT; Wei et al. 2022) has become a standard prompting technique for enhancing model performance on complex tasks. The model is instructed to “think step by step” to utilize more test-time computation to decompose hard tasks into smaller and simpler steps. CoT transforms big tasks into multiple manageable tasks and shed lights into an interpretation of the model’s thinking process.\nTree of Thoughts (Yao et al. 2023) extends CoT by exploring multiple reasoning possibilities at each step. It first decomposes the problem into multiple thought steps and generates multiple thoughts per step, creating a tree structure. The search process can be BFS (breadth-first search) or DFS (depth-first search) with each state evaluated by a classifier (via a prompt) or majority vote.\nTask decomposition can be done (1) by LLM with simple prompting like "Steps for XYZ.\\n1.", "What are the subgoals for achieving XYZ?", (2) by using task-specific instructions; e.g. "Write a story outline." for writing a novel, or (3) with human inputs.\nAnother quite distinct approach, LLM+P (Liu et al. 2023), involves relying on an external classical planner to do long-horizon planning. This approach utilizes the Planning Domain Definition Language (PDDL) as an intermediate interface to describe the planning problem. In this process, LLM (1) translates the problem into “Problem PDDL”, then (2) requests a classical planner to generate a PDDL plan based on an existing “Domain PDDL”, and finally (3) translates the PDDL plan back into natural language. Essentially, the planning step is outsourced to an external tool, assuming the availability of domain-specific PDDL and a suitable planner which is common in certain robotic setups but not in many other domains.\nSelf-Reflection#')
Milvusベクトルストアにドキュメントをロードする。
Milvusベクターストアには2つのベクターフィールドがあります。dense
はOpenAIのエンベッディング用、sparse
はBM25関数用です。
vectorstore = Milvus.from_documents(
documents=docs,
embedding=OpenAIEmbeddings(),
builtin_function=BM25BuiltInFunction(),
vector_field=["dense", "sparse"],
connection_args={
"uri": URI,
},
consistency_level="Strong",
drop_old=True,
)
RAGチェーンの構築
LLMインスタンスとプロンプトを準備し、LangChain Expression Languageを使ってRAGパイプラインに結合する。
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
# Initialize the OpenAI language model for response generation
llm = ChatOpenAI(model_name="gpt-4o", temperature=0)
# Define the prompt template for generating AI responses
PROMPT_TEMPLATE = """
Human: You are an AI assistant, and provides answers to questions by using fact based and statistical information when possible.
Use the following pieces of information to provide a concise answer to the question enclosed in <question> tags.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
<context>
{context}
</context>
<question>
{question}
</question>
The response should be specific and use statistics or numbers when possible.
Assistant:"""
# Create a PromptTemplate instance with the defined template and input variables
prompt = PromptTemplate(
template=PROMPT_TEMPLATE, input_variables=["context", "question"]
)
# Convert the vector store to a retriever
retriever = vectorstore.as_retriever()
# Define a function to format the retrieved documents
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
LCEL(LangChain Expression Language)を使ってRAGチェインを構築する。
# Define the RAG (Retrieval-Augmented Generation) chain for AI response generation
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# rag_chain.get_graph().print_ascii()
特定の質問でRAGチェーンを呼び出し、応答を取得する。
query = "What is PAL and PoT?"
res = rag_chain.invoke(query)
res
'PAL (Program-aided Language models) and PoT (Program of Thoughts prompting) are approaches that involve using language models to generate programming language statements to solve natural language reasoning problems. This method offloads the solution step to a runtime, such as a Python interpreter, allowing for complex computation and reasoning to be handled externally. PAL and PoT rely on language models with strong coding skills to effectively generate and execute these programming statements.'
おめでとう!MilvusとLangChainのハイブリッド(密なベクトル+疎なbm25関数)検索RAGチェーンが構築できました。