原文参考我的公众号文章 # ChatGPT文档聊天来咯~
简单粗暴的搞了个 ChatGPT 文档聊天小应用,chatpdf 大家都知道吧,我这个是 chatcsv😂,先看两张图。
- 选择文档问答
- 回答可追溯且避免回答无关内容
背景介绍
若想让 ChatGPT 根据指定的知识来回答我们的问题可以以下方式实现:
- 直接 prompt 注入:把所有相关内容注入到 messages 的 system 角色中;
- 优化 prompt 注入:借助向量查询,只注入和问题接近的内容;
- 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;
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。