词嵌入:为您的聊天机器人提供上下文以获得更好的答案

Word Embedding Contextualize for Your Chatbot to Get Better Answers.

毫无疑问,OpenAI的ChatGPT异常智能 —— 它已通过律师考试,具备医生一样的知识,并且一些测试显示它的智商为155。然而,它倾向于捏造信息而不是承认无知。这种倾向,加上它的知识仅限于2021年,给使用GPT API构建专业产品带来了挑战。

我们如何克服这些障碍?如何向像GPT-3这样的模型灌输新的知识?我的目标是通过使用Python、OpenAI API和词嵌入构建一个问答机器人来回答这些问题。

我将构建什么

我打算创建一个机器人,根据提示生成持续集成流水线,你可能知道,这些流水线是使用Semaphore CI/CD中的YAML编写的。

下面是机器人运行的示例:

运行程序的屏幕截图。屏幕上执行命令python query.py "创建一个构建并上传Docker镜像到Docker Hub的CI流水线",程序打印出对应于执行请求操作的CI流水线的YAML。

我计划借鉴类似DocsGPT、My AskAI和Libraria的项目,”教授” GPT-3模型关于Semaphore以及如何生成流水线配置文件的知识。我将通过利用现有的文档来实现这一目标。

我不会假设你已经具备构建机器人的先验知识,并且会保持代码整洁,以便你能根据自己的需求进行调整。

先决条件

你不需要有编写机器人的经验或者了解神经网络来跟随本教程。但是,你需要:

  • Python 3。

  • 一个Pinecone账户(免费注册Starter计划)。

  • 一个OpenAI API密钥(付费,需要信用卡);新用户在前3个月内可以免费获得5美元的使用额度。

但是ChatGPT无法学习,对吗?

ChatGPT,或者更准确地说,GPT-3和GPT-4,即驱动它们的大型语言模型(LLMs),是在一个巨大的数据集上进行训练的,截止日期大约在2021年9月。

从本质上讲,GPT-3对于那个日期之后的事件了解甚少。我们可以通过一个简单的提示来验证这一点:

虽然一些OpenAI模型可以进行微调,但更高级的模型,如我们关注的这些模型,不能进行微调;我们不能扩充它们的训练数据。

我们如何从GPT-3获得超出其训练数据的答案?一种方法是利用其文本理解能力;通过增加相关上下文的提示,我们很可能可以获得正确的答案。

在下面的示例中,我提供了来自FIFA官方网站的上下文,响应有了显著的差异:

我们可以推断出,只要给定足够相关的上下文,该模型就可以对任何提示做出回应。问题是:在给定任意提示的情况下,我们如何知道什么是相关的?为了解决这个问题,我们需要探索一下什么是词嵌入

什么是词嵌入?

在语言模型的语境下,嵌入是一种将单词、句子或整个文档表示为向量或数字列表的方式。

要计算嵌入,我们需要一个像word2vec或text-embedding-ada-002这样的神经网络。这些网络已经在大量的文本上进行了训练,并且可以通过分析特定模式在训练数据中出现的频率来找到单词之间的关系。

假设我们有以下单词:

  • 房子

想象一下,我们使用其中一个嵌入网络来计算每个单词的向量。例如:

一旦我们有了每个单词的向量,我们可以用它们来表示文本的含义。例如,句子“猫追逐球”可以表示为向量[0.1, 0.2, 0.3, 0.4, 0.5] + [0.2, 0.4, 0.6, 0.8, 1.0] = [0.3, 0.6, 0.9, 1.2, 1.5]。这个向量表示的是一个关于动物追逐物体的句子。

词嵌入可以被视为多维空间,其中具有相似含义的单词或句子靠得很近。我们可以通过计算向量之间的“距离”来找到任何输入文本的相似含义。

以向量空间形式呈现的嵌入的3D表示。实际上,这些空间可以有数百个或数千个维度。来源:Meet AI’s Multitool: Vector Embeddings

所有这些背后的实际数学知识超出了本文的范围。然而,关键点在于向量运算使我们能够使用数学来操作或确定含义。取代表单词“queen”的向量,减去“woman”向量,再加上“man”向量。结果应该是在“king”附近的一个向量。如果我们加上“son”,我们应该得到接近“prince”的结果。

使用标记嵌入神经网络

到目前为止,我们已经讨论了以单词为输入和数字为输出的嵌入神经网络。然而,许多现代网络已经从处理单词转变为处理标记。

标记是模型可以处理的最小文本单位。标记可以是单词、字符、标点符号、符号或单词的部分。

我们可以通过尝试使用OpenAI在线分词器来查看单词如何转换为标记,该分词器使用字节对编码(BPE)将文本转换为标记,并使用一个数字表示每个标记:

标记和单词之间通常存在一对一的关系。大多数标记包括单词和一个前导空格。然而,也存在像“embedding”这样由两个标记“embed”和“ding”组成的特殊情况,或者像“capabilities”这样由四个标记组成的情况。如果您点击“Token IDs”,您可以看到模型对每个标记的数字表示。

使用嵌入设计更智能的机器人

现在我们已经了解了嵌入是什么,接下来的问题是:它们如何帮助我们构建一个更智能的机器人?

首先,让我们考虑当我们直接使用GPT-3 API时会发生什么。用户提出一个提示,模型尽力回答。

然而,当我们添加上下文时,情况就会发生变化。例如,当我在提供上下文后询问ChatGPT关于世界杯的获胜者时,这就产生了很大的不同。

因此,构建一个更智能的机器人的计划如下:

  1. 拦截用户的提示。

  2. 计算该提示的嵌入,得到一个向量。

  3. 在数据库中搜索靠近该向量的文档,因为它们应该在语义上与初始提示相关。

  4. 将原始提示与任何相关上下文一起发送给GPT-3。

  5. 将GPT-3的响应转发给用户。

让我们像大多数项目一样,从设计数据库开始。

使用嵌入创建知识数据库

我们的上下文数据库必须包括原始文档及其相应的向量。原则上,我们可以为此任务使用任何类型的数据库,但是一个向量数据库是最适合这项任务的工具。

向量数据库是专门设计用于存储和检索高维向量数据的数据库。与使用SQL等查询语言进行搜索不同,我们提供一个向量并请求N个最近的邻居。

为了生成向量,我们将使用OpenAI的text-embedding-ada-002,因为它是他们提供的最快和最具成本效益的模型。该模型将输入文本转换为标记,并使用一种被称为Transformer的注意机制来学习它们之间的关系。这个神经网络的输出是表示文本含义的向量。

为了创建上下文数据库,我将:

  1. 收集所有的源文件。

  2. 过滤掉不相关的文档。

  3. 计算每个文档的嵌入。

  4. 将向量、原始文本和任何其他相关元数据存储在数据库中。

将文档转换为向量

首先,我必须用OpenAI API密钥初始化一个环境文件。这个文件不应该被提交到版本控制中,因为API密钥是私有的,与您的账户相关联。

export OPENAI_API_KEY=YOUR_API_KEY

接下来,我将为我的Python应用程序创建一个虚拟环境:

$ virtualenv venv
$ source venv/bin/activate
$ source .env

然后安装OpenAI包:

```bash
$ pip install openai numpy

让我们尝试计算字符串”Docker Container”的嵌入。您可以在Python REPL上或作为Python脚本运行:

$ python

>>> import openai

>>> embeddings = openai.Embedding.create(input="Docker Containers", engine="text-embedding-ada-002")

>>> embeddings

 JSON: {
 "data": [
 {
 "embedding": [
 -0.00530336843803525,
 0.0013223182177171111,
 
 ... 还有1533个项目 ...,
 
 -0.015645816922187805
 ],
 "index": 0,
 "object": "embedding"
 }
 ],
 "model": "text-embedding-ada-002-v2",
 "object": "list",
 "usage": {
 "prompt_tokens": 2,
 "total_tokens": 2
 }
}

如您所见,OpenAI的模型返回了一个包含1536个项目的embedding列表 — 这是text-embedding-ada-002的向量大小。

将嵌入存储在Pinecone中

虽然有多个可选择的向量数据库引擎,比如开源的Chroma,但我选择了Pinecone,因为它是一个托管的数据库,有一个免费的套餐,这使得事情更简单。他们的Starter套餐完全可以处理我所需的所有数据。

在创建了我的Pinecone账户并获取了API密钥和环境之后,我将这两个值添加到我的.env文件中。

现在,.env应该包含我的Pinecone和OpenAI的密钥。

export OPENAI_API_KEY=YOUR_API_KEY

# Pinecone密钥
export PINECONE_API_KEY=YOUR_API_KEY
export PINECONE_ENVIRONMENT=YOUR_PINECONE_DATACENTER

然后,我安装Python的Pinecone客户端:

$ pip install pinecone-client

我需要初始化一个数据库;这是db_create.py脚本的内容:

# db_create.py

import pinecone
import openai
import os

index_name = "semaphore"
embed_model = "text-embedding-ada-002"

api_key = os.getenv("PINECONE_API_KEY")
env = os.getenv("PINECONE_ENVIRONMENT")
pinecone.init(api_key=api_key, environment=env)

embedding = openai.Embedding.create(
 input=[
 "Sample document text goes here",
 "there will be several phrases in each batch"
 ], engine=embed_model
)

if index_name not in pinecone.list_indexes():
 print("Creating pinecone index: " + index_name)
 pinecone.create_index(
 index_name,
 dimension=len(embedding['data'][0]['embedding']),
 metric='cosine',
 metadata_config={'indexed': ['source', 'id']}
 )

该脚本可能需要几分钟来创建数据库。

$ python db_create.py

接下来,我将安装tiktoken包。我将使用它来计算源文档的标记数。这很重要,因为嵌入模型只能处理最多8191个标记。

$ pip install tiktoken

在安装软件包时,让我们也安装tqdm以生成一个漂亮的进度条。

$ pip install tqdm

现在,我需要将文档上传到数据库。此脚本将被称为index_docs.py。让我们首先导入所需的模块并定义一些常量:

# index_docs.py

# Pinecone数据库名称和上传批处理大小
index_name = 'semaphore'
upsert_batch_size = 20

# OpenAI嵌入和标记化模型
embed_model = "text-embedding-ada-002"
encoding_model = "cl100k_base"
max_tokens_model = 8191

接下来,我们需要一个函数来计数标记。OpenAI页面上有一个标记计数器的示例:

import tiktoken
def num_tokens_from_string(string: str) -> int:
 """返回文本字符串中的标记数量。"""
 encoding = tiktoken.get_encoding(encoding_model)
 num_tokens = len(encoding.encode(string))
 return num_tokens

最后,我需要一些过滤函数将原始文档转换为可用的示例。文档中的大多数示例都在代码墙之间,因此我将从每个文件中提取所有的YAML代码:

import re
def extract_yaml(text: str) -> str:
 """返回在文本中找到的所有YAML代码块的列表。"""
 matches = [m.group(1) for m in re.finditer("```yaml([\w\W]*?)```", text)]
 return matches

函数部分已经完成。接下来,这将在内存中加载文件并提取示例:

from tqdm import tqdm
import sys
import os
import pathlib

repo_path = sys.argv[1]
repo_path = os.path.abspath(repo_path)
repo = pathlib.Path(repo_path)

markdown_files = list(repo.glob("**/*.md")) + list(
 repo.glob("**/*.mdx")
)

print(f"从 {repo_path} 中的 Markdown 文件中提取 YAML")
new_data = []
for i in tqdm(range(0, len(markdown_files))):
 markdown_file = markdown_files[i]
 with open(markdown_file, "r") as f:
 relative_path = markdown_file.relative_to(repo_path)
 text = str(f.read())
 if text == '':
 continue
 yamls = extract_yaml(text)
 j = 0
 for y in yamls:
 j = j+1
 new_data.append({
 "source": str(relative_path),
 "text": y,
 "id": f"github.com/semaphore/docs/{relative_path}[{j}]"
 })

此时,所有的YAML应该都存储在new_data列表中。最后一步是将嵌入上传到Pinecone中。

import pinecone
import openai

api_key = os.getenv("PINECONE_API_KEY")
env = os.getenv("PINECONE_ENVIRONMENT")
pinecone.init(api_key=api_key, enviroment=env)
index = pinecone.Index(index_name)

print(f"创建嵌入并上传向量到数据库")
for i in tqdm(range(0, len(new_data), upsert_batch_size)):
 
 i_end = min(len(new_data), i+upsert_batch_size)
 meta_batch = new_data[i:i_end]
 ids_batch = [x['id'] for x in meta_batch]
 texts = [x['text'] for x in meta_batch]

 embedding = openai.Embedding.create(input=texts, engine=embed_model)
 embeds = [record['embedding'] for record in embedding['data']]

 # 清理元数据后进行更新
 meta_batch = [{
 'id': x['id'],
 'text': x['text'],
 'source': x['source']
 } for x in meta_batch] 

 to_upsert = list(zip(ids_batch, embeds, meta_batch))
 index.upsert(vectors=to_upsert)

作为参考,您可以在演示存储库中找到完整的index_docs.py文件。

运行索引脚本以完成数据库设置:

$ git clone https://github.com/semaphoreci/docs.git /tmp/docs
$ source .env
$ python index_docs.py /tmp/docs

测试数据库

Pinecone仪表板应显示数据库中的向量。

我们可以使用以下代码查询数据库,您可以将其作为脚本运行或直接在Python REPL中运行:

$ python

>>> import os
>>> import pinecone
>>> import openai

# 计算字符串"Docker Container"的嵌入
>>> embeddings = openai.Embedding.create(input="Docker Containers", engine="text-embedding-ada-002")


# 连接到数据库
>>> index_name = "semaphore"
>>> api_key = os.getenv("PINECONE_API_KEY")
>>> env = os.getenv("PINECONE_ENVIRONMENT")
>>> pinecone.init(api_key=api_key, environment=env)
>>> index = pinecone.Index(index_name)

# 查询数据库
>>> matches = index.query(embeddings['data'][0]['embedding'], top_k=1, include_metadata=True)

>>> matches['matches'][0]
{'id': 'github.com/semaphore/docs/docs/ci-cd-environment/docker-authentication.md[3]',
 'metadata': {'id': 'github.com/semaphore/docs/docs/ci-cd-environment/docker-authentication.md[3]',
 'source': 'docs/ci-cd-environment/docker-authentication.md',
 'text': '\n'
 '# .semaphore/semaphore.yml\n'
 'version: v1.0\n'
 'name: Using a Docker image\n'
 'agent:\n'
 ' machine:\n'
 ' type: e1-standard-2\n'
 ' os_image: ubuntu1804\n'
 '\n'
 'blocks:\n'
 ' - name: Run container from Docker Hub\n'
 ' task:\n'
 ' jobs:\n'
 ' - name: Authenticate docker pull\n'
 ' commands:\n'
 ' - checkout\n'
 ' - echo $DOCKERHUB_PASSWORD | docker login '
 '--username "$DOCKERHUB_USERNAME" --password-stdin\n'
 ' - docker pull /\n'
 ' - docker images\n'
 ' - docker run /\n'
 ' secrets:\n'
 ' - name: docker-hub\n'},
 'score': 0.796259582,
 'values': []}

正如您所看到的,第一个匹配是一个Semaphore管道的YAML,它拉取一个Docker镜像并运行它。这是一个很好的开始,因为它与我们的” Docker容器 “搜索字符串相关。

构建机器人

我们有数据,也知道如何查询它。让我们在机器人中投入使用。

处理提示的步骤是:

  1. 获取用户的提示。

  2. 计算其向量。

  3. 从数据库中检索相关上下文。

  4. 将用户的提示与上下文一起发送给GPT-3。

  5. 将模型的响应转发给用户。

和往常一样,我将从在机器人的主要脚本complete.py中定义一些常量开始:

# complete.py

# Pinecone数据库名称、要检索的匹配数
# 截断的相似度得分,以及作为上下文的令牌数量
index_name = 'semaphore'
context_cap_per_query = 30
match_min_score = 0.75
context_tokens_per_query = 3000

# OpenAI LLM模型参数
chat_engine_model = "gpt-3.5-turbo"
max_tokens_model = 4096
temperature = 0.2 
embed_model = "text-embedding-ada-002"
encoding_model_messages = "gpt-3.5-turbo-0301"
encoding_model_strings = "cl100k_base"

import pinecone
import os

# 连接Pinecone数据库并索引
api_key = os.getenv("PINECONE_API_KEY")
env = os.getenv("PINECONE_ENVIRONMENT")
pinecone.init(api_key=api_key, environment=env)
index = pinecone.Index(index_name)

接下来,我将添加一些计算令牌数的函数,如OpenAI示例所示。第一个函数计算字符串中的令牌数,而第二个函数计算消息中的令牌数。稍后我们将详细介绍消息。现在,我们只需要知道它是一种在内存中保持对话状态的结构。

import tiktoken

def num_tokens_from_string(string: str) -> int:
    """返回文本字符串中的令牌数。"""
    encoding = tiktoken.get_encoding(encoding_model_strings)
    num_tokens = len(encoding.encode(string))
    return num_tokens


def num_tokens_from_messages(messages):
    """返回由消息列表使用的令牌数。与模型兼容"""

    try:
        encoding = tiktoken.encoding_for_model(encoding_model_messages)
    except KeyError:
        encoding = tiktoken.get_encoding(encoding_model_strings)

    num_tokens = 0
    for message in messages:
        num_tokens += 4 # 每个消息后面跟随{role/name}\n{content}\n
        for key, value in message.items():
            num_tokens += len(encoding.encode(value))
        if key == "name": # 如果有名称,则省略角色
            num_tokens += -1 # 角色始终是必需的并且始终是1个令牌
        num_tokens += 2 # 每个回复都以助理为开头
    return num_tokens

下面的函数接受原始提示和上下文字符串,返回一个丰富了的用于GPT-3的提示:

def get_prompt(query: str, context: str) -> str:
    """返回带有查询和上下文的提示。"""
    return (
        f"创建连续集成管道的YAML代码以完成所请求的任务。\n" +
        f"下面是一些可能有帮助的上下文。如果看起来无关紧要,请忽略它。\n\n" +
        f"上下文:\n{context}" +
        f"\n\n任务:{query}\n\nYAML代码:"
    )

get_message函数以与API兼容的格式格式化提示:

def get_message(role: str, content: str) -> dict:
    """为OpenAI API的完成生成一条消息。"""
    return {"role": role, "content": content}

有三种角色类型会影响模型的反应:

  • 用户:用户的原始提示。

  • 系统:帮助设置助手的行为。尽管对其效果存在争议,但似乎将其发送到消息列表的末尾时更有效。

  • 助手:代表模型的先前响应。OpenAI API没有”记忆”,因此我们必须在每次交互时将模型的先前响应发送回来以保持对话。

现在是有趣的部分。 get_context 函数接受提示信息,查询数据库并生成一个上下文字符串,直到满足以下条件之一:

  • 完整的文本超过了 context_tokens_per_query,我为上下文保留的空间。

  • 搜索函数检索了所有请求的匹配项。

  • 相似度得分低于 match_min_score 的匹配项将被忽略。

import openai

def get_context(query: str, max_tokens: int) -> list:
 """为OpenAI模型生成消息。添加上下文直到达到 `context_token_limit` 限制。返回提示字符串。"""

 embeddings = openai.Embedding.create(
 input=[query],
 engine=embed_model
 )

 # 搜索数据库
 vectors = embeddings['data'][0]['embedding']
 embeddings = index.query(vectors, top_k=context_cap_per_query, include_metadata=True)
 matches = embeddings['matches']

 # 过滤和聚合上下文
 usable_context = ""
 context_count = 0
 for i in range(0, len(matches)):

 source = matches[i]['metadata']['source']
 if matches[i]['score'] < match_min_score:
 # 跳过相似度得分低的上下文
 continue
 
 context = matches[i]['metadata']['text']
 token_count = num_tokens_from_string(usable_context + '\n---\n' + context)

 if token_count < context_tokens_per_query:
 usable_context = usable_context + '\n---\n' + context 
 context_count = context_count + 1

 print(f"为您的查询找到 {context_count} 个上下文")

 return usable_context

下一个也是最后一个函数,complete,向OpenAI发出API请求并返回模型的响应。

def complete(messages):
 """查询OpenAI模型。返回第一个答案。"""

 res = openai.ChatCompletion.create(
 model=chat_engine_model,
 messages=messages,
 temperature=temperature
 )
 return res.choices[0].message.content.strip()

好了,现在我只需要处理命令行参数并按正确的顺序调用函数:

import sys

query = sys.argv[1]

context = get_context(query, context_tokens_per_query)
prompt = get_prompt(query, context)

# 初始化要发送到OpenAI API的消息列表
messages = []
messages.append(get_message('user', prompt))
messages.append(get_message('system', 'You are a helpful assistant that writes YAML code for Semaphore continuous integration pipelines and explains them. Return YAML code inside code fences.'))

if num_tokens_from_messages(messages) >= max_tokens_model:
 raise Exception('达到了模型的令牌大小限制') 

print("正在处理您的查询... ")
answer = complete(messages)
print("答案:\n")
print(answer)
messages.append(get_message('assistant', answer))

现在是运行脚本并看看效果的时候了:

$ python complete.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"

结果是:

version: v1.0
name: Docker Build and Push
agent:
 machine:
 type: e1-standard-2
 os_image: ubuntu1804

blocks:
 - name: "Build and Push Docker Image"
 task:
 jobs:
 - name: "Docker Build and Push"
 commands:
 - checkout
 - docker build -t /: .
 - echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
 - docker push /:

promotions:
 - name: Deploy to production
 pipeline_file: deploy-production.yml
 auto_promote:
 when: "result = 'passed' and branch = 'master'"

这是第一个好的结果。模型从我们提供的上下文示例中推断出了语法。

扩展机器人功能的想法

请记住,我从一个谦虚的目标开始:创建一个能够编写YAML流水线的助手。通过在我的向量数据库中添加更丰富的内容,我可以将机器人推广到回答有关Semaphore(或任何产品 – 记住将文档克隆到 /tmp 中)的任何问题。

获得良好答案的关键是 – 不出所料 – 质量上下文。仅将每个文档上传到向量数据库中不太可能产生良好的结果。上下文数据库应该经过策划,带有描述性元数据,并且要简明扼要。否则,我们将冒着用无关上下文填满提示的令牌配额的风险。

因此,从某种意义上讲,微调机器人以满足我们的需求是一门艺术,也需要大量的试错。我们可以通过调整相似度分数,实验上下文限制、删除低质量内容、总结和过滤无关的上下文来进行实验。

实现一个合适的聊天机器人

你可能已经注意到,我的机器人不能像ChatGPT一样进行实际对话。我们提出一个问题,得到一个答案。

原则上,将机器人转化为一个成熟的聊天机器人并不太具有挑战性。我们可以通过在每个API请求中重新发送先前的回答来保持对话。以“assistant”角色的形式将之前的GPT-3回答发送回来。例如:

messages = []

while True:

 query = input('请输入您的提示:\n')
 
 context = get_context(query, context_tokens_per_query)
 prompt = get_prompt(query, context)
 messages.append(get_message('user', prompt))
 messages.append(get_message('system', '您是一个有用的助手,为Semaphore持续集成流水线编写和解释YAML代码。在代码框内返回YAML代码。'))

 if num_tokens_from_messages(messages) >= max_tokens_model:
 raise Exception('已达到模型的标记数限制') 

 print("正在处理您的查询... ")
 answer = complete(messages)
 print("答案:\n")
 print(answer)
 
 # 删除系统消息并添加模型的答案
 messages.pop() 
 messages.append(get_message('assistant', answer))

不幸的是,这种实现方式相当简陋。随着每次交互的标记数量增加,它将不支持扩展对话。很快,我们将达到GPT-3的4096个标记限制,无法继续对话。

因此,我们必须找到一些方法来保持请求在标记限制内。以下是一些策略:

  • 删除较早的消息。虽然这是最简单的解决方案,但它将限制对话的“记忆”只能包含最近的消息。

  • 对先前的消息进行总结。我们可以利用“向模型提问”来压缩早期的消息,并将其替换为原始的问题和答案。尽管这种方法会增加成本和查询之间的延迟,但与简单删除过去的消息相比,可能会产生更好的结果。

  • 对交互次数设置严格限制。

  • 等待GPT-4 API的普遍可用性,它不仅更智能,还具有双倍的标记容量。

  • 使用更新的模型,如“gpt-3.5-turbo-16k”,可以处理多达16k个标记。

结论

通过单词嵌入和良好的上下文数据库,可以改进机器人的回答。为了实现这一点,我们需要高质量的文档。开发一个看似掌握主题的机器人涉及大量的试错。

我希望这次深入探讨单词嵌入和大型语言模型能帮助您构建一个更强大、根据您需求定制的机器人。

祝您建设愉快!