來 Azure 學習 OpenAI 四 - 用 Embedding 加強 GPT
Published: 2023-05-21 22:30:00

大家好，我是學生大使 Jambo。在我們前一篇文章中，我們介紹了 OpenAI 模型的調用。今天，我將為大家介紹 Embedding 的使用。

## 嵌入是什麼

嵌入（Embedding ）是一種將高維數據映射到低維空間的方法。嵌入可以將高維數據可視化，也可以用於聚類、分類等任務。嵌入可以是線性的，也可以是非線性的。在深度學習中，我們通常使用非線性嵌入。非線性嵌入通常使用神經網絡實現。

上面這句話對於沒接觸過 NLP（自然語言處理）的同學來說可能有點抽象。你可以理解為通過嵌入，可以將文字信息壓縮、編碼成向量（或者不准確的稱之為數組），而這個向量包含了這段文字的語義。我們可以將這個技術用於搜索引擎、推薦系統等等。

## 調用 Embedding 模型

與前篇一樣，我們需要先部署模型。這裡我們使用 `text-embedding-ada-002`。

![1](https://raw.githubusercontent.com/JamboChen/Jambo-blog/master/img/azure-openai/4.openai-embedding/1.png)

然後安裝 `openai` 包。用以下命令安裝，會將 numpy、pandas 等庫一併安裝。

```bash
pip install openai[datalib]
```

接下來導入 `openai`，並做一些初始化工作。

```python
import openai

openai.api_key = "REPLACE_WITH_YOUR_API_KEY_HERE"    # Azure 的密鑰
openai.api_base = "REPLACE_WITH_YOUR_ENDPOINT_HERE"  # Azure 的終結點
openai.api_type = "azure" 
openai.api_version = "2023-03-15-preview" # API 版本，未來可能會變
model = "text-embedding-ada-002"  # 模型的部署名
```

```python
embedding = openai.Embedding.create(
    input="蘋果", engine="text-embedding-ada-002"
)
print(embedding1)
```

```txt
{
  "data": [
    {
      "embedding": [
        0.011903401464223862,
        -0.023080304265022278,
        -0.0015027695335447788,
        ...
    ],
      "index": 0,
      "object": "embedding"
    }
  ],
  "model": "ada",
  "object": "list",
  "usage": {
    "prompt_tokens": 3,
    "total_tokens": 3
  }
}
```

其中 `embedding` 就是 “蘋果” 所對應的向量。

## 計算向量相似度

在我們將文字轉換成向量之後，我們討論兩句話的相似度，其實就是問它們所對應向量的相似度。通常我們使用餘弦相似度來衡量兩個向量的相似度。

餘弦相似度是計算兩個向量夾角角度的 $\cos$ 值，取值範圍在 -1 和 1 之間。如果兩個向量的方向完全一致，那麼它們的餘弦相似度為 1；如果兩個向量的方向完全相反，那麼它們的餘弦相似度為 -1；如果兩向量是垂直（正交）的，那麼它們的餘弦相似度為 0。其公式如下：

$$
\cos(\theta) = \frac{\vec A \cdot \vec B}{\|\vec A\| \|\vec B\|}
$$

$\vec A$ 和 $\vec B$ 分別是兩個向量，$\theta$ 是兩個向量的夾角。而 $\|\vec A\|$ 和 $\|\vec B\|$ 分別是向量 $\vec A$ 和 $\vec B$ 的長度（模長）。因為 OpenAI 的 Embedding 模型返回的是單位向量，即向量的長度為 1，所以我們不需要計算模長，而它們的夾角就是兩個向量的點積。

$$
\cos(\theta) = \frac{\vec A \cdot \vec B}{1 \cdot 1} = \vec A \cdot \vec B
$$

有的人可能會疑惑為什麼不用歐式距離來計算。在這種向量長度都為 1 的情況下，歐式距離和余弦相似度其實是等價的，它們之間是可以互相轉換的。

在 Python 中，我們可以使用 `numpy` 庫來計算兩個數列的餘弦相似度：

```python
import numpy as np

# 計算兩個向量的餘弦相似度
def cosine_similarity(a, b):
    return np.dot(a, b)  # 計算點積
```

下面是個簡單的例子：

```python
embedding1 = openai.Embedding.create(
    input="蘋果", engine="text-embedding-ada-002"
)["data"][0]["embedding"]
embedding2 = openai.Embedding.create(
    input="apple", engine="text-embedding-ada-002"
)["data"][0]["embedding"]
embedding3 = openai.Embedding.create(
    input="鞋子", engine="text-embedding-ada-002"
)["data"][0]["embedding"]

print(cosine_similarity(embedding1, embedding2))
print(cosine_similarity(embedding1, embedding3))
print(cosine_similarity(embedding2, embedding3))
```

```text
0.8823086919469535
0.8256366789720779
0.7738048005367909
```

## 用 Embedding 加強 GPT 的能力

GPT模型非常強大，它能夠根據訓練的數據回答問題。但是，它只能回答那些在訓練時獲取到的知識，對於訓練時獲取不到的知識，它一無所知。所以對於類似“明天天氣如何”，或者企業內部的一些專業知識，GPT模型就無能為力了。

天氣等及時性較強的內容，我們可以通過搜索引擎獲得相關的信息。而像新聞報導或是文檔這類內容，通常篇幅較長，可 GPT 模型能處理的文字有限，因此我們需要將其分割成多個段落，然後找到其中最相關的段落，再將其與問題一起傳入 GPT 模型中。而如何找到最相關的段落呢？這就需要用到 Embedding 模型了。將分割後的段落傳入 Embedding 模型，得到每個段落的向量，然後計算與問題的相似度，最後找到最相似的段落。特別是本地文檔，我們可以事先將其轉換成向量，然後保存下來，這樣就可以快速地找到最相關的段落。

下面我用 [Codon 的文檔](https://github.com/exaloop/codon/tree/develop/docs)作為資料來源，並讓 GPT 可以根據文檔裡的內容進行回答。

### 預處理文檔

我用下面的代碼將文檔分割成多個段落，並且保證每段字數不超過 2048：

```python
import os
import pandas

MAX_LEN = 2048


def split_text(text, max_length=2048):
    paragraphs = text.split("\n")
    result = []
    current_paragraph = ""
    for paragraph in paragraphs:
        if len(current_paragraph) + len(paragraph) > max_length:
            result.append(current_paragraph)
            current_paragraph = paragraph
        else:
            current_paragraph += "\n" + paragraph
    if current_paragraph:
        result.append(current_paragraph)
    return result


def find_md_files(directory):
    result = []
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith(".md"):
                result.append(os.path.join(root, file))
    return result


if __name__ == "__main__":
    df = pandas.DataFrame(columns=["file", "content"])
    for file in find_md_files("."):
        with open(file) as f:
            text = f.read()
        for c in split_text(text, MAX_LEN):
            df.loc[len(df)] = [file, c]

    df.to_csv("output.csv", index=False)

```

然後將這些段落傳入 Embedding 模型，得到每個段落的向量。這裡我沒有使用異步，這是為了避免觸發 API 的速率限制。為了演示方便，我只是將數據保存在 csv 文件中，實際使用時，我們可以將數據保存到 Pinecone，Milvus 等向量數據庫中。

```python
import openai
import pandas

openai.api_key = ""
openai.api_base = ""
openai.api_type = "azure"
openai.api_version = "2023-03-15-preview"
model = "text-embedding-ada-002"


def get_embedding(text):
    response = openai.Embedding.create(input=text, engine="text-embedding-ada-002")
    embedding = response["data"][0]["embedding"]
    assert len(embedding) == 1536
    return embedding


def main():
    df = pandas.read_csv("output.csv")
    embeddings = [get_embedding(text) for text in df["content"]]
    df["embedding"] = embeddings
    df.to_csv("docs.csv", index=False)


if __name__ == "__main__":
    import time

    star = time.time()
    main()
    print(f"Time taken: {time.time() - star}")

```

### 製作 Prompt

為了讓 GPT 只回答文檔裡的內容，我們首先要將這件事告訴 GPT，並且我們還需要傳入與問題相關的段落。

```python
prompt_prefix = """
你是一個客服，回答用戶關於文檔的問題。
僅使用以下資料提供的事實進行回答。如果下面沒有足夠的信息，就說你不知道。不要生成不使用以下資料的答案。

資料：
{sources}
"""
```

有時我們提問的問題可能會與先前的對話相關，因此為了更好的匹配文檔段落，我們將對話歷史和新的問題告訴 GPT，並讓它幫我們生成一個查詢語句。

```python
summary_prompt_template = """
以上是到目前為止的對話記錄，下面我將提出一個新問題，需要通過在知識庫中搜索相關的條目來回答問題。根據以上的對話記錄和下面的新問題，生成一個英文的查詢語句，用於在知識庫中搜索相關的條目。你只需要回答查詢的語句，不用加其他任何內容。

新問題：
{question}
"""
```

### 生成查詢語句

我們首先先定義一些幫助函數：

```python
def cos_sim(a, b):
    return np.dot(a, b)


def get_chat_answer(messages: dict, max_token=1024):
    return openai.ChatCompletion.create(
        engine=chat_model,
        messages=messages,
        temperature=0.7,
        max_tokens=max_token,
    )["choices"][0]["message"]


def get_embedding(text):
    return openai.Embedding.create(
        engine=embed_model,
        input=text,
    )["data"][
        0
    ]["embedding"]


docs = pd.read_csv("docs.csv", converters={"embedding": eval})
pd.set_option("display.max_colwidth", None) # 顯示完整的文本
history = []

```

`history` 是用來儲存對話歷史。在下面的代碼中如果 `history` 為空，那麼我們就直接使用用戶的輸入作為查詢語句，否則我們就使用 GPT 生成的查詢語句。要注意的是，我是把歷史記錄和生成查詢的請求拼在一起輸入給模型的，沒有把請求放到 `history` 中。

```python
user_input = ""

if len(history) == 0:
    query = user_input
else:
    query = get_chat_answer(
        history
        + [
            {
                "role": "user",
                "content": summary_prompt_template.format(question=user_input),
            }
        ],
        max_token=32,
    )["content"]

print(f"Searching: {query}")
```

### 搜索最相關的段落

我用 pandas 將先前保存好的段落和對應向量讀取出來，然後計算查詢語句和每個段落的相似度，最後拿到最相似的段落。當然，如果相似度不夠高，我們就告訴 GPT “no information”。

```python
docs = pd.read_csv("data.csv", converters={"embedding": eval})

query_embedding = get_embedding(query)
dot_products = np.dot(np.stack(docs["embedding"].values), query_embedding)
top_index = np.argsort(dot_products)[-1:]
top_content = (
    docs.iloc[top_index]["content"].to_string(index=False)
    if dot_products[top_index] > 0.8
    else "no information"
)
```

### 生成回答

現在我們獲取到了相關的信息，接下來我們將相關的信息和問題一起傳入 GPT，讓它生成回答。這裡因為我用的是 GPT-3，他對 `system` 的內容沒有那麼看重，所以我用了 `user` 的身份來傳入最開始我們設定的 prompt，並手動編寫了一個回答來強化 GPT 對於我們的提示的理解。這句話和上面生成查詢語句的請求一樣，並沒有放到 `history` 中。但我們有將 GPT 的回答放進去。

```python
history.append({"role": "user", "content": user_input})

massage = [
    {"role": "user", "content": prompt_prefix.format(sources=top_content)},
    {
        "role": "assistant",
        "content": "好的，我只會根據以上提供的資料提供的內容回答問題，我不會回答不使用資源的內容。",
    },
] + history
res = get_chat_answer(massage)
print(res["content"])
history.append(res)
print("-" * 50, end="\n\n")
```

接下來我們可以來嘗試一下，我先輸入一個問題：“Python Codon 是什麼？”

```text
[Searching: Python Codon 是什麼？ ]
Codon 是一個高性能的 Python 編譯器，它可以將 Python 代碼編譯成本地機器代碼，而不需要任何運行時開銷。 Codon 的性能通常與 C/C++ 相當，而且還支持本地多線程，可以實現更高的加速比。此外，Codon 還可以通過插件基礎架構進行擴展，可以輕鬆地集成新的庫、編譯器優化和關鍵字等。
--------------------------------------------------
```

![2](https://raw.githubusercontent.com/JamboChen/Jambo-blog/master/img/azure-openai/4.openai-embedding/2.png)

作為對比，我們來看看 ChatGPT 的回答：

![3](https://raw.githubusercontent.com/JamboChen/Jambo-blog/master/img/azure-openai/4.openai-embedding/3.png)

可見在 ChatGPT 的訓練集中，並沒有 Codon 相關的信息，因此他無法給出我們想要的答案。而我們通過 Embedding 的方式，找到 Codon 相關的資料，然後將其傳入 GPT，讓 GPT 生成答案，這樣就可以得到我們想要的答案了。

當然，在實際的應用中，代碼絕對不會這麼簡單，我們還需要考慮很多問題，比如如何儲存和更新知識庫，如何處理對話的輸入和輸出，如何管理對話的歷史等等。但是，這些都不是我們今天要討論的問題，我們今天只是想要討論一下 Embedding 與 GPT 的結合，以及如何將文字轉換為 Embedding。

而 Embedding 的用法也不只是這一種。得益於向量的可測距性，我們還可以將其用於聚類、分類、推薦，甚至是可視化等等，這些都是我們今天沒有討論的內容。