【langchain】AgentExecutorの出力にベクトル検索のスコアを含めたい

# はじめに
langchainのAgentExecutorの出力に、（中間ステップで実行した）ベクトル検索のスコアを含める方法を検討しました。
# 動機
langchainを使った検索UIを作るうえで、ベクトル検索の結果を事後に補正したくなる時があります。langchainのAgent機能（AgentExecutor）を使うと、ユーザ入力を言語モデルで検索ワードに変換し、検索結果をえるという処理を簡潔に書けて便利です。しかし、検索結果の文章は中間出力として取得できるのですが、検索スコアは含まれていませんでした。そこで、AgentExecutorの出力に検索スコアを含める方法を検討しました。
# 環境

```
% pip list | grep langchain
langchain                 0.0.277
```
# 実装
結論としては、VectorStoreクラスの`similarity_search_with_score`系の関数をオーバーライドして、Documentのmetadataにscoreを書き込むようにするのが簡単だと思いました。

例えば、FAISSクラスの場合、以下のようなクラスを定義してToolに渡すことで、実現できました。

```Python
from langchain.vectorstores.faiss import FAISS

import operator
import os
import pickle
import uuid
import warnings
from pathlib import Path
from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    List,
    Optional,
    Sized,
    Tuple,
)

import numpy as np

from langchain.docstore.document import Document
from langchain.vectorstores.utils import DistanceStrategy
from langchain.vectorstores.faiss import dependable_faiss_import

class MyFAISS(FAISS):
    def similarity_search_with_score_by_vector(
        self,
        embedding: List[float],
        k: int = 4,
        filter: Optional[Dict[str, Any]] = None,
        fetch_k: int = 20,
        **kwargs: Any,
    ) -> List[Tuple[Document, float]]:
        """Return docs most similar to query.

        Args:
            embedding: Embedding vector to look up documents similar to.
            k: Number of Documents to return. Defaults to 4.
            filter (Optional[Dict[str, Any]]): Filter by metadata. Defaults to None.
            fetch_k: (Optional[int]) Number of Documents to fetch before filtering.
                      Defaults to 20.
            **kwargs: kwargs to be passed to similarity search. Can include:
                score_threshold: Optional, a floating point value between 0 to 1 to
                    filter the resulting set of retrieved docs

        Returns:
            List of documents most similar to the query text and L2 distance
            in float for each. Lower score represents more similarity.
        """
        faiss = dependable_faiss_import()
        vector = np.array([embedding], dtype=np.float32)
        if self._normalize_L2:
            faiss.normalize_L2(vector)
        scores, indices = self.index.search(vector, k if filter is None else fetch_k)
        docs = []
        for j, i in enumerate(indices[0]):
            if i == -1:
                # This happens when not enough docs are returned.
                continue
            _id = self.index_to_docstore_id[i]
            doc = self.docstore.search(_id)

            # オリジナル版に追記。metadataにscoreを保存
            doc.metadata["score"] = scores[0][j]
            # 追記ここまで
            if not isinstance(doc, Document):
                raise ValueError(f"Could not find document for id {_id}, got {doc}")
            if filter is not None:
                filter = {
                    key: [value] if not isinstance(value, list) else value
                    for key, value in filter.items()
                }
                if all(doc.metadata.get(key) in value for key, value in filter.items()):
                    docs.append((doc, scores[0][j]))
            else:
                docs.append((doc, scores[0][j]))

        score_threshold = kwargs.get("score_threshold")
        if score_threshold is not None:
            cmp = (
                operator.ge
                if self.distance_strategy
                in (DistanceStrategy.MAX_INNER_PRODUCT, DistanceStrategy.JACCARD)
                else operator.le
            )
            docs = [
                (doc, similarity)
                for doc, similarity in docs
                if cmp(similarity, score_threshold)
            ]
        return docs[:k]
    
    # 既存のFAISSクラスを変換するためのクラスメソッドを追加
    @classmethod
    def from_vectorstore(cls, vectorstore: FAISS):
        """
        オリジナルから必要な変数をコピーしてクラスを初期化。以下参考。

        Initialize with necessary components.
        self.embedding_function = embedding_function
        self.index = index
        self.docstore = docstore
        self.index_to_docstore_id = index_to_docstore_id
        self.distance_strategy = distance_strategy
        self.override_relevance_score_fn = relevance_score_fn
        self._normalize_L2 = normalize_L2
                        """
        return cls(
            vectorstore.embedding_function
            , vectorstore.index
            , vectorstore.docstore
            , vectorstore.index_to_docstore_id
            , vectorstore.override_relevance_score_fn
            , vectorstore._normalize_L2
        )
```

以下のように使います。

```Python
from langchain.chat_models import ChatOpenAI
from langchain.agents import initialize_agent, AgentType, Tool
from langchain.embeddings import OpenAIEmbeddings

vectorstore = MyFAISS.from_texts(["「こんにちは」は昼の挨拶です。", "「さようなら」は別れの挨拶です。"], OpenAIEmbeddings())

retriever = vectorstore.as_retriever(
    search_kwargs=dict(k=3)
)

tools = [
    Tool(
        name = "greeting_search"
        , func = retriever.get_relevant_documents
        , description = "挨拶を検索したいときに使う"
    )
]
llm = ChatOpenAI(model="gpt-3.5-turbo")
agent = initialize_agent(tools, llm, AgentType.OPENAI_FUNCTIONS, return_intermediate_steps=True)

res = agent.invoke({"input":"別れの挨拶は？"})

# 出力の取得
output = res["output"]
# 検索結果の取得
intermediate_steps = res["intermediate_steps"] # 中間出力
print(output)
print(intermediate_steps)
```

```
別れの挨拶としては「さようなら」が一般的です。
[(_FunctionsAgentAction(tool='greeting_search', tool_input='別れ', log='\nInvoking: `greeting_search` with `別れ`\n\n\n', message_log=[AIMessage(content='', additional_kwargs={'function_call': {'name': 'greeting_search', 'arguments': '{\n  "__arg1": "別れ"\n}'}}, example=False)]), [Document(page_content='「さようなら」は別れの挨拶です。', metadata={'score': 0.26613048}), Document(page_content='「こんにちは」は昼の挨拶です。', metadata={'score': 0.36656606})])]
```

intermediate_stepsに含まれる検索結果（Document）に、中に、scoreを含むDocumentのデータが存在していました。

# 解説

## AgentExecutorの基本的な仕組み
initialize_agentの戻り値はAgentExecutorです。AgentExecutorはToolを使ってユーザの入力に回答する一連の処理を行うためのクラスです。AgentExecutorの初期化時にreturn_intermediate_steps=Trueを指定することで、中間ステップの出力も取得できます。中間ステップの出力とはToolの実行結果です。Toolの実行結果とは、Toolの初期化時に渡した関数の戻り値です。

```Python
tools = [
    Tool(
        name = "greeting_search"
        , func = retriever.get_relevant_documents
        , description = "挨拶を検索したいときに使う"
    )
]
```

上記で言うと、retriever.get_relevant_documentsの戻り値がintermediate_stepsに含まれます。

## 検討したこと
要するに、Toolに与える関数がスコア込みの検索結果を返すものであれば、intermediate_steps経由でAgentExecutorの出力としてscoreを取り出せることになります。

get_relevant_documentsを全く別の関数にしてしまうことも検討したのですが、get_relevant_documents含めBaseRetrieverはToolとして使いやすい工夫がいろいろなされたクラスなので、なるべく活かしたいと思いました。AgentExecutor側が問題なく動くことを保証するためにも、Toolの戻り値はDocumentのリストのままにしておいた方が良いと思いました。そこで、Documentのmetadataにscoreを追加することにしました。

ソースコードを読むと、VectorStoreの中でもFAISSの場合は、similarity_search_with_score_by_vectorという関数で検索結果をDocument化しています。同じ関数の中でscoreも得ていますので、この関数だけoverrideすれば、目的が達成できるとわかりました。

FAISS以外のVectorStoreの場合、similarity_search_with_scoreが存在しているとは限りませんが、出力をDocument化している関数が何かしらあるはずなので、そこでscoreを追加してやれば良さそうです。