Embeddings 向量相似度之亂
Tags: python, langchain, chromadb, embeddings
Published: 2023-08-11 06:18:03

使用 Embedding 最重要的事情就是計算兩個向量之間的相似度, 不同的工具預設的計算方式不同, 甚至名稱一樣的計算工具計算方式也不見得一樣。如果沒有搞清楚, 就會被弄得一頭霧水, 同樣的兩個向量為什麼算出來的相似度差那麼多！

## Chroma 預設是計算幾何距離的平方

計算向量相似度最精確的作法是計算向量之間的幾何距離, 不過這牽涉求平方根運算, 所以在 [Chroma 資料庫](https://docs.trychroma.com/usage-guide?lang=py#changing-the-distance-function)中, 預設使用的是底層 [hsnwlib](https://github.com/nmslib/hnswlib#python-bindings) 的 'l2', 計算**向量之間幾何距離的平方**。由於我們實際需要比較的是單一向量與多個向量之間的相似度, 距離平方只會加大尺度, 但不會變更相似度順序, 計算時還可以省去求平方根的運算, 可以說是效率不錯的作法。

以下以 LangChain 為例, 先匯入相關類別：

```python
>>> from langchain.vectorstores import Chroma
>>> from langchain.embeddings.openai import OpenAIEmbeddings
>>> embeddings = OpenAIEmbeddings()
```

利用一組字串建立資料庫, 這裡我們只想觀察計算結果, 所以只加入一個字串：

```python
>>> vectorstore = Chroma.from_texts(
...     texts=['這是什麼？'],
...     ids=['t1'],
...     embedding=embeddings,
...     collection_name='coll1')
```

由於不同 OpenAIEmbeddings 物件轉的向量不會完全一樣, 所以先將轉好的向量取出來, 稍後才能用同樣的基準比較計算值：

```python
>>> items = vectorstore.get(ids=['t1'], include=['embeddings'])
>>> emb = items['embeddings'][0]
```

接著製作一個查詢用的向量, 稍後會在資料庫中尋找相似的向量：

```python
>>> q_emb = embeddings.embed_query('我是誰？')
```

然後就可以傳入向量給 [`similarity_search_by_vector_with_relevance_scores`](https://api.python.langchain.com/en/latest/vectorstores/langchain.vectorstores.chroma.Chroma.html?highlight=chroma#langchain.vectorstores.chroma.Chroma.similarity_search_by_vector_with_relevance_scores) 搜尋相似的向量：

```python
>>> vectorstore.similarity_search_by_vector_with_relevance_scores(
...      embedding=q_emb, k=1)
[(Document(page_content='這是什麼？', metadata={}), 0.2521283030509949)]
```

可以看到相似值是 0.2520018517971039。如果要對照餘弦距離, 我們可以使用 openai 套件內的 [`embeddings_utils`](https://github.com/openai/openai-python/blob/main/openai/embeddings_utils.py#L139) 模組：

```python
>>> from openai import embeddings_utils
>>> embeddings_utils.distances_from_embeddings(
...     distance_metric='cosine',
...     embeddings=[emb],
...     query_embedding=q_emb)
[0.12606411768227765]
```

就會看到這兩個數值差很多, 因為 Chroma 算的並不是餘弦距離。[`distances_from_embeddings`](https://github.com/openai/openai-python/blob/b82a3f7e4c462a8a10fa445193301a3cefef9a4a/openai/embeddings_utils.py#L139) 也可以計算幾何距離：

```python
>>> embeddings_utils.distances_from_embeddings(
...     distance_metric='L2',
...     embeddings=[emb],
...     query_embedding=q_emb)
[0.5021237315911099]
```

不過要注意的是雖然計算名稱是 'L2', 似乎和剛剛提到 Chroma 的 'l2' 同名, 不過它算的是**幾何距離**, 但是 Chroma 算的是**距離的平方**, 所以我們要將計算出的值平方才會得到和 Chroma 同樣的結果：

```python
>>> embeddings_utils.distances_from_embeddings(
...     distance_metric='L2',
...     embeddings=[emb],
...     query_embedding=q_emb)[0] ** 2
0.25212824182698096
```

## OpenAI 將向量標準化為 1 的好處

你可能已經發現餘弦距離似乎剛剛好是幾何距離的一半, 這不是巧合, 而是因為 OpenAI 的 Embedding 會將向量都[標準化成長度 1](https://platform.openai.com/docs/guides/embeddings/which-distance-function-should-i-use), 所以才會有這樣的結果。請參考以下圖解：

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l46il35kyzr1pjqxbwlw.png)

由於 OpenAI Embedding 的這個特性, 使得餘弦距離與幾何距離是線性關係, 在比較向量間距離的相關關係時可以將餘弦距離與幾何距離看成等義, 實際上就不需要計算幾何距離了。另外, 因為向量長度為 1, 計算餘弦時只需要計算兩個向量內積就可以, 計算上會比幾何距離快。

:::info
embeddings_util 是通用的模組, 並不限定只能搭配 OpenAI 的 Embeddings 使用, 所以實際上計算餘弦距離時仍然會計算向量長度。
:::

如果要讓 Chroma 改用餘弦距離, 建立資料庫時就要加入[額外的選項](https://docs.trychroma.com/usage-guide?lang=py#changing-the-distance-function), 例如：

```python
>>> vectorstore_c = Chroma.from_texts(
...     texts=['這是什麼？'],
...     embedding=embeddings,
...     collection_name='coll2',
...     collection_metadata={"hnsw:space": "cosine"})
```

:::warning
建立新的 Chroma 物件時請注意要使用不同的 collection 名稱, 才會是不同的儲存個體, collection_metadata 才會生效。同 collection 名稱的距離計算方式無法更改。
:::

再計算相似值, 就可以發現改成餘弦距離了：

```python
>>> vectorstore_c.similarity_search_by_vector_with_relevance_scores(
...      embedding=q_emb, k=1)
[(Document(page_content='這是什麼？', metadata={}), 0.12606358528137207)]
```

# Chroma 的底層 hnswlib

Chroma 實際上在底層使用的是 [hnswlib](https://github.com/nmslib/hnswlib/tree/master), 由 hnswlib 負責處理向量, Chroma 負責文字與向量之間的關聯, 因此實際上搜尋相關文字的工作是由 hnswlib 處理, 使用的是大家都耳熟能詳的[最近鄰居 (k-nearest-neighbor, knn) 演算法](https://zh.wikipedia.org/zh-tw/K-%E8%BF%91%E9%82%BB%E7%AE%97%E6%B3%95)。你也可以直接使用 hnswlib：

```python
>>> import hnswlib
```

建立儲存索引的物件時可以指定要使用哪一種距離計算方法, 1536 是 OpenAI Embedding 向量的維度：

```python
>>> index = hnswlib.Index('cosine', 1536)
```

接著設定可以容納的向量個數, 這裡我們只需要儲存單一個向量：

```python
>>> index.init_index(1)
```

加入向量後就可以搜尋相近的向量：

```python
>>> index.add_items([emb])
>>> index.knn_query(q_emb, k=1)
(array([[0]], dtype=uint64), array([[0.12606359]], dtype=float32))
```

你可以看到因為是採用餘弦距離, 所以這裡算出來的數值跟之前的程式結果是一樣的。你也可以指定使用不同的計算方法, 像是 "l2", 這也是預設的方法：

```python
>>> index = hnswlib.Index('l2', 1536)
>>> index.init_index(1)
>>> index.add_items([emb])
>>> index.knn_query(q_emb, k=1)
(array([[0]], dtype=uint64), array([[0.25212833]], dtype=float32))
```

計算出來就是餘弦距離的兩倍了。

:::warning
再次提醒, 這是因為 OpenAI 的 Embedding 機制會將向量都標準化成長度 1, 才會有幾何距離平方是餘弦距離兩倍的結果。其他 Embedding 機制不一定會將向量標準化成長度 1, 自然就不會有這樣的結果。
:::