大家好,我是學生大使 Jambo。在我們前一篇文章中,我們介紹了 OpenAI 模型的調用。今天,我將為大家介紹 Embedding 的使用。
嵌入是什麼
嵌入(Embedding )是一種將高維數據映射到低維空間的方法。嵌入可以將高維數據可視化,也可以用於聚類、分類等任務。嵌入可以是線性的,也可以是非線性的。在深度學習中,我們通常使用非線性嵌入。非線性嵌入通常使用神經網絡實現。
上面這句話對於沒接觸過 NLP(自然語言處理)的同學來說可能有點抽象。你可以理解為通過嵌入,可以將文字信息壓縮、編碼成向量(或者不准確的稱之為數組),而這個向量包含了這段文字的語義。我們可以將這個技術用於搜索引擎、推薦系統等等。
調用 Embedding 模型
與前篇一樣,我們需要先部署模型。這裡我們使用 text-embedding-ada-002
。
然後安裝 openai
包。用以下命令安裝,會將 numpy、pandas 等庫一併安裝。
pip install openai[datalib]
接下來導入 openai
,並做一些初始化工作。
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" # 模型的部署名
embedding = openai.Embedding.create(
input="蘋果", engine="text-embedding-ada-002"
)
print(embedding1)
{
"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
庫來計算兩個數列的餘弦相似度:
import numpy as np
# 計算兩個向量的餘弦相似度
def cosine_similarity(a, b):
return np.dot(a, b) # 計算點積
下面是個簡單的例子:
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))
0.8823086919469535
0.8256366789720779
0.7738048005367909
用 Embedding 加強 GPT 的能力
GPT模型非常強大,它能夠根據訓練的數據回答問題。但是,它只能回答那些在訓練時獲取到的知識,對於訓練時獲取不到的知識,它一無所知。所以對於類似“明天天氣如何”,或者企業內部的一些專業知識,GPT模型就無能為力了。
天氣等及時性較強的內容,我們可以通過搜索引擎獲得相關的信息。而像新聞報導或是文檔這類內容,通常篇幅較長,可 GPT 模型能處理的文字有限,因此我們需要將其分割成多個段落,然後找到其中最相關的段落,再將其與問題一起傳入 GPT 模型中。而如何找到最相關的段落呢?這就需要用到 Embedding 模型了。將分割後的段落傳入 Embedding 模型,得到每個段落的向量,然後計算與問題的相似度,最後找到最相似的段落。特別是本地文檔,我們可以事先將其轉換成向量,然後保存下來,這樣就可以快速地找到最相關的段落。
下面我用 Codon 的文檔作為資料來源,並讓 GPT 可以根據文檔裡的內容進行回答。
預處理文檔
我用下面的代碼將文檔分割成多個段落,並且保證每段字數不超過 2048:
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 等向量數據庫中。
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,並且我們還需要傳入與問題相關的段落。
prompt_prefix = """
你是一個客服,回答用戶關於文檔的問題。
僅使用以下資料提供的事實進行回答。如果下面沒有足夠的信息,就說你不知道。不要生成不使用以下資料的答案。
資料:
{sources}
"""
有時我們提問的問題可能會與先前的對話相關,因此為了更好的匹配文檔段落,我們將對話歷史和新的問題告訴 GPT,並讓它幫我們生成一個查詢語句。
summary_prompt_template = """
以上是到目前為止的對話記錄,下面我將提出一個新問題,需要通過在知識庫中搜索相關的條目來回答問題。根據以上的對話記錄和下面的新問題,生成一個英文的查詢語句,用於在知識庫中搜索相關的條目。你只需要回答查詢的語句,不用加其他任何內容。
新問題:
{question}
"""
生成查詢語句
我們首先先定義一些幫助函數:
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
中。
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”。
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 的回答放進去。
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 是什麼?”
[Searching: Python Codon 是什麼? ]
Codon 是一個高性能的 Python 編譯器,它可以將 Python 代碼編譯成本地機器代碼,而不需要任何運行時開銷。 Codon 的性能通常與 C/C++ 相當,而且還支持本地多線程,可以實現更高的加速比。此外,Codon 還可以通過插件基礎架構進行擴展,可以輕鬆地集成新的庫、編譯器優化和關鍵字等。
--------------------------------------------------
作為對比,我們來看看 ChatGPT 的回答:
可見在 ChatGPT 的訓練集中,並沒有 Codon 相關的信息,因此他無法給出我們想要的答案。而我們通過 Embedding 的方式,找到 Codon 相關的資料,然後將其傳入 GPT,讓 GPT 生成答案,這樣就可以得到我們想要的答案了。
當然,在實際的應用中,代碼絕對不會這麼簡單,我們還需要考慮很多問題,比如如何儲存和更新知識庫,如何處理對話的輸入和輸出,如何管理對話的歷史等等。但是,這些都不是我們今天要討論的問題,我們今天只是想要討論一下 Embedding 與 GPT 的結合,以及如何將文字轉換為 Embedding。
而 Embedding 的用法也不只是這一種。得益於向量的可測距性,我們還可以將其用於聚類、分類、推薦,甚至是可視化等等,這些都是我們今天沒有討論的內容。
Top comments (1)
如果用openai sdk裡的cosine_similarity function跟單純用np.dot運算後得到的結果會不一樣, 比較推薦用哪種方式呢?