毫无疑问,OpenAI的ChatGPT异常聪明——它通过了律师资格考试,拥有类似于医生的知识,一些测试显示它的智商高达155 。然而,它倾向于捏造信息而不是承认无知。这种趋势,加上其知识将于 2021 年终止的事实,给使用 GPT API 构建专业产品带来了挑战。
我们怎样才能克服这些障碍呢?我们如何向 GPT-3 这样的模型传授新知识?我的目标是通过使用 Python、OpenAI API 和词嵌入构建问答机器人来解决这些问题。
我打算创建一个机器人,它可以根据提示生成持续集成管道,如您所知,该管道是使用 Semaphore CI/CD 中的 YAML 格式化的。
以下是机器人实际运行的示例:
正在运行的程序的屏幕截图。在屏幕上,执行命令python query.py "Create a CI pipeline that builds and uploads a Docker image to Docker Hub"
,程序打印出与执行请求操作的 CI 管道对应的 YAML。
本着DocsGPT 、 My AskAI和Libraria等项目的精神,我计划“教授”GPT-3 模型有关信号量以及如何生成管道配置文件的知识。我将通过利用现有文档来实现这一目标。
我不会假设您事先了解机器人构建,并将维护干净的代码,以便您可以根据您的要求进行调整。
您无需具备机器人编码经验或神经网络知识即可学习本教程。但是,您将需要:
ChatGPT,或更准确地说,GPT-3 和 GPT-4,即为其提供支持的大型语言模型 (LLM),已经在海量数据集上进行了训练,截止日期约为 2021 年 9 月。
本质上,GPT-3 对于该日期之后的事件知之甚少。我们可以通过一个简单的提示来验证这一点:
ChatGPT 不知道谁赢得了 2022 年世界杯。
虽然某些 OpenAI 模型可以进行微调,但更高级的模型(例如我们感兴趣的模型)则不能;我们无法增加他们的训练数据。
除了训练数据之外,我们如何从 GPT-3 中获得答案?一种方法是利用其文本理解能力;通过增强相关上下文的提示,我们很可能获得正确的答案。
在下面的示例中,我提供了来自FIFA 官方网站的上下文,并且响应明显不同:
根据提供的上下文,ChatGPT 可以准确回答。
我们可以推断,如果给出足够的相关上下文,模型可以响应任何提示。问题仍然是:在给定任意提示的情况下,我们如何才能知道什么是相关的?为了解决这个问题,我们需要探索什么是词嵌入。
在语言模型的上下文中,嵌入是将单词、句子或整个文档表示为向量或数字列表的一种方式。
为了计算嵌入,我们需要一个神经网络,例如word2vec或text-embedding-ada-002 。这些网络经过大量文本的训练,可以通过分析训练数据中特定模式出现的频率来找到单词之间的关系。
假设我们有以下单词:
想象一下,我们使用这些嵌入网络之一来计算每个单词的向量。例如:
单词 | 向量 | 语境 |
---|---|---|
猫 | [0.1、0.2、0.3、0.4、0.5] | 动物、物体、小东西 |
狗 | [0.6、0.7、0.8、0.9、1.0] | 动物、物体、大东西 |
球 | [0.2、0.4、0.6、0.8、1.0] | 物体、玩具、小东西 |
房子 | [0.3、0.6、0.9、1.2、1.5] | 建筑物、住宅、大型物体 |
一旦我们有了每个单词的向量,我们就可以用它们来表示文本的含义。例如,句子“猫追球”可以表示为向量 [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 表示。实际上,这些空间可以有数百或数千个维度。资料来源: 认识 AI 的多功能工具:矢量嵌入
所有这一切背后的实际数学超出了本文的范围。然而,关键的一点是向量运算允许我们使用数学来操纵或确定含义。获取表示单词“queen”的向量,从中减去“woman”向量,然后添加“man”向量。结果应该是“king”附近的向量。如果我们加上“儿子”,我们应该会接近“王子”。
到目前为止,我们已经讨论了将单词作为输入、数字作为输出的嵌入神经网络。然而,许多现代网络已经从处理单词转向处理令牌。
令牌是模型可以处理的最小文本单元。标记可以是单词、字符、标点符号、符号或单词的一部分。
我们可以通过试验OpenAI 在线 tokenizer来了解单词如何转换为 token,它使用字节对编码(BPE) 将文本转换为 token,并用数字表示每个 token:
标记和单词之间通常存在一对一的关系。大多数标记都包含单词和前导空格。然而,也有一些特殊情况,比如“embedding”,它由两个标记组成,“embed”和“ding”,或者“capability”,它由四个标记组成。如果单击“令牌 ID”,您可以看到每个令牌的模型数字表示形式。
现在我们已经了解了嵌入是什么,下一个问题是:它们如何帮助我们构建更智能的机器人?
首先,让我们考虑一下直接使用 GPT-3 API 时会发生什么。用户发出提示,模型会尽力做出响应。
然而,当我们在等式中添加上下文时,事情就会发生变化。例如,当我在提供背景信息后向 ChatGPT 询问世界杯冠军的情况时,一切都变得不同了。
因此,构建更智能的机器人的计划如下:
让我们像大多数项目一样从设计数据库开始。
我们的上下文数据库必须包括原始文档及其各自的向量。原则上,我们可以使用任何类型的数据库来完成此任务,但矢量数据库是完成此任务的最佳工具。
矢量数据库是专门用于存储和检索高维矢量数据的数据库。我们不使用 SQL 等查询语言进行搜索,而是提供一个向量并请求 N 个最近邻居。
为了生成向量,我们将使用 OpenAI 的text-embedding-ada-002 ,因为它是他们提供的最快且最具成本效益的模型。该模型将输入文本转换为标记,并使用称为Transformer 的注意机制来学习它们的关系。该神经网络的输出是表示文本含义的向量。
要创建上下文数据库,我将:
首先,我必须使用 OpenAI API 密钥初始化环境文件。该文件不应提交版本控制,因为 API 密钥是私有的并与您的帐户绑定。
export OPENAI_API_KEY=YOUR_API_KEY
接下来,我将为我的 Python 应用程序创建一个 virtualenv:
$ 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 more items ..., -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 的向量大小。
虽然有多种矢量数据库引擎可供选择,例如开源的Chroma ,但我选择了Pinecone ,因为它是一个具有免费层的托管数据库,这使事情变得更简单。他们的Starter 计划完全能够处理我需要的所有数据。
创建 Pinecone 帐户并检索 API 密钥和环境后,我将这两个值添加到我的.env
文件中。
现在.env
应该包含我的 Pinecone 和 OpenAI 秘密。
export OPENAI_API_KEY=YOUR_API_KEY # Pinecone secrets 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 db name and upload batch size index_name = 'semaphore' upsert_batch_size = 20 # OpenAI embedding and tokenizer models 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: """Returns the number of tokens in a text string.""" encoding = tiktoken.get_encoding(encoding_model) num_tokens = len(encoding.encode(string)) return num_tokens
最后,我需要一些过滤函数将原始文档转换为可用的示例。文档中的大多数示例都位于代码围栏之间,因此我将从每个文件中提取所有 YAML 代码:
import re def extract_yaml(text: str) -> str: """Returns list with all the YAML code blocks found in text.""" 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"Extracting YAML from Markdown files in {repo_path}") 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"Creating embeddings and uploading vectors to database") 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']] # clean metadata before upserting 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 # Compute embeddings for string "Docker Container" >>> embeddings = openai.Embedding.create(input="Docker Containers", engine="text-embedding-ada-002") # Connect to database >>> 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) # Query database >>> 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': []}
正如您所看到的,第一个匹配是用于提取 Docker 映像并运行它的信号量管道的 YAML。这是一个好的开始,因为它与我们的“Docker Containers”搜索字符串相关。
我们有数据,并且知道如何查询它。让我们将其应用到机器人中。
处理提示的步骤是:
像往常一样,我将首先在机器人的主脚本complete.py
中定义一些常量:
# complete.py # Pinecone database name, number of matched to retrieve # cutoff similarity score, and how much tokens as context index_name = 'semaphore' context_cap_per_query = 30 match_min_score = 0.75 context_tokens_per_query = 3000 # OpenAI LLM model parameters 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 # Connect with Pinecone db and index 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: """Returns the number of tokens in a text string.""" encoding = tiktoken.get_encoding(encoding_model_strings) num_tokens = len(encoding.encode(string)) return num_tokens def num_tokens_from_messages(messages): """Returns the number of tokens used by a list of messages. Compatible with model """ 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 # every message follows {role/name}\n{content}\n for key, value in message.items(): num_tokens += len(encoding.encode(value)) if key == "name": # if there's a name, the role is omitted num_tokens += -1 # role is always required and always 1 token num_tokens += 2 # every reply is primed with assistant return num_tokens
以下函数采用原始提示和上下文字符串来返回 GPT-3 的丰富提示:
def get_prompt(query: str, context: str) -> str: """Return the prompt with query and context.""" return ( f"Create the continuous integration pipeline YAML code to fullfil the requested task.\n" + f"Below you will find some context that may help. Ignore it if it seems irrelevant.\n\n" + f"Context:\n{context}" + f"\n\nTask: {query}\n\nYAML Code:" )
get_message
函数以与 API 兼容的格式格式化提示:
def get_message(role: str, content: str) -> dict: """Generate a message for OpenAI API completion.""" return {"role": role, "content": content}
影响模型反应方式的角色分为三类:
现在是引人入胜的部分。 get_context
函数接受提示、查询数据库并生成上下文字符串,直到满足以下条件之一:
context_tokens_per_query
,即我为上下文保留的空间。match_min_score
匹配将被忽略。 import openai def get_context(query: str, max_tokens: int) -> list: """Generate message for OpenAI model. Add context until hitting `context_token_limit` limit. Returns prompt string.""" embeddings = openai.Embedding.create( input=[query], engine=embed_model ) # search the database vectors = embeddings['data'][0]['embedding'] embeddings = index.query(vectors, top_k=context_cap_per_query, include_metadata=True) matches = embeddings['matches'] # filter and aggregate context usable_context = "" context_count = 0 for i in range(0, len(matches)): source = matches[i]['metadata']['source'] if matches[i]['score'] < match_min_score: # skip context with low similarity 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"Found {context_count} contexts for your query") return usable_context
下一个也是最后一个函数complete
向 OpenAI 发出 API 请求并返回模型的响应。
def complete(messages): """Query the OpenAI model. Returns the first answer. """ 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) # initialize messages list to send to 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('Model token size limit reached') print("Working on your query... ") answer = complete(messages) print("Answer:\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 请求重新发送先前对模型的响应来维持对话。之前的 GPT-3 答案会以“助理”角色发回。例如:
messages = [] while True: query = input('Type your prompt:\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', '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('Model token size limit reached') print("Working on your query... ") answer = complete(messages) print("Answer:\n") print(answer) # remove system message and append model's answer messages.pop() messages.append(get_message('assistant', answer))
不幸的是,这个实现相当初级。它不支持扩展对话,因为令牌计数随着每次交互而增加。很快,我们将达到 GPT-3 的 4096 个代币限制,从而阻止进一步的对话。
因此,我们必须找到某种方法将请求保持在令牌限制内。以下是一些策略:
通过词嵌入和良好的上下文数据库可以增强机器人的响应。为了实现这一目标,我们需要高质量的文档。开发一个看似掌握了主题的机器人需要进行大量的试验和错误。
我希望对词嵌入和大型语言模型的深入探索可以帮助您构建更强大的机器人,根据您的需求进行定制。
快乐建设!
也发布在这里。