10 分鐘打造基於 ChatGPT 的 Markdown 智能文檔
ChatGPT 可以幫助我們實現很多原本很難實現功能,爲傳統系統加入 AI 支持,從而提升用戶體驗。本文介紹瞭如何給在線 Markdown 文檔系統添加 ChatGPT 問答支持,將靜態文檔改造爲智能文檔。原文: Build a ChatGPT Powered Markdown Documentation in No Time[1]
今天,我們將學習如何構建一個通過 ChatGPT 來回答關於文檔相關問題的系統,該系統將基於 OpenAI 和 Embedbase[2] 構建。項目發佈在 https://differentai.gumroad.com/l/chatgpt-documentation,也可以在 https://docs.embedbase.xyz 上嘗試互動版。
概述
我們在這裏主要討論:
-
需要將內容存儲在數據庫中。
-
需要讓用戶輸入查詢。
-
在數據庫中搜索與用戶查詢最相似的結果 (稍後詳細介紹)。
-
基於匹配查詢的前 5 個最相似結果創建 "上下文" 並詢問 ChatGPT:
根據下面的上下文回答問題,如果不能根據上下文回答問題,就說 "我不知道"
上下文:
[上下文內容]問題:
[問題內容]
回答:
實現細節
好,我們開始。
以下是實現本系統需要的前提條件。
-
Embedbase API key[3]: 一個可以找到 "最相似結果" 的數據庫。並不是所有數據庫都適合這種工作,我們將使用 Embedbase,它可以做到這一點。Embedbase 允許我們找到搜索查詢和存儲內容之間的 "語義相似性"。
-
OpenAI API key[4]: 這是 ChatGPT 部分。
-
Nextra[5]: 並且安裝好 Node.js[6]
在.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[7] 上找到官方 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} class>
<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} class>
<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>
</>
);
}
你現在應該能看到:
當然,可以隨意改進樣式。
結論
總結一下,我們做了:
-
創建了 Nextra 文檔
-
在 Embedbase 中準備和存儲文檔
-
構建了獲取用戶查詢的接口
-
在數據庫中搜索需要查詢 ChatGPT 的問題上下文
-
使用此上下文構建提示並調用 ChatGPT
-
通過將所有內容聯繫起來,讓用戶提問
感謝閱讀本文,Github[8] 上有一個創建此類文檔的開源模板。
延伸閱讀
嵌入 (Embedding) 是一種機器學習概念,允許我們將數據的語義數字化,從而創建以下功能:
-
語義搜索 (例如,"牛喫草" 和 "猴子喫香蕉" 之間有什麼相似之處,也適用於比較圖像等)
-
推薦系統 (如果你喜歡電影《阿凡達》,可能也會喜歡《星球大戰》)
-
分類 ("這部電影太棒了" 是肯定句,"這部電影爛透了" 是否定句)
-
生成式搜索 (可以回答有關 PDF、網站、YouTube 視頻等問題的聊天機器人)
Embedding 並不是一項新技術,但由於 OpenAI Embedding 端點的快速和廉價,最近變得更受歡迎、更通用、更容易使用。在網上有很多關於 Embedding 的信息,因此我們不會深入研究 Embedding 的技術主題。
AI embedding 可以被認爲是哈利波特的分院帽。就像分院帽根據學生特質來分配學院一樣,AI embedding 也是根據特徵來分類相似內容。當我們想找到類似內容時,可以要求 AI 爲我們提供內容的 embedding,計算它們之間的距離。embedding 之間的距離越近,內容就越相似。這個過程類似於分院帽如何利用每個學生的特徵來確定最適合的學院。通過使用 AI embedding,我們可以根據內容特徵快速、輕鬆的進行比較,從而做出更明智的決定和更有效的搜索結果。
上面描述的方法只是簡單的嵌入單詞,但如今已經可以嵌入句子、圖像、句子 + 圖像以及許多其他東西。
如果想在生產環境中使用 embedding,有一些陷阱需要小心:
-
大規模存儲 embedding 的基礎設施
-
成本優化 (例如避免計算兩次數據)
-
用戶 embedding 的隔離 (不希望搜索功能顯示其他用戶的數據)
-
處理模型輸入的大小限制
-
與流行的應用基礎設施 (supabase, firebase,谷歌雲等) 集成
在 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
參考資料
[1]
Build a ChatGPT Powered Markdown Documentation in No Time: https://betterprogramming.pub/building-a-chatgpt-powered-markdown-documentation-in-no-time-50e308f9038e
[2]
Embedbase: https://github.com/different-ai/embedbase
[3]
Embedbase API key: https://embedbase.xyz
[4]
OpenAI API key: https://platform.openai.com/account/api-keys
[5]
Nextra: https://nextra.site
[6]
Node.js: https://nodejs.org/en/download
[7]
Nextra Docs Template: https://github.com/shuding/nextra-docs-template
[8]
Embedbase Nextra Docs Template: https://github.com/another-ai/embedbase-nextra-docs-template
·END·
作者:俞凡
來源:DeepNoMind
原文:https://betterprogramming.pub/building-a-chatgpt-powered-markdown-documentation-in-no-time-50e308f9038e
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/JGwOg5BT2rgfhrBO9JBNOA