用JavaScript構(gòu)建開源索引管道:揭秘檢索增強(qiáng)生成(RAG)
本文是系列文章中的一篇,探讨如何在JavaScript中实现检索增强生成(RAG)¹,并使用Meta-Llama-3.1–8B、Mistral-7B等Hugging Face开源模型。这些模型来自Hugging Face,还有更多开源模型可供选择。
<sup>¹ RAG:检索增强生成</sup>
本文将讨论以下关键概念等:
- 索引管道是啥?
- 用LangChain拆分文本
- 用实际例子理解嵌入
- 用Node-Llama-CPP生成嵌入
- 用Supabase向量数据库
RAG 在 JavaScript 中的系列文章:
- 如何搭建一个开源索引管道
- 实现生成管道功能
- 开发一个用户友好的React界面来上传文档和查询
- 优化并评估RAG中的检索效果
这个教程需要你具备哪些知识
- Node.js 的基础知识
- SQL 的基础知识
在读完本系列文章之后,您将彻底理解下面这幅图所示的过程。
索引流程是什么?
索引流程是一个整理信息的过程,以便日后能够快速轻松地查找。就像一个图书馆。
- 收集信息:索引管道就像图书馆员那样收集书籍,收集各种文档或数据(比如公司手册、文章等)。
- 拆分文档:管道接着将这些文档拆分成更小的部分,称为块。这就像把一本大书分成章节或部分,使得查找特定信息更容易。
- 生成表示:对于每个块,管道会生成一种称为嵌入的表示。这就像为块制作一个摘要,捕捉其主要思想。这些嵌入帮助计算机更好地理解这些内容。
- 存储信息:最后,这些块及其嵌入会被存储在一个数据库中。这就像把书放到图书馆的正确位置,以便以后可以快速找到它们。
当有人提问时,系统可以快速查找这些组织好的信息块(chunks),找到最相关的信息片段。
LangChain文本切分如何将大型文档拆分成较小的部分,并为每一部分创建向量表示,以便为AI进行准备?我们将使用诸如LangChain和向量数据库之类的工具来完成这些任务。
如果你有一个非常大的文档,比如公司手册,你最好先将其拆分成更小的部分,然后再创建嵌入。例如,想象一个内部员工门户,帮助员工寻找有关人力资源程序、福利和工作场所指南的信息。应用程序可以将手册拆分成更小的块,这样可以让应用程序在员工提问时找到相关部分。
比如说:
我们将使用这段示例文本并将其保存为名为 handbook.txt 的文件:
公司手册
1. 欢迎词
欢迎加入[公司名称]!这份手册旨在为员工提供有关公司政策、福利和程序的重要信息。它将帮助您了解工作场所的期望,并充分利用公司提供的资源。
2. 工作场所指南
2.1 出勤和准时
员工应按规定时间出勤并准备好工作。经常迟到或旷工将导致纪律处分。
2.2 行为准则
所有员工必须遵守专业、尊重和诚信的最高标准。任何形式的歧视、骚扰或其他不当行为将不被容忍。
2.3 着装要求
我们的着装要求是商务休闲装,但周五是休闲日,可休闲着装。着装应整洁并适合专业环境。
3. 流程
3.1 请假申请
员工必须至少提前两周通过公司的HR门户提交请假申请。批准根据公司的人员需求和员工资历。
手册里包含了各种程序和步骤、福利政策以及工作场所指南。
- Chunk 1 : 安全规程 → 为该部分嵌入
- Chunk 2 : 健康益处 → 为该部分嵌入
- Chunk 3 : 假期政策 → 为该部分嵌入
- Chunk 4 : 工作场所安全指南 → 为该部分嵌入
- Chunk 5 : 请假政策 → 为该部分嵌入
- …
- Chunk n : 其他工作场所指南 → 为每个部分嵌入
在本地运行代码前,请先创建并配置一个 Node.js 项目,然后安装所需的依赖项。
# 创建一个名为 rag 的目录
mkdir rag
切换到 rag 目录
# 初始化 Node.js 项目
npm init -y
# 安装必要的依赖项
npm install @supabase/supabase-js@^2.45.4 dotenv@^16.4.5 langchain@^0.3.2 node-llama-cpp@^3.0.3
这里是如何使用(RecursiveCharacterTextSplitter
)将文档分成小块。
// 从LangChain导入所需的类
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
// 下面定义一个函数来拆分文档成更小的段
const splitDocument = async (pathToDocument) => {
// 读取文档内容(假设它是文本文件)
const text = (await fs.readFile(pathToDocument)).toString();
// 创建一个带有指定段大小和重叠的文本分割器
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 250, // 每个段将有250个字符
chunkOverlap: 40 // 每个段的最后40个字符会与下一个段的开头重叠
});
// 将文本拆分成段
const output = await splitter.createDocuments([text]);
// 返回每个段的内容(pageContent包含实际文本)
return output.map(document => document.pageContent);
};
// 示例用法:拆分名为 'handbook.txt' 的文档
const handbookChunks = await splitDocument('handbook.txt');
// handbookChunks数组现在将包含每个文本段
根据需要调整 chunkSize 和 chunkOverlap 的值,如果你预计问题是短的(比如简短的问答),最好数据库中存储较短的块;反之,如果你预计问题是长的,最好数据库中存储较长的块。
几种不同的文本拆分工具有多种方式可以分割文本,LangChain 提供了多种分割器,以适应您的需求:
- RecursiveCharacterTextSplitter :在分割文本时确保片段不会破坏有意义的句子或单词。适用于结构化的文本文档。
- CharacterTextSplitter :根据字符数量分割文本,虽然简单但可能会在重要句子的不恰当位置上进行分割。
- TokenTextSplitter :基于词元而不是字符进行分割,当你更关心单词数量而不是单独字符时,这会很有用。
- MarkdownTextSplitter :专门用于分割Markdown格式的文本,保留其结构。
- LatexTextSplitter :专门用于分割LaTeX文档,确保数学表达式和格式正确无误地保留。
比如说,如果你要分割一个 Markdown 文档,你可以用 MarkdownTextSplitter
以保持格式不变。
想想你在组织一个大型家庭聚会,想找到兴趣相投的人。每个家庭成员有不同的爱好,比如园艺、烹饪、徒步等。你不想通过精确比较每个人的爱好(比如“园艺”)来找到类似的人群,而是根据他们爱好的类型(比如户外活动、创意工作等)来分组。
这就是人工智能世界中嵌入技术的作用。
在这篇文章中,我们将使用“词向量”这个词。最简单的向量解释是,它就像一支箭,指向一个方向并具有一定的长度。
在计算机和人工智能中,向量就是一个数字列表,这些数字代表某种东西(比如一个词或一段信息)。向量的方向和长度告诉我们这些事物有多相似或多不同。
真实生活案例:家庭成员的兴趣
例如我们有三个人,他们的爱好如下:
- Alice 喜欢园艺。
- Bob 喜欢徒步。
- Charlie 喜欢烹饪。
我们可以把每种爱好看作是更大类别中的一部分,例如:
- 园艺和徒步旅行都是户外活动,
- 烹饪是一种有创意的活动。
我们不是根据人们的具体用词来比较,而是根据他们的爱好的含义或种类。
嵌入技术的工作原理
当我们为文本创建词嵌入时,我们将单词转化为数字(向量),以表示文本中的含义,这就像根据家庭成员的兴趣爱好来对他们进行分组。
比如说:
- 爱丽丝的园艺向量比如
[0.8, 0.1]
,鲍勃的徒步向量可能表示为[0.7, 0.2]
,查理的烹饪向量可能是这样的[0.1, 0.9]
每个向量中的数字代表一个“方面”的含义。简单来说,比如说:
- 矢量中的第一个数字可以反映这项活动与户外活动的关联性。
- 第二个数字可以反映这项活动与创意性活动的关联性。
爱好们的向量表示如下:
- 园艺(爱丽丝): 【0.8,0.1】(主要是户外活动,有点创意)
- 远足(鲍勃): 【0.7,0.2】(主要是户外)
- 烹饪(查理): 【0.1,0.9】(主要是创造性的)
尽管园艺和徒步是不同的活动,但它们的特性彼此接近,因为它们都属于户外活动。相比之下,烹饪的特性却非常不同,因为它主要是创造性的活动。
用词向量进行匹配
现在,你想找一个像Bob这样的人,他喜欢远足。你会拿Bob的向量[0.7, 0.2]
这个向量,比如和Alice和Charlie的向量进行比较。
鲍勃和爱丽丝的向量(徒步对比园艺)的余弦相似度
相似度计算如下: (0.7 * 0.8) + (0.2 * 0.1) = 0.56, 0.02 加起来等于 0.58
相似度得分为0.58,这表示Alice和Bob比较相似,他们都爱户外活动。
我们来看看鲍勃的情况与查理的对比(徒步 vs. 烹饪)。
相似性 = (0.7 * 0.1) + (0.2 * 0.9) = 0.07 + 0.18, 结果是 0.25
相似度得分只有0.25,这意味着鲍勃和查理非常不相似,因为他们各自的爱好不同。
这就是AI中嵌入技术的工作方式。嵌入不是逐字比较文本,而是根据文本的深层含义,据此给出相似度评分。
例如:AI 中的短数组(Short Array)嵌入
比如说,我们有以下这些文本片段,我们希望将它们与查询进行比较,使用嵌入式表示。
- 查询:未批准的开支
- 文本A:未批准的开支将不会报销。
- 文本B:未经授权的购买将不会报销。
- 文本C:鼓励员工提交出差相关的报告。
如果我们创建这些文本的词嵌入,它们可能看起来像下面这样:
- 查询: [0.85, 0.05]
- 文本A(费用): [0.8, 0.1]
- 文本B(未经授权的购买行为): [0.75, 0.15]
- 文本C(旅行记录): [0.3, 0.7]
当我们比较查询与存储的向量时,会发生以下情况。
查询和A(两者都有关支出)的余弦相似度:
相似度计算如下所示:0.85 × 0.8 + 0.05 × 0.1 = 0.68 + 0.005 = 0.685
(开销 vs. 未经授权的消费),查询与B的相似之处
相似度 = 0.85 乘以 0.75 + 0.05 乘以 0.15 = 0.6375 + 0.0075 = 0.645
查询与C之间的相似性(费用与旅行的比较):
相似性 = 0.85 * 0.3 + 0.05 * 0.7,即 0.255 + 0.035 = 0.29
因为文本A的相似度得分最高(0.685),系统会将文本A视为查询最合适的。文本B也挺相关的(0.645),但文本C(0.29)的相关度低得多,因为它涉及旅行。
创建嵌入一旦你把文档分成了若干部分,下一步就是为每一部分创建嵌入。嵌入能够捕捉文本的意义,并将其转化为数值,以便AI可以处理这些数值。
你可以从这里下载模型:这里。
下面是一个例子,使用Llama模型(LLaMA模型)来创建词嵌入:
// 导入Llama模型加载模块
import { getLlama } from "node-llama-cpp";
import path from "path";
// 加载Llama模型
const llama = await getLlama();
// 指定模型文件的路径
const model = await llama.loadModel({
modelPath: path.join("./models", "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf") // 确保您已经将模型文件下载到本地文件夹
});
// 创建生成嵌入的上下文环境
const context = await model.createEmbeddingContext();
// 用于为每个文档片段创建嵌入的函数(embedDocuments)
const embedDocuments = async (documents) => {
const embeddings = [];
// 对每个文档片段,创建其嵌入向量
await Promise.all(
documents.map(async (document) => {
const embedding = await context.getEmbeddingFor(document); // 获取文档的嵌入向量
embeddings.push({ content: document, embedding: embedding.vector }); // 存储片段及其嵌入向量
console.debug(`${embeddings.length}/${documents.length} 文档嵌入完成`); // 调试日志以显示进度
})
);
// 返回所有嵌入
return embeddings;
};
// 示例用法:为手册的所有部分创建嵌入
const documentEmbeddings = await embedDocuments(handbookChunks);
在这种情况下,embedDocuments
函数为手册的每个片段创建嵌入向量。
现在我们有了嵌入,我们需要一个地方来高效地存储和搜索它们。这时,向量数据库就发挥作用了。向量数据库存储嵌入(实际上是一串数字数组),并允许我们根据相似性搜索。
我们这样分一下。
- Chroma :一个开源的向量数据库,速度快且易于使用,非常适合进行嵌入中的快速搜索。
- Pinecone :一个专注于可扩展性的托管服务,当你需要处理大量嵌入时,它是一个好的选择。
- Supabase :虽然不是专门的向量数据库,Supabase 让你可以在关系数据库中存储嵌入,具有很大的灵活性。适合处理较小的项目,还可以用来存储和管理其他类型的数据。
我们在这个例子中会用到 Supabase。
去 supabase.com,然后点击“启动你的项目”。
你会看到登录页面。如果没有账户,可以点击“立即注册”。更简单的方法是使用你的GitHub账号,然后点击“使用GitHub登录”。
当你进入仪表盘时,点击“新建项目”按钮。首先,你会看到“创建新组织”的表单,然后,你会进入“创建新项目”的页面,你可以选择你喜欢的任何名称、密码和地区。
接下来,从侧边栏选择“数据库”,如图所示。
请同时也启用向量扩展。在数据库的扩展部分导航,找到向量并启用该扩展,就像下面演示的那样,如下图所示。
接下来,进入项目设置中的 API 部分,复制项目 URL 和 API 密钥:
在rag文件夹内创建一个.env文件,并将相应的值粘贴进去。
# .env 文件
SUPABASE_URL=请填写你的 Supabase URL
SUPABASE_API_KEY=请填写你的 Supabase API 密钥
我们现在来给代码添加数据库连接部分。
// 导入 dotenv 来加载环境变量并初始化 Supabase 客户端
import 'dotenv/config';
import { createClient } from '@supabase/supabase-js';
// 通过 .env 文件中的 URL 和 API 密钥来创建 Supabase 客户端
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_API_KEY);
进入 SQL 编辑器,并添加以下的 CREATE TABLE 语句:
CREATE TABLE handbook_docs (
id bigserial primary key, -- 自增ID
content text, -- 文本块的内容
embedding vector(4096) -- 4096维的向量嵌入
)
接下来,我们将在脚本中加入一个插入调用,把我们的嵌入向量插入到向量数据库中。
// 将文档嵌入插入 Supabase 表的函数
const insertEmbeddings = async (embeddings) => {
// 将文档向量插入 'handbook_docs' 表
const { error } = await supabase
.from('handbook_docs')
.insert(embeddings); // 一次性插入所有文档向量
// 处理插入错误
if (error) {
console.error('插入向量时出错:', error);
} else {
console.log('向量插入成功!');
}
};
// 示例用例:将文档向量插入数据库
await insertEmbeddings(文档向量);
脚本运行后,我们可以进入表编辑器,,点击我们的表handbook_docs,看看上传的数据怎么样。
就这样, 干得好,了。索引流程。
摘要在本文中,我们探讨了如何使用JavaScript和LangChain、Node-Llama-CPP和Supabase等工具构建一个开源索引管道。我们介绍了将大型文档分割成可以管理的小块、创建嵌入以捕获每个块的含义,以及将这些嵌入存储在向量数据库中以实现高效的检索的过程。这构成了检索增强生成(RAG)系统的基础,使我们能够根据语义相似性而非确切的单词匹配来查询文档。通过遵循这些步骤,你现在有了构建自己的RAG管道和提高应用程序中AI驱动搜索性能的工具。
共同學(xué)習(xí),寫下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章