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 上嘗試互動版。

概述

我們在這裏主要討論:

  1. 需要將內容存儲在數據庫中。

  2. 需要讓用戶輸入查詢。

  3. 在數據庫中搜索與用戶查詢最相似的結果 (稍後詳細介紹)。

  4. 基於匹配查詢的前 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[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>
  </>
 );
}

你現在應該能看到:

當然,可以隨意改進樣式。

結論

總結一下,我們做了:

感謝閱讀本文,Github[8] 上有一個創建此類文檔的開源模板。

延伸閱讀

嵌入 (Embedding) 是一種機器學習概念,允許我們將數據的語義數字化,從而創建以下功能:

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

參考資料

[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