使用向量嵌入来提供上下文
当ChatGPT接收到有关所使用技术的教育时,它成为了增长和学习的美好资源。
起初,我想让ChatGPT帮助我创建Hilla应用程序。但是,由于它是基于2021年的数据进行训练的,它给出了与现实不符的制造回复。
我还可以改进的另一个领域是,Hilla支持React和Lit用于前端。我需要确保回复考虑了相关的框架作为上下文。
以下是我的方法,用于构建一位助手,该助手使用最新的文档来提供与ChatGPT相关的回复。
像其他大型语言模型一样,ChatGPT的上下文大小是有限的,必须适合于您的问题、相关的背景事实和响应。例如,“get-3.5-turbo”最多只能有4096个令牌,大约相当于3000个单词。组合提示中最有用的文档部分对于引出有意义的回复至关重要。
嵌入是一种实用的方法,用于识别这些关键文档部分。嵌入是一种将文本的意义编码为在多维空间中代表位置的向量的技术。具有类似含义的文本彼此靠近,而具有不同含义的文本则相距较远。
这个想法与颜色选择器类似。包含红色、绿色和蓝色值的三元向量可用于表示每种颜色。具有相似值的颜色具有相似的值,而具有不同值的颜色具有独特的值。
本文无需了解OpenAI API如何将文本转换为嵌入。如果您想了解嵌入的功能,可以从这篇文章开始。
使用生成的嵌入后,您可以通过查找与查询最相关的部分来快速发现提示中包含的最相关部分。
以下是ChatGPT在回答问题时使用文档作为上下文所需的高级过程:
为文档创建嵌入
1.将文档分成较小的块,例如按标题分块,然后为每个块生成嵌入(向量)。
2.在向量数据库中保存嵌入、源文本和其他元数据。
-
为用户查询创建嵌入。
-
使用嵌入,搜索最相关的N个文档部分。
-
创建提示,告诉ChatGPT只使用可用的文档来回答给定的查询。
-
使用OpenAI API生成提示的完成。
在接下来的部分中,我将详细介绍我如何执行这些过程。
使用的工具
下面我将简要介绍代码中最重要的部分。您可以在GitHube上找到源代码。
文档处理
Hilla文档以Asciidoc编写。将它们转换为嵌入需要以下过程:
使用Asciidoctor处理Asciidoc文件以包括代码片段和其他插入项。
基于HTML文档结构,将生成的文档分成章节。
将材料转换为纯文本以节省标记。
如果需要,将部分分成更小的部分。
为每个文本块创建嵌入向量。
使用Pinecone保存嵌入向量和源文本。
ASCIIdoc的处理
async function processAdoc(file, path) {
console.log(`Processing ${path}`);
const frontMatterRegex = /^---[\s\S]+?---\n*/;
const namespace = path.includes('articles/react') ? 'react' : path.includes('articles/lit') ? 'lit' : '';
if (!namespace) return;
// Remove front matter. The JS version of asciidoctor doesn't support removing it.
const noFrontMatter = file.replace(frontMatterRegex, '');
// Run through asciidoctor to get includes
const html = asciidoctor.convert(noFrontMatter, {
attributes: {
root: process.env.DOCS_ROOT,
articles: process.env.DOCS_ARTICLES,
react: namespace === 'react',
lit: namespace === 'lit'
},
safe: 'unsafe',
base_dir: process.env.DOCS_ARTICLES
});
// Extract sections
const dom = new JSDOM(html);
const sections = dom.window.document.querySelectorAll('.sect1');
// Convert section html to plain text to save on tokens
const plainText = Array.from(sections).map(section => convert(section.innerHTML));
// Split section content further if needed, filter out short blocks
const docs = await splitter.createDocuments(plainText);
const blocks = docs.map(doc => doc.pageContent)
.filter(block => block.length > 200);
await createAndSaveEmbeddings(blocks, path, namespace);
}
创建嵌入并保存它们
async function createAndSaveEmbeddings(blocks, path, namespace) {
// OpenAI suggests removing newlines for better performance when creating embeddings.
// Don't remove them from the source.
const withoutNewlines = blocks.map(block => block.replace(/\n/g, ' '));
const embeddings = await getEmbeddings(withoutNewlines);
const vectors = embeddings.map((embedding, i) => ({
id: nanoid(),
values: embedding,
metadata: {
path: path,
text: blocks[i]
}
}));
await pinecone.upsert({
upsertRequest: {
vectors,
namespace
}
});
}
从OpenAI获取嵌入
export async function getEmbeddings(texts) {
const response = await openai.createEmbedding({
model: 'text-embedding-ada-002',
input: texts
});
return response.data.data.map((item) => item.embedding);
}
搜索上下文
到目前为止,我们将文档分成了易于管理的块,并将它们放入向量数据库中。当用户提出问题时,我们必须执行以下操作:
根据所问的查询创建嵌入。
在向量数据库中查找最相关的10个文档部分。
创建一个问题,其中包含尽可能多的文档部分,用于1536个标记,留下2560用于响应。
async function getMessagesWithContext(messages: ChatCompletionRequestMessage[], frontend: string) {
// Ensure that there are only messages from the user and assistant, trim input
const historyMessages = sanitizeMessages(messages);
// Send all messages to OpenAI for moderation.
// Throws exception if flagged -> should be handled properly in a real app.
await moderate(historyMessages);
// Extract the last user message to get the question
const [userMessage] = historyMessages.filter(({role}) => role === ChatCompletionRequestMessageRoleEnum.User).slice(-1)
// Create an embedding for the user's question
const embedding = await createEmbedding(userMessage.content);
// Find the most similar documents to the user's question
const docSections = await findSimilarDocuments(embedding, 10, frontend);
// Get at most 1536 tokens of documentation as context
const contextString = await getContextString(docSections, 1536);
// The messages that set up the context for the question
const initMessages: ChatCompletionRequestMessage[] = [
{
role: ChatCompletionRequestMessageRoleEnum.System,
content: codeBlock`
${oneLine`
You are Hilla AI. You love to help developers!
Answer the user's question given the following
information from the Hilla documentation.
`}
`
},
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: codeBlock`
Here is the Hilla documentation:
"""
${contextString}
"""
`
},
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: codeBlock`
${oneLine`
Answer all future questions using only the above
documentation and your knowledge of the
${frontend === 'react' ? 'React' : 'Lit'} library
`}
${oneLine`
You must also follow the below rules when answering:
`}
${oneLine`
- Do not make up answers that are not provided
in the documentation
`}
${oneLine`
- If you are unsure and the answer is not explicitly
written in the documentation context, say
"Sorry, I don't know how to help with that"
`}
${oneLine`
- Prefer splitting your response into
multiple paragraphs
`}
${oneLine`
- Output as markdown
`}
${oneLine`
- Always include code snippets if available
`}
`
}
];
// Cap the messages to fit the max token count, removing earlier messages if necessary
return capMessages(
initMessages,
historyMessages
);
}
当用户提问时,我们可以使用getMessagesWithContext()
来检索必须发送到ChatGPT的消息。然后使用OpenAI API获得完成并将响应提供给客户端。
export default async function handler(req: NextRequest) {
// All the non-system messages up until now along with
// the framework we should use for the context.
const {messages, frontend} = (await req.json()) as {
messages: ChatCompletionRequestMessage[],
frontend: string
};
const completionMessages = await getMessagesWithContext(messages, frontend);
const stream = await streamChatCompletion(completionMessages, MAX_RESPONSE_TOKENS);
return new Response(stream);
}