Langchain の Retriever で similarity_score の取得

# はじめに
この記事は株式会社ナレッジコミュニケーションが運営する [クラウド AI by ナレコム Advent Calendar 2023](https://qiita.com/advent-calendar/2023/knowcom-cloudai) の15日目にあたる記事になります。

# 前提条件
langchain のバージョンは 0.0.348 です。
ベクトルDB は FAISS を利用しています。
検証用モンキーパッチとして参考にしていただければと思います。

# 課題
`retriever = db.as_retriever()` で作成した Retriever において参照したドキュメントのスコアを取得できない

# 解決策
`db.as_retireiver()` で指定できるドキュメント検索の `search_type` は以下の3つです。今回は、1と3を扱います。
1. similarity (default)：関連度スコアに基づいて検索
2. mmr：ドキュメントの多様性を考慮し検索（対象外）
3. similarity_score_threshold：関連度スコアの閾値を設定し検索

### similarity を利用するパターン
`similarity` では以下の `faiss.similarity_search` が利用されるためここを修正します。
`metadata` に `score` 属性を追加して返却します。
```python:langchain/vectorstores/faiss.py
def similarity_search(
    self,
    query: str,
    k: int = 4,
    filter: Optional[Dict[str, Any]] = None,
    fetch_k: int = 20,
    **kwargs: Any,
) -> List[Document]:
    """Return docs most similar to query.

    Args:
        query: Text to look up documents similar to.
        k: Number of Documents to return. Defaults to 4.
        filter: (Optional[Dict[str, str]]): Filter by metadata. Defaults to None.
        fetch_k: (Optional[int]) Number of Documents to fetch before filtering.
                  Defaults to 20.

    Returns:
        List of Documents most similar to the query.
    """
    docs_and_scores = self.similarity_search_with_score(
        query, k, filter=filter, fetch_k=fetch_k, **kwargs
    )
    
    # Before
    """
    return [doc for doc, _ in docs_and_scores]
    """
    
    # After
    result = []
    for doc, score in docs_and_scores:
        doc.metadata['score'] = score
        result.append(doc)
    return result
```

### similarity_score_threshold を利用するパターン
`similarity_score_threshold` では以下の `faiss._similarity_search_with_relevance_scores` が利用されるためここを修正します。
```python:langchain/vectorstores/faiss.py
    def _similarity_search_with_relevance_scores(
        self,
        query: str,
        k: int = 4,
        filter: Optional[Dict[str, Any]] = None,
        fetch_k: int = 20,
        **kwargs: Any,
    ) -> List[Tuple[Document, float]]:
        """Return docs and their similarity scores on a scale from 0 to 1."""
        # Pop score threshold so that only relevancy scores, not raw scores, are
        # filtered.
        relevance_score_fn = self._select_relevance_score_fn()
        if relevance_score_fn is None:
            raise ValueError(
                "normalize_score_fn must be provided to"
                " FAISS constructor to normalize scores"
            )
        docs_and_scores = self.similarity_search_with_score(
            query,
            k=k,
            filter=filter,
            fetch_k=fetch_k,
            **kwargs,
        )
        docs_and_rel_scores = []
        # Before
        """
        for doc, score in docs_and_scores:
            docs_and_rel_scores.append((doc, relevance_score_fn(score)))
        """
        # After
        for doc, score in docs_and_scores:
            doc.metadata["similarity_score"] = score # 距離スコア
            doc.metadata["relevance_score"] = relevance_score_fn(score) # 関連度スコア
            docs_and_rel_scores.append((doc, relevance_score_fn(score)))
        return docs_and_rel_scores
```
ここで、`relevance_score_fn` という関数がでてきています。
これは、
- 距離スコア：0に近いほど関連度が高い（関数の性質により異なりますが、簡易的に表現しています）
- 閾値：1に近いほど関連度が高い

となっている2つの値のギャップを埋めるための変換となっているようです。
なので、実際に閾値が適応される値は `relevance_score_fn(score)` となるため、ドキュメントの関連度としてはこちらの値を参考にする方が適切と思われます。

ちなみに、`relevance_score_fn` は以下で定義されています。


```python:langchain_core/vectorstores.py
    @staticmethod
    def _euclidean_relevance_score_fn(distance: float) -> float:
        """Return a similarity score on a scale [0, 1]."""
        # The 'correct' relevance function
        # may differ depending on a few things, including:
        # - the distance / similarity metric used by the VectorStore
        # - the scale of your embeddings (OpenAI's are unit normed. Many
        #  others are not!)
        # - embedding dimensionality
        # - etc.
        # This function converts the euclidean norm of normalized embeddings
        # (0 is most similar, sqrt(2) most dissimilar)
        # to a similarity function (0 to 1)
        return 1.0 - distance / math.sqrt(2)

    @staticmethod
    def _cosine_relevance_score_fn(distance: float) -> float:
        """Normalize the distance to a score on a scale [0, 1]."""

        return 1.0 - distance

    @staticmethod
    def _max_inner_product_relevance_score_fn(distance: float) -> float:
        """Normalize the distance to a score on a scale [0, 1]."""
        if distance > 0:
            return 1.0 - distance

        return -1.0 * distance
```

# おわりに
ナレッジコミュニケーションでは「[Musubite](https://musubite-job.com/)」というエンジニア同士のカジュアルトークサービスを利用しています！この記事にあるような生成 AI 技術を使ったプロジェクトに携わるメンバーと直接話せるサービスですので興味がある方は是非利用を検討してください！

https://recruiting.musubite-job.com/features/company/knowledge-communication