乐趣区

关于前端:如何基于文档的内容实现-AI-对话功能以-Documate-为例

前言

在 ChatGPT 呈现之时,社区内也呈现过 把 React 官网文档投喂给它,而后对它进行发问的实际。然而,因为每次 ChatGPT 对话能承受的文本内容对应的 Token 是有下限的,所以这种应用形式存在肯定的手动操作老本和不能复用的问题。

而 Documate 的呈现则是通过工具链的集成,仅需应用 CLI 提供的命令和部署服务端的代码,就能够很轻松地实现上述的数据投喂模型 + 发问 ChatGPT 过程的自动化,让你的文档(VitePress、Docusaurus、Docsify)站点具备 AI 对话性能。

Documate 的官网文档对如何应用它进行现有文档站点的接入介绍的很为详尽,并且其作者(月影)也专门写了【黑科技】让你的 VitePress 文档站反对 AI 对话能力文章介绍,对接入 Documate 感兴趣的同学能够自行浏览文档或文章。

置信很多同学和我一样,对 如何基于文档的内容实现 AI Chat 留有疑难,那么接下来,本文将围绕 Documate 的实现原理别离开展介绍:

  • Documate 运行机制
  • Documate 服务端

一、Documate 运行机制

Documate 次要由 2 局部形成,Documate CLI 和服务端(Backend)接口实现,其中 Documate CLI 主要职责是获取本地文档过程的文档和结构指定构造的文档数据,最终上传数据到 upload 接口,而服务端主要职责是提供 uploadask 接口,它们别离的作用:

  • upload 接口,接管来自 CLI 上传的文档数据,对数据进行 Token 化、分片入库等操作
  • ask 接口,接管来自文档站点的发问内容,校验内容合法性、生成内容的矢量坐标,基于所有文档进行矢量搜寻、进行 Chat 发问获取后果并返回等操作

整体实现机制如下图所示:

其中,对于 Documate CLI 次要反对了 initupload 等 2 个命令,init 负责向文档工程注入 Documate 运行的根底工程配置,upload 负责上传文档工程的文档内容到服务端,2 者的实现并不简单,有趣味的同学能够自行理解。

相比拟 CLI,在 Documate 的服务端实现的一系列能力是反对文档内容对话的关键技术点,那这些能力又是如何通过代码实现的?上面,咱们来别离从代码层面深刻意识下 Documate 服务端的各个能力的实现。

二、Documate 服务端

Documate 服务端次要负责接管并存储 documate upload 命令上传的文档内容、依据对话的发问内容返回与之关联的答复:

其中,后者须要应用 OpenAI 提供的 Text Embeddings 来实现 AI 对话的性能,所以,咱们先来对 OpenAI Text Embeddings 建设一个根底的认知。

2.1 OpenAI Text Embeddings

在 OpenAI 的开发者平台 提供了很多性能的 API 给开发者调用:

  • Text generation,生成文本和调用函数
  • Prompt engineering,Prompt 的最佳工程实际
  • Embeddings,搜寻、分类和比拟文本
  • Speech to text,语音转文本
  • Image generation,应用 DALL·E 生成或者操作图像
  • Fine-tuning,为利用定制模型
  • Text to speech,将文本转为真切的语音
  • Vision,应用 GPT-4 了解图像

基于文档内容的 AI 对话的实现实质是依据关键词搜寻失去答案,所以须要应用到 Embeddings,Embedding 次要用于掂量文本字符串之间的关联性,一个 Embedding 是由浮点数字形成的矢量数组,例如 [0.938293, 0.284951, 0.348264, 0.948276, 0.564720]。2 个矢量之间的间隔示意它们的关联性。间隔小示意它们之间的关联性高,反之关联性低。

2.2 文档内容存储

文档内容的存储次要分为 2 个步骤:

1、依据模型每次能承受的 Token 最大长度去对内容进行分片 chunks

OpenAI 的模型调用所能接管的 Token 的长度是无限的,对应的 text-embedding-ada-002 模型可接管的 Token 最大长度是 1536。所以,在接管到 CLI 上传的文档内容后,须要依据 Token 的最大长度 1536 来对文档内容进行分片:

const tokenizer = require('gpt-3-encoder');
// Split the page content into chunks base on the MAX_TOKEN_PER_CHUNK
function getContentChunks(content) {
  // GPT-2 and GPT-3 use byte pair encoding to turn text into a series of integers to feed into the model.
  const encoded = tokenizer.encode(content);
  const tokenChunks = encoded.reduce((acc, token) => (acc[acc.length - 1].length < MAX_TOKEN_PER_CHUNK
        ? acc[acc.length - 1].push(token)
        : acc.push([token]),
      acc
    ),
    [[]],
  );
  return tokenChunks.map(tokens => tokenizer.decode(tokens));
}

首先,会先应用 gpt-3-encoder 来对文档内容进行 Byte Pair Encoding,将文档从文本模式转成一系列数字,从而用于后续投喂(Feed)给模型。其中,BPE 算法 的实现:

  • 把文本内容拆分成一个个字符,计算字符呈现频率
  • 合并相邻反复呈现的字符和对应的呈现频率
  • 对最终拆分的字符编码成数字,也就是 Token 的值,而后结构字符到数字映射的一个词汇表
  • 依据词汇表将原有的文本内容转为对应的 Token 示意

因为 BPE 编码后的后果 encoded 是一个 Token 数组,且模型每次能投喂是有最大长度的限度,所以依据 Token 最大长度进行分片,也就是代码中的 accacc 初始值是一个二维数组,每个值是一个 Token,每个元素数组次要用于存储模型最大 Token 限度下的数据,行将一个大的 Token 分片成模型容许传入的小 Token。

对文档内容进行分片的目标是用于后续将文档内容投喂(Feed)给模型的时候是无效(不会超出 Token 最大长度)和间断的。

2、结构指定的数据结构 ChunkItem 存入数据库中,ChunkItem 数据结构

因为,将文档的所有内容全副投喂给模型是 有老本(Token 计费)并且收益低(问答内容关联性低),所以,须要在发问的环节通过矢量数据库查问的形式,查出关联的文档内容,而后再将对应的文档内容投喂给模型,模型依据对 关联 文档上下文和问题给出正当的答复。

那么,在后面依据 BPE 生成的 Token 和分片生成的根底上,须要将该后果按指定的数据结构(门路、题目等)存入数据库中,用于后续发问的时候查问矢量数据库:

const aircode = require('aircode');
const PagesTable = aircode.db.table('pages');

// 依据 BPE 和模型的 Token 下限限度去划分 chunk
const chunks = getContentChunks(content);
// 结构出存到数据库中的数据结构
const pagesToSave = chunks.map((chunk, index) => ({
  project,
  // 文档文件门路
  path,
  title,
  // 文件内容生成的 hash 值
  checksum,
  chunkIndex: index,
  // 内容
  content: chunk,
  embedding: null,
}))

// Save the result to database
for (let i = 0; i < pagesToSave.length; i += 100) {await PagesTable.save(pagesToSave.slice(i, i + 100));
}

这里会应用到 AirCode 提供的表操作的 PagesTable.save API,用于将结构好的数据入库。

2.3 依据发问进行 AI 对话

OpenAI 要求输出的内容是须要合乎它们规定的内容政策的,所以须要先对输出的问题进行内容查看,OpenAI 也提供相应的 API 用于查看内容平安,而 OpenAI 的 API 调用能够通过 OpenAI Node 来实现:

const OpenAI = require('openai');

// 创立 OpenAI 的实例
const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY,});

// 发问内容
const question = params.question.trim();
// https://platform.openai.com/docs/api-reference/moderations
const {results: moderationRes} = await openai.moderations.create({input: question,});
if (moderationRes[0].flagged) {console.log('The user input contains flagged content.', moderationRes[0].categories);
    context.status(403);
    return {
    error: 'Question input didn\'t meet the moderation criteria.',
    categories: moderationRes[0].categories,
    };
}

如果,返回的后果 moderationsRes[0].flagged 则视为不合乎,标识为谬误的申请。反之合乎,接着应用 Embeddings 来获取发问内容所对应的矢量坐标:

// https://platform.openai.com/docs/api-reference/embeddings/object
const {data: [ { embedding}] } = await openai.embeddings.create({
    model: 'text-embedding-ada-002',
    input: question.replace(/\n/g, ' '),
});

那么,有了矢量坐标后,咱们须要用先前存储到数据库的分片文本创立矢量数据库,这能够应用 Orama 实现,它提供了全文和矢量搜寻的能力。

首先,须要先从数据库中查问出所有的文档数据:

const {project = 'default'} = params;
const pages = await PagesTable
  .where({project})
  .projection({path: 1, title: 1, content: 1, embedding: 1, _id: 0})
  .find();

而后,通过 Orama 提供的 create 办法初始化一个矢量数据库 memDB,并且将文档内容 pages 插入到数据库中:

const memDB = await create({
  // 建设好索引的 `schema`
  schema: {
    path: 'string',
    title: 'string',
    content: 'string',
    embedding: 'vector[1536]',
  },
});
await insertMultiple(memDB, pages);

有了文档内容对应的矢量数据库后,咱们就能够用后面 Emdeedings 依据发问内容生成的矢量坐标进行搜寻,应用 Orama 提供的 searchVector 进行搜寻:

const {hits} = await searchVector(memDB, {
  vector: embedding,
  property: 'embedding',
  similarity: 0.8,  // Minimum similarity. Defaults to `0.8`
  limit: 10,        // Defaults to `10`
  offset: 0,        // Defaults to `0`
});

那么,为什么应用的是矢量搜寻而不是文本搜寻? 因为,矢量搜寻的作用是为了搜寻到和文本对应的矢量地位相近的内容,用于生成上下文本 GPT 整顿最终的答复。

其中 hits 的数据结构:

{
  count: 1,
  elapsed: {
    raw: 25000,
    formatted: '25ms',
  },
  hits: [
    {
      id: '1-19238',
      score: 0.812383129,
      document: {
        title: 'The Prestige',
        embedding: [0.938293, 0.284951, 0.348264, 0.948276, 0.564720],
      }
    }
  ]
}

因为,先前将文档内容依据 Embeddings 的 Token 最大长度分片进行存储,所以,这里须要将 hits 中的数据获取的内容组合起来:

let tokenCount = 0;
let contextSections = '';

for (let i = 0; i < hits.length; i += 1) {const { content} = hits[i].document;
  // 留神 encode,用于组合分片
  const encoded = tokenizer.encode(content);
  tokenCount += encoded.length;
  
  // 判断是否达到 token 下限
  if (tokenCount >= MAX_CONTEXT_TOKEN && contextSections !== '') {break;}

  contextSections += `${content.trim()}\n---\n`;
}

到这里,咱们曾经有了 问题和问题关联的内容,能够用它们结构一个 Prompt 用于后续 AI 对话应用:

const prompt = `You are a very kindly assistant who loves to help people. Given the following sections from documatation, answer the question using only that information, outputted in markdown format. If you are unsure and the answer is not explicitly written in the documentation, say "Sorry, I don't know how to help with that." Always trying to anwser in the spoken language of the questioner.

Context sections:
${contextSections}

Question:
${question}

Answer as markdown (including related code snippets if available):`

上面,咱们就能够调用 OpenAI 的 API 进行 AI 对话:

const messages = [{
  role: 'user',
  content: prompt,
}];

const response = await openai.chat.completions.create({
  messages,
  model: 'gpt-3.5-turbo',
  max_tokens: 512,
  temperature: 0.4,
  stream: true,
})

其中,response 是一个 OpenAI API 调用返回的自定义数据结构的 Streaming Responses,间接将 response 返回给 ask 接口申请方必定是不合理的(申请方只须要拿到答案)。那么,这里能够应用这里能够应用 Vercel 团队实现的 ai 提供的 OpenAIStream 函数来实现:

const {OpenAIStream} = require('ai');

const stream = OpenAIStream(response);
return stream;

OpenAIStream 会主动将 OpenAI Completions 返回的后果解析成能够失常读取的 Streaming Resonsese,如果应用的是 AirCode 则能够间接返回 stream,如果应用的是一般的 Node Server,能够进一步应用 ai 提供的 streamToResponse 函数来将 stream 转为 ServerResponse 对象:

const {OpenAIStream, streamToResponse} = require('ai');

const stream = OpenAIStream(response);
streamToResponse(stream);

结语

通过学习 Documate 外部的实现原理,咱们能够晓得了如何从理论的问题登程,联合应用 OpenAI API 提供的模型解决问题。在这个根底上,咱们也能够去做别的场景摸索,让 AI 成为当初或者未来咱们解决问题的一种技术手段或尝试,而不是仅仅局限于会应用 ChatGPT 发问和获取答案。

退出移动版