ChatGPT 可以帮助我们实现很多原本很难实现功能,为传统系统加入 AI 支持,从而提升用户体验。本文介绍了如何给在线 Markdown 文档系统添加 ChatGPT 问答支持,将静态文档改造为智能文档。原文: Build a ChatGPT Powered Markdown Documentation in No Time
今天,我们将学习如何构建一个通过 ChatGPT 来回答关于文档相关问题的系统,该系统将基于 OpenAI 和Embedbase构建。项目发布在 https://differentai.gumroad.com/l/chatgpt-documentation,也可以在https://docs.embedbase.xyz上尝试互动版。
概述
我们在这里主要讨论:
需要将内容存储在数据库中。
需要让用户输入查询。
在数据库中搜索与用户查询最相似的结果(稍后详细介绍)。
基于匹配查询的前 5 个最相似结果创建"上下文"并询问 ChatGPT:
根据下面的上下文回答问题,如果不能根据上下文回答问题,就说"我不知道"
上下文:
[上下文内容]
---
问题:
[问题内容]
回答:
实现细节
好,我们开始。
以下是实现本系统需要的前提条件。
在.env
中填好 Embedbase 和 OpenAI API key。
OPENAI_API_KEY="<YOUR KEY>"
EMBEDBASE_API_KEY="<YOUR KEY>"
复制代码
提醒一下,我们将基于了不起的文档框架 Nextra 创建由 ChatGPT 提供支持的 QA 文档,该框架允许我们使用 NextJS、tailwindcss 和 MDX(Markdown + React)编写文档。我们还将使用 Embedbase 作为数据库,并调用 OpenAI 的 ChatGPT。
创建 Nextra 文档
可以在Github上找到官方 Nextra 文档模板,用模板创建文档之后,可以用任何你喜欢的编辑器打开。
# we won't use "pnpm" here, rather the traditional "npm"
rm pnpm-lock.yaml
npm i
npm run dev
复制代码
现在请访问 https://localhost:3000。
尝试编辑.mdx
文档,看看内容有何变化。
准备并存储文件
第一步需要将文档存储在 Embedbase 中。不过有一点需要注意,如果我们在 DB 中存储相关联的较小的块,效果会更好,因此我们将把文档按句子分组。让我们从在文件夹scripts
中编写一个名为sync.js
的脚本开始。
你需要 glob 库来列出文件,用命令npm i glob@8.1.0
(我们将使用 8.1.0 版本)安装 glob 库。
const glob = require("glob");
const fs = require("fs");
const sync = async () => {
// 1. read all files under pages/* with .mdx extension
// for each file, read the content
const documents = glob.sync("pages/**/*.mdx").map((path) => ({
// we use as id /{pagename} which could be useful to
// provide links in the UI
id: path.replace("pages/", "/").replace("index.mdx", "").replace(".mdx", ""),
// content of the file
data: fs.readFileSync(path, "utf-8")
}));
// 2. here we split the documents in chunks, you can do it in many different ways, pick the one you prefer
// split documents into chunks of 100 lines
const chunks = [];
documents.forEach((document) => {
const lines = document.data.split("\n");
const chunkSize = 100;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize).join("\n");
chunks.push({
data: chunk
});
}
});
}
sync();
复制代码
现在我们构建好了存储在 DB 中的块,接下来扩展脚本,以便将块添加到 Embedbase。
要查询 Embedbase,需要执行npm i node-fetch@2.6.9
安装 2.6.9 版本的 node-fetch。
const fetch = require("node-fetch");
// your Embedbase api key
const apiKey = process.env.EMBEDBASE_API_KEY;
const sync = async () => {
// ...
// 3. we then insert the data in Embedbase
const response = await fetch("https://embedbase-hosted-usx5gpslaq-uc.a.run.app/v1/documentation", { // "documentation" is your dataset ID
method: "POST",
headers: {
"Authorization": "Bearer " + apiKey,
"Content-Type": "application/json"
},
body: JSON.stringify({
documents: chunks
})
});
const data = await response.json();
console.log(data);
}
sync();
复制代码
很好,现在可以运行了:
EMBEDBASE_API_KEY="<YOUR API KEY>" node scripts/sync.js
复制代码
如果运行良好,应该看到:
获取用户查询
接下来修改 Nextra 文档主题,将内置搜索栏替换为支持 ChatGPT 的搜索栏。
在theme.config.tsx
中添加一个Modal
组件,内容如下:
// update the imports
import { DocsThemeConfig, useTheme } from 'nextra-theme-docs'
const Modal = ({ children, open, onClose }) => {
const theme = useTheme();
if (!open) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 100,
}}
onClick={onClose}>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: theme.resolvedTheme === 'dark' ? '#1a1a1a' : 'white',
padding: 20,
borderRadius: 5,
width: '80%',
maxWidth: 700,
maxHeight: '80%',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
};
复制代码
现在创建搜索栏:
// update the imports
import React, { useState } from 'react'
// we create a Search component
const Search = () => {
const [open, setOpen] = useState(false);
const [question, setQuestion] = useState("");
// ...
// All the logic that we will see later
const answerQuestion = () => { }
// ...
return (
<>
<input
placeholder="Ask a question"
// We open the modal here
// to let the user ask a question
onClick={() => setOpen(true)}
type="text"
/>
<Modal open={open} onClose={() => setOpen(false)}>
<form onSubmit={answerQuestion} className="nx-flex nx-gap-3">
<input
placeholder="Ask a question"
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
<button type="submit">
Ask
</button>
</form>
</Modal>
</>
);
}
复制代码
最后,更新配置以设置新创建的搜索栏:
const config: DocsThemeConfig = {
logo: <span>My Project</span>,
project: {
link: 'https://github.com/shuding/nextra-docs-template',
},
chat: {
link: 'https://discord.com',
},
docsRepositoryBase: 'https://github.com/shuding/nextra-docs-template',
footer: {
text: 'Nextra Docs Template',
},
// add this to use our Search component
search: {
component: <Search />
}
}
复制代码
构建上下文
这里需要 OpenAI token 计数库tiktoken
,执行npm i @dqbd/tiktoken
安装。
接下来创建带上下文的 ChatGPT 提示词。创建文件pages/api/buildPrompt.ts
,代码如下:
// pages/api/buildPrompt.ts
import { get_encoding } from "@dqbd/tiktoken";
// Load the tokenizer which is designed to work with the embedding model
const enc = get_encoding('cl100k_base');
const apiKey = process.env.EMBEDBASE_API_KEY;
// this is how you search Embedbase with a string query
const search = async (query: string) => {
return fetch("https://embedbase-hosted-usx5gpslaq-uc.a.run.app/v1/documentation/search", {
method: "POST",
headers: {
Authorization: "Bearer " + apiKey,
"Content-Type": "application/json"
},
body: JSON.stringify({
query: query
})
}).then(response => response.json());
};
const createContext = async (question: string, maxLen = 1800) => {
// get the similar data to our query from the database
const searchResponse = await search(question);
let curLen = 0;
const returns = [];
// We want to add context to some limit of length (tokens)
// because usually LLM have limited input size
for (const similarity of searchResponse["similarities"]) {
const sentence = similarity["data"];
// count the tokens
const nTokens = enc.encode(sentence).length;
// a token is roughly 4 characters, to learn more
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
curLen += nTokens + 4;
if (curLen > maxLen) {
break;
}
returns.push(sentence);
}
// we join the entries we found with a separator to show it's different
return returns.join("\n\n###\n\n");
}
// this is the endpoint that returns an answer to the client
export default async function buildPrompt(req, res) {
const prompt = req.body.prompt;
const context = await createContext(prompt);
const newPrompt = `Answer the question based on the context below, and if the question can't be answered based on the context, say "I don't know"\n\nContext: ${context}\n\n---\n\nQuestion: ${prompt}\nAnswer:`;
res.status(200).json({ prompt: newPrompt });
}
复制代码
调用 ChatGPT
首先,在文件utils/OpenAIStream.ts
中添加一些用于对 OpenAI 进行流调用的函数,执行npm i eventsource-parser
安装 eventsource-parser。
import {
createParser,
ParsedEvent,
ReconnectInterval,
} from "eventsource-parser";
export interface OpenAIStreamPayload {
model: string;
// this is a list of messages to give ChatGPT
messages: { role: "user"; content: string }[];
stream: boolean;
}
export async function OpenAIStream(payload: OpenAIStreamPayload) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let counter = 0;
const res = await fetch("https://api.openai.com/v1/chat/completions", {
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OPENAI_API_KEY ?? ""}`,
},
method: "POST",
body: JSON.stringify(payload),
});
const stream = new ReadableStream({
async start(controller) {
// callback
function onParse(event: ParsedEvent | ReconnectInterval) {
if (event.type === "event") {
const data = event.data;
// https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
if (data === "[DONE]") {
controller.close();
return;
}
try {
const json = JSON.parse(data);
// get the text response from ChatGPT
const text = json.choices[0]?.delta?.content;
if (!text) return;
if (counter < 2 && (text.match(/\n/) || []).length) {
// this is a prefix character (i.e., "\n\n"), do nothing
return;
}
const queue = encoder.encode(text);
controller.enqueue(queue);
counter++;
} catch (e) {
// maybe parse error
controller.error(e);
}
}
}
// stream response (SSE) from OpenAI may be fragmented into multiple chunks
// this ensures we properly read chunks and invoke an event for each SSE event stream
const parser = createParser(onParse);
// https://web.dev/streams/#asynchronous-iteration
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
}
},
});
return stream;
}
复制代码
然后创建文件pages/api/qa.ts
,作为对 ChatGPT 进行流调用的端点。
// pages/api/qa.ts
import { OpenAIStream, OpenAIStreamPayload } from "../../utils/OpenAIStream";
export const config = {
// We are using Vercel edge function for this endpoint
runtime: "edge",
};
interface RequestPayload {
prompt: string;
}
const handler = async (req: Request, res: Response): Promise<Response> => {
const { prompt } = (await req.json()) as RequestPayload;
if (!prompt) {
return new Response("No prompt in the request", { status: 400 });
}
const payload: OpenAIStreamPayload = {
model: "gpt-3.5-turbo",
messages: [{ role: "user", content: prompt }],
stream: true,
};
const stream = await OpenAIStream(payload);
return new Response(stream);
};
export default handler;
复制代码
连接一切并提问
现在是时候通过 API 调用提问。编辑theme.config.tsx
,将该函数添加到Search
组件中:
// theme.config.tsx
const Search = () => {
const [open, setOpen] = useState(false);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState("");
const answerQuestion = async (e: any) => {
e.preventDefault();
setAnswer("");
// build the contextualized prompt
const promptResponse = await fetch("/api/buildPrompt", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: question,
}),
});
const promptData = await promptResponse.json();
// send it to ChatGPT
const response = await fetch("/api/qa", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: promptData.prompt,
}),
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = response.body;
if (!data) {
return;
}
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
// read the streaming ChatGPT answer
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
// update our interface with the answer
setAnswer((prev) => prev + chunkValue);
}
};
return (
<>
<input
placeholder="Ask a question"
onClick={() => setOpen(true)}
type="text"
/>
<Modal open={open} onClose={() => setOpen(false)}>
<form onSubmit={answerQuestion} className="nx-flex nx-gap-3">
<input
placeholder="Ask a question"
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
<button type="submit">
Ask
</button>
</form>
<p>
{answer}
</p>
</Modal>
</>
);
}
复制代码
你现在应该能看到:
当然,可以随意改进样式。
结论
总结一下,我们做了:
感谢阅读本文,Github上有一个创建此类文档的开源模板。
延伸阅读
嵌入(Embedding)是一种机器学习概念,允许我们将数据的语义数字化,从而创建以下功能:
语义搜索(例如,"牛吃草"和"猴子吃香蕉"之间有什么相似之处,也适用于比较图像等)
推荐系统(如果你喜欢电影《阿凡达》,可能也会喜欢《星球大战》)
分类("这部电影太棒了"是肯定句,"这部电影烂透了"是否定句)
生成式搜索(可以回答有关 PDF、网站、YouTube 视频等问题的聊天机器人)
Embedding 并不是一项新技术,但由于 OpenAI Embedding 端点的快速和廉价,最近变得更受欢迎、更通用、更容易使用。在网上有很多关于 Embedding 的信息,因此我们不会深入研究 Embedding 的技术主题。
AI embedding 可以被认为是哈利波特的分院帽。就像分院帽根据学生特质来分配学院一样,AI embedding 也是根据特征来分类相似内容。当我们想找到类似内容时,可以要求 AI 为我们提供内容的 embedding,计算它们之间的距离。embedding 之间的距离越近,内容就越相似。这个过程类似于分院帽如何利用每个学生的特征来确定最适合的学院。通过使用 AI embedding,我们可以根据内容特征快速、轻松的进行比较,从而做出更明智的决定和更有效的搜索结果。
上面描述的方法只是简单的嵌入单词,但如今已经可以嵌入句子、图像、句子+图像以及许多其他东西。
如果想在生产环境中使用 embedding,有一些陷阱需要小心:
在 GitHub Action 中持续准备数据
embedding 的意义在于能够索引任何类型的非结构化数据,我们希望每次修改文档时都能被索引,对吧?下面展示的是一个 GitHub Action,当主分支完成git push
时,将索引每个 markdown 文件:
# .github/workflows/index.yaml
name: Index documentation
on:
push:
branches:
- main
jobs:
index:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 14
- run: npm install
- run: node scripts/sync.js
env:
EMBEDBASE_API_KEY: ${{ secrets.EMBEDBASE_API_KEY }}
复制代码
别忘了把EMBEDBASE_API_KEY
添加到你的 GitHub 密钥里。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind
评论