头图

原文参考我的公众号文章 # ChatGPT文档聊天来咯~

简单粗暴的搞了个 ChatGPT 文档聊天小应用,chatpdf 大家都知道吧,我这个是 chatcsv😂,先看两张图。

  • 选择文档问答

  • 回答可追溯且避免回答无关内容

背景介绍

若想让 ChatGPT 根据指定的知识来回答我们的问题可以以下方式实现:

  1. 直接 prompt 注入:把所有相关内容注入到 messages 的 system 角色中;
  2. 优化 prompt 注入:借助向量查询,只注入和问题接近的内容;
  3. Functions_call:开发若干接口函数,并自动触发函数调用,把函数结果返回 GPT 进行自然语言回答;

本文主要采用第 2 种方式,优点是可以将整个文档 Embedding,通过根据向量余弦相似性进行搜索,最后让 ChatGPT 根据相似内容进行自然语言的回答,避免回复无关内容。同时,也解决了 Token 限制问题,避免了不必要的 Token 浪费问题。

前置准备

之前发过一个用 python 简单实现的过程,但是我本人工作中不写 python,所以还是基于 Nodejs 搞一搞吧。结合 OpenAI 的 embedding 和 ChatCompletion 接口,实现对文档的 Embedding 和 问答操作。因此需要做好以下准备:

  • NodeJS 开发环境(node 服务部署在国外服务器才能调通 OpenAI 接口);
  • 获取 OpenAI key;
  • 了解 OpenAI Embedding 接口;
  • 了解 OpenAI ChatCompletion 接口;
  • 安装 vector-cosine-similarity node 包(向量相似性计算);

实现原理

  • 一图胜千言

由于我这里是浅用,基于 csv 文档进行 Embedding 操作,所以没有复杂的「文档分割」过程。串了流程,接下来就开始进入无聊的编码环节了......

构建知识库

1.文档上传

在 Nodejs 中可以使用koa-multer库方便的上传文档。直接搞一个上传中间件,在 router 中注入使用。

  • 文档上传中间件
const multer = require("koa-multer");
const path = require("path");
const fs = require("fs");

// 设置文件上传目录
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    console.log("req.body:", req.body);

    const subDir = req.body.dirname || "";
    const uploadDir = path.join(__dirname, "../", `data/doc/${subDir}`);
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }

    cb(null, uploadDir);
  },
  filename: function (req, file, cb) {
    const ext = path.extname(file.originalname);
    cb(null, Date.now() + "-" + Math.round(Math.random() * 1e9) + ext);
  },
});

// 创建 multer 实例
const singleUploader = multer({ storage: storage });

module.exports = singleUploader;
  • 路由中注入文档上传中间件
const singleUploader = require("../middleware/uploader");
const chatdocApi = require("./chatdoc");
const Router = require("koa-router");

const routers = new Router();

routers.post(
  "/chatdoc/embedding",
  singleUploader.single("file"),
  chatdocApi.embeddingDoc
);

// 另外两个接口:文档列表、文档问答
routers.get("/chatdoc/list", chatdocApi.listDoc);
routers.post("/chatdoc/query", chatdocApi.queryDoc);

2.文档分割

文档上传后,会返回文档在服务器上的地址,接下来就是根据这个地址读取文档内容进行文档分割。

当前只能上传 CSV 文件,所以基本上是上传问答类文档,所以不存在复杂的分割问题,直接按照行列处理就好。每一行内容「问题,答案」就是一个文档 chunk,后面会对所有的文档 chunk 依次进行 Embedding 操作。

文档内容类似如下:

问题,回答
快乐是什么?,快乐是一种积极的情绪和心理状态,通常与愉悦、满足和幸福感相关。它是一种主观的体验,因为每个人对快乐的定义和体验可能会有所不同。
/**加载CSV文档内容,返回可进行Embedding的数据 */
let inputData = fs.readFileSync(path, "utf8");

// 将csv数据解析成一个二维数组:[row[col,col,...],row[col,col,...],...]
let df = inputData.split("\n").map((row) => row.split(","));
let headerRow = df.shift(); //移除表头

console.log("行:", df.length);
console.log("列:", headerRow.length);

// 合并每一行的所有列的数据
df = df.map((row) => {
  let combinedColData = row.reduce((prev, curr, index) => {
    return `${prev} ${curr}`;
  }, "");
  return combinedColData.replaceAll("\r", "");
});

// 过滤空行
df = df.filter((row) => {
  row = row.replace(/\s+/g, "");
  return row.length > 0;
});

3.文档 Embedding

调用 OpenAI 的 Embedding 接口,对刚刚的文档 chunk 逐一进行嵌入操作,生成对应的向量数据。

async function embedding(data) {
  // 返回数据如下
  // return {
  //   chunks: [
  //     "怎么收费?都行啊!",
  //     "有什么活动?都有啊!",
  //   ],
  //   embeddings: [
  //     [-0.014690092, -0.020053348, -0.0010497594, -0.009163321,...], // 共1536项,对chunks[0]的embedding结果
  //     [-0.014690092, -0.020053348, -0.0010497594, -0.009163321,...],
  //   ],
  // };

  let chunks = [];
  let embeddings = [];
  for (let i = 0; i < data.length; i++) {
    const response = await openai.createEmbedding({
      model: "text-embedding-ada-002",
      input: data[i],
    });

    let embeddingArr = response.data.data[0].embedding; // 共1536项的向量数组

    // 文本段和它的多维向量一一对应存储
    chunks.push(data[i]);
    embeddings.push(embeddingArr);
  }

  return {
    chunks,
    embeddings,
  };
}

4.构建文档存储关系图

这里没有借助「向量数据库」,而是直接存储在服务器本地,所以需要构建一个文档和向量的关系图,方便后面搜索和知识库的扩展。初步设计一个简单的结构,在向量构建方面需要能够满足以下功能:

  • 初次创建文档向量库;
  • 向已有文档向量库中新增追加数据;
  • 覆盖已有文档向量库数据;

数据结构和文件结构大概如下:

  • 关系图 json

  • 数据存储文件结构

  • 某个文档的 chunk 和 embedding 的存储文件结构

查询知识库

1.对问题 Embedding

// 将用户问题embedding
const response = await openai.createEmbedding({
  model: "text-embedding-ada-002",
  input: question,
});
let questionEmbedding = response.data.data[0].embedding;

2.计算向量相似性

通过计算问题的向量和文档的向量的「向量余弦相似性」,可以得到与问题相关的文档内容 chunk。

const n = 3; //返回最相关的n条chunk内容
const similarities_limit = 0.8; //相似性临界值

// 获取文档存储的embedding数据
let docEmbeddingData = this.queryChunksAndEmbeddings(docName);
if (!docEmbeddingData) {
  return reject({ msg: `${docName}并未构建embedding` });
}

// 计算embedding cosine相似度
let similaryChunks = docEmbeddingData.map(({ chunk, embedding }, index) => {
  let similarities = cosineSimilarity(embedding, questionEmbedding);
  if (similarities >= similarities_limit) {
    return {
      index,
      chunk,
      similarities,
    };
  }
});

// 提取相似度最高的n条内容
similaryChunks = similaryChunks
  .sort((a, b) => b.similarities - a.similarities)
  .slice(0, n)
  .filter((item) => item);

3.GPT 问答

在有了相关内容后,就可以注入到 GPT 的问题中让 GPT 进行回复了。这里也可以顺便记录相关内容 chunk 属于哪个原始问答,方便用户对 GPT 的回答进行验证,这样使用起来更健壮!

// 构建要注入prompt的语料
let referenceContent = ""; //内容拼接
let referenceLinks = []; //内容索引
similaryChunks.map(({ chunk, index, similarities }) => {
  referenceContent += `${chunk}\n`;
  referenceLinks.push({ no: index, content: chunk, similarities });
});

const messages = [
  {
    role: "system",
    content: `你是一个AI问答机器人,请结合'''包裹的相关内容回答我的问题。\n'''${referenceContent}'''`,
  },
  { role: "user", content: `请问,${question}` },
];

// 向GPT提问
const completion = await openai.createChatCompletion({
  model: "gpt-3.5-turbo",
  temperature: 1, //生成文本的随机性程度。值越大,生成的文本越随机;值越小,生成的文本越保守,
  messages: messages,
});

// 得到GPT的最终回答
const answer = completion.data.choices[0].message.content;

核心代码

为了方便使用,将整个流程封装成了一个简单的类,并实现了以下关键方法:

  • create:文档上传 + Embedding 处理,暂时只实现了 csv 文档。(支持 「新增/追加/覆盖」 3 种模式)
  • query:基于文档问答聊天
  • list:返回所有可进行聊天的文档列表
const fs = require("fs");
const path = require("path");
const md5 = require("md5-node");
const { cosineSimilarity } = require("vector-cosine-similarity");

class llmVector {
  constructor(storagePath) {
    this.storagePath = storagePath || path.join(__dirname, "../", "data");
    this.indexPath = `${this.storagePath}/docvectorstore.json`; // 文档embedding关系图
    this.docChunkEmbeddingMap = {}; // 缓存要查询文档对应的embedding数据
  }

  /**
   * 创建(追加|覆盖)文档的embedding
   * @param {String} fileType 文档类型
   * @param {String} path 文档的路径
   * @param {String} name 文档名称
   * @param {String} mode 文档追加模式:append(已存在的时候进行“追加”) cover(已存在的时候进行“覆盖”)
   * @returns Promise
   */
  create(fileType, path, name, mode = "append") {
    let nameKey = md5(name); // 文档的key
    if (!fs.existsSync(this.indexPath)) {
      fs.writeFileSync(this.indexPath, "");
    }

    return new Promise(async (resolve, reject) => {
      if (!path) {
        return reject("文档路径不能为空");
      }
      if (!name) {
        return reject("文档名称不能为空");
      }

      // 文档embedding结果
      let embeddingRes = {
        chunks: [],
        embeddings: [],
      };

      /**加载文件内容,并进行embedding */
      switch (fileType) {
        case "csv":
          // 加载csv文件内容
          let csvData = this.loadCSV(path);
          // 开始对文件内容embedding
          embeddingRes = await this.embedding(csvData);
          break;
      }

      if (embeddingRes) {
        let chunksAndEmbeddingsData = {};

        let returnData = {
          msg: "",
          data: {},
        };

        // 存储到系统
        let dvsm = this.getJson(this.indexPath);
        if (dvsm[nameKey]) {
          if (mode == "append") {
            // 追加
            chunksAndEmbeddingsData = this.createChunksAndEmbeddings(
              nameKey,
              embeddingRes,
              dvsm[nameKey]["files_chunks_embeddings"].length
            );

            dvsm[nameKey]["files_chunks_embeddings"].push(
              chunksAndEmbeddingsData
            );
            returnData.msg = `向[${name}]追加了数据`;
          } else if (mode == "cover") {
            // 覆盖
            chunksAndEmbeddingsData = this.createChunksAndEmbeddings(
              nameKey,
              embeddingRes
            );

            dvsm[nameKey]["files_chunks_embeddings"] = [
              chunksAndEmbeddingsData,
            ];
            returnData.msg = `覆盖了[${name}]的数据`;
          } else {
            // 不处理
            return reject({
              msg: `${name} 已存在embedding数据`,
            });
          }
        } else {
          // 新增
          chunksAndEmbeddingsData = this.createChunksAndEmbeddings(
            nameKey,
            embeddingRes
          );

          dvsm[nameKey] = {
            id: nameKey,
            name: name,
            files_chunks_embeddings: [chunksAndEmbeddingsData],
          };

          returnData.msg = `创建了[${name}]的数据`;
        }

        // 保存docVecStoreMap
        this.setJson(this.indexPath, dvsm);

        return resolve(returnData);
      }

      return reject({ msg: `不支持的文件类型:${fileType}` });
    });
  }

  /**
   * 创建文档的chunks和embeddings文件
   * @param {String} key 文档名称md5后的key
   * @param {Object} data 文档embedding后的数据
   * @param {Number} existFilesLength 追加模式下,当前文档的序号
   * @returns
   */
  createChunksAndEmbeddings(key, data, existFilesLength = 0) {
    let chunksEmbeddingsSaveDir = ""; // 根据文档名创建的存储【chunks和embeddings】的文件夹
    let chunksEmbeddingsSavePath = ""; // 完整存储路径
    let fileIndex = existFilesLength ? existFilesLength + 1 : 1;

    // 创建数据存储文件夹
    chunksEmbeddingsSaveDir = `chunks_embeddings_${key}_${fileIndex}`;
    chunksEmbeddingsSavePath = `${this.storagePath}/${chunksEmbeddingsSaveDir}`;
    this.makeDir(chunksEmbeddingsSavePath);

    // 创建chunks.json
    let chunksFile = `${chunksEmbeddingsSavePath}/chunks.json`;
    this.makeFile(chunksFile, JSON.stringify(data.chunks));

    // 创建embedding.json
    let embeddingFile = `${chunksEmbeddingsSavePath}/embeddings.json`;
    this.makeFile(embeddingFile, JSON.stringify(data.embeddings));

    return {
      root: chunksEmbeddingsSavePath,
      chunks: chunksFile,
      embeddings: embeddingFile,
    };
  }

  /**向量查询 */
  query(docName = "", question = "", similarities_limit = 0.75, n = 3) {
    return new Promise(async (resolve, reject) => {
      // 获取文档存储的embedding数据
      let docEmbeddingData = this.queryChunksAndEmbeddings(docName);
      if (!docEmbeddingData) {
        return reject({ msg: `${docName}并未构建embedding` });
      }

      // 将用户问题embedding
      const response = await openai.createEmbedding({
        model: "text-embedding-ada-002",
        input: question,
      });
      let questionEmbedding = response.data.data[0].embedding;

      // 计算embedding cosine相似度
      let similaryChunks = docEmbeddingData.map(
        ({ chunk, embedding }, index) => {
          let similarities = cosineSimilarity(embedding, questionEmbedding);
          if (similarities >= similarities_limit) {
            return {
              index,
              chunk,
              similarities,
            };
          }
        }
      );

      // 提取相似度最高的n条内容
      similaryChunks = similaryChunks
        .sort((a, b) => b.similarities - a.similarities)
        .slice(0, n)
        .filter((item) => item);
      if (!similaryChunks.length) {
        console.log("无相关内容,无法回答");
        return resolve({
          question,
          answer: "没有在文档中找到有关内容",
          reference: [],
        });
      }

      // 构建要注入prompt的语料
      let referenceContent = ""; //内容拼接
      let referenceLinks = []; //内容索引
      similaryChunks.map(({ chunk, index, similarities }) => {
        referenceContent += `${chunk}\n`;
        referenceLinks.push({ no: index, content: chunk, similarities });
      });

      const messages = [
        {
          role: "system",
          content: `你是一个AI问答机器人,请结合'''包裹的相关内容回答我的问题。\n'''${referenceContent}'''`,
        },
        { role: "user", content: `请问,${question}` },
      ];

      // 提问
      const completion = await openai.createChatCompletion({
        model: "gpt-3.5-turbo",
        temperature: 1, //生成文本的随机性程度。值越大,生成的文本越随机;值越小,生成的文本越保守,
        messages: messages,
      });
      const answer = completion.data.choices[0].message.content;

      return resolve({
        question,
        answer,
        reference: referenceLinks,
      });
    });
  }

  /**
   * 根据文档名称,在「文档embedding关系图」查找embedding数据
   * - 数据缓存
   * @param {String} docName 文档名称
   * @returns [] || null
   */
  queryChunksAndEmbeddings(docName) {
    let nameKey = md5(docName);

    // 从缓存里取文档的embedding数据
    if (this.docChunkEmbeddingMap[nameKey]) {
      return this.docChunkEmbeddingMap[nameKey];
    }

    // 首次获取文档的embedding数据:读取embedding数据入口
    let dvsm = this.getJson(this.indexPath);
    if (!dvsm[nameKey]) {
      return null;
    }

    let data = [];
    const { files_chunks_embeddings } = dvsm[nameKey];
    files_chunks_embeddings.map(({ chunks, embeddings }) => {
      let theChunks = this.getJson(chunks);
      let theEmbeddings = this.getJson(embeddings);
      theChunks.map((chunk, index) => {
        data.push({
          chunk,
          embedding: theEmbeddings[index] || [],
        });
      });
    });

    this.docChunkEmbeddingMap[nameKey] = data;

    return data;
  }

  /**
   * 获取所有已经进行embedding的文档
   * @returns
   */
  list() {
    let dvsm = this.getJson(this.indexPath);
    let data = [];
    for (let key in dvsm) {
      data.push({
        id: dvsm[key].id,
        name: dvsm[key].name,
      });
    }

    return data;
  }

  /**加载CSV文档内容 */
  loadCSV(path) {
    let inputData = fs.readFileSync(path, "utf8");

    // 将csv数据解析成一个二维数组:[row[col,col,...],row[col,col,...],...]
    let df = inputData.split("\n").map((row) => row.split(","));
    let headerRow = df.shift(); //移除表头

    console.log("行:", df.length);
    console.log("列:", headerRow.length);

    // 合并每一行的所有列的数据
    df = df.map((row) => {
      let combinedColData = row.reduce((prev, curr, index) => {
        return `${prev} ${curr}`;
      }, "");
      return combinedColData.replaceAll("\r", "");
    });

    // 过滤空行
    df = df.filter((row) => {
      row = row.replace(/\s+/g, "");
      return row.length > 0;
    });

    return df;
  }

  /**使用openai API获取embedding */
  async embedding(data) {
    let chunks = [];
    let embeddings = [];
    for (let i = 0; i < data.length; i++) {
      const response = await openai.createEmbedding({
        model: "text-embedding-ada-002",
        input: data[i],
      });

      let embeddingArr = response.data.data[0].embedding;
      console.log("embedding结果: ", i, embeddingArr.length);

      chunks.push(data[i]);
      embeddings.push(embeddingArr);
    }

    return {
      chunks,
      embeddings,
    };
  }

  getJson(path) {
    let data = fs.readFileSync(path, "utf8") || "{}";
    let jsonData = JSON.parse(data);
    return jsonData;
  }

  setJson(path, data = {}) {
    let res = fs.writeFileSync(path, JSON.stringify(data), "utf8");
    return res;
  }

  makeDir(targetDirPath) {
    if (!targetDirPath) {
      return;
    }
    let fullPath = path.join(targetDirPath); //增加path操作,让传入的targetDirPath形式更健壮
    let dirLevelArr = fullPath.split("/");
    let dirLevelPath = "";
    dirLevelArr.map((dir) => {
      if (!dir) {
        return;
      }
      dirLevelPath += "/" + dir;
      if (!fs.existsSync(dirLevelPath)) {
        fs.mkdirSync(dirLevelPath);
      }
    });
  }

  makeFile(path, content) {
    fs.writeFileSync(path, content);
  }
}

module.exports = llmVector;

Believer
47 声望5 粉丝

无法忍受尘世间的丑 便看不到尘世间的美