10分钟打造基于ChatGPT的Markdown智能文档

1,602 阅读5分钟

ChatGPT可以帮助我们实现很多原本很难实现功能,为传统系统加入AI支持,从而提升用户体验。本文介绍了如何给在线Markdown文档系统添加ChatGPT问答支持,将静态文档改造为智能文档。原文: Build a ChatGPT Powered Markdown Documentation in No Time

今天,我们将学习如何构建一个通过ChatGPT来回答关于文档相关问题的系统,该系统将基于OpenAI和Embedbase构建。项目发布在 differentai.gumroad.com/l/chatgpt-d…

概述

我们在这里主要讨论:

  1. 需要将内容存储在数据库中。
  2. 需要让用户输入查询。
  3. 在数据库中搜索与用户查询最相似的结果(稍后详细介绍)。
  4. 基于匹配查询的前5个最相似结果创建"上下文"并询问ChatGPT:

根据下面的上下文回答问题,如果不能根据上下文回答问题,就说"我不知道"

上下文:
[上下文内容]
---
问题:
[问题内容]
回答:

实现细节

好,我们开始。

以下是实现本系统需要的前提条件。

  • Embedbase API key: 一个可以找到"最相似结果"的数据库。并不是所有数据库都适合这种工作,我们将使用Embedbase,它可以做到这一点。Embedbase允许我们找到搜索查询和存储内容之间的"语义相似性"。
  • OpenAI API key: 这是ChatGPT部分。
  • Nextra: 并且安装好Node.js

.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>
		</>
	);
}

你现在应该能看到:

当然,可以随意改进样式。

结论

总结一下,我们做了:

  • 创建了Nextra文档
  • 在Embedbase中准备和存储文档
  • 构建了获取用户查询的接口
  • 在数据库中搜索需要查询ChatGPT的问题上下文
  • 使用此上下文构建提示并调用ChatGPT
  • 通过将所有内容联系起来,让用户提问

感谢阅读本文,Github上有一个创建此类文档的开源模板。

延伸阅读

嵌入(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