如何基於文件的內容實現 AI 對話功能,以 Documate 為例

發表於2024-02-26

前言

在 ChatGPT 出現之時,社群內也出現過 把 React 官方文件投餵給它 ,然後對它進行提問的實踐。但是,由於每次 ChatGPT 對話能接受的文字內容對應的 Token 是有上限的,所以這種使用方式存在一定的手動操作成本和不能複用的問題。

Documate 的出現則是透過工具鏈的整合,僅需使用 CLI 提供的命令和部署服務端的程式碼,就可以很輕鬆地實現上述的資料投餵模型 + 提問 ChatGPT 過程的自動化,讓你的文件(VitePressDocusaurusDocsify)站點具備 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 給開發者呼叫:

基於文件內容的 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 提問和獲取答案。

相關文章