用JavaScript快速实现低成本RAG知识库

广告位招租
扫码页面底部二维码联系

很久没有写文章了,过去两个多月我研发了一套系统,以辅助开发者们更便捷的获得AI服务。在过程中,我想提供给用户一个FAQ,但有不想做的像传统的问答编辑系统一样,于是想借助RAG来实现问答系统。初步想法是把站内所有的wiki向量化,作为知识库的一部分,另外,当用户提出自己的问题时,运营同学可以在后端进行答复,把这些答复的内容也向量化作为知识库的一部分。通过RAG来做,可以防止类似github issue沉底的问题,避免已经出现过的问题和答复无法实时的让新用户在有疑问时获得了解。

同时,在这个过程中,我浏览了各个云平台,以及各家大模型服务商,试图找到一个可以直接用于实现RAG的后端服务,但没有找到。对于FAQ这一需求而言,我认为一个精简的产品形态就是几个接口即可:

  • 用于提交知识(文本)的接口,该接口用于把网站或客户端的知识集中到后端服务中向量化存储
  • 用于提问/查询的接口,该接口用于从知识库中获得结果

通过两个接口,屏蔽了有关向量化、向量存储、检索增强等一系列的技术性问题,变成了无论是前端还是后端同学都可以使用的纯粹接口。当然,我们可以在云服务的后台,对所使用的大模型、向量模型、向量存储等更底层的服务进行配置,不同的配置所需的花费不同,从而让不同资金需求的用户都可以获得各自想要的RAG服务效果。

抱着这样的心态,我开始自己实现这一需求和服务。

什么是RAG知识库?

相信你已经对RAG已经有所了解了,为了使得文章更充实(凑字数),我还是从我个人的角度来聊一聊什么是RAG知识库。

作为本科和研究生阶段都对信息管理有深入研究的从业者,脱离学术,我们讲知识管理,本质上是在讲人类利用信息的高效通道。传统知识库我们称为“图书馆”,通过类目方式对知识进行索引,并提供类目检索工具让我们快速找到对应的图书,从而可以获得知识。现代知识库我们称之为“搜索引擎”,传统知识库获取知识的通道效率太低,想要精准的获取某个知识点,可能还需要借助对应的人才能完成,而搜索引擎时代,通过新的索引方式,向人们提供了寻找知识的捷径。当代知识库我们我们称之为“智能助手”,搜索引擎时代虽然相较于传统图书馆已经有了极大的提升,但是随着信息泛滥,搜索引擎只能提供获取知识的“线索”,而要精准获得知识,仍然有赖于搜索者对搜索结果的总结,而借助于AI的智能助手,则更近一步,不仅主动完成信息的搜索,还对信息进行提炼,将人们对知识的需求,以最直接的方式呈现在面前,也就是“所问既所答”。

RAG知识库就是当代AI背景下的知识助手,LLM-Based已经成为AI的新范式,可以说没有LLM也就没有RAG知识库。

知识库本身存在知识范围的限制,人类知识过于浩瀚,而真正在企业中发挥经济价值的知识,往往限定在与企业生产生活相关的范畴中。因此,从知识范围的角度,RAG知识库可以分为宏观行业知识库、企业单位生产知识库、自然人学习生活知识库。对于使用RAG知识库的用户而言,应该明确自己所需的知识范围,避免因为知识范围的不同导致往知识库中加入错误的知识来源。

对于AI行业而言,RAG又是有效的应用形态,弥补了LLM训练数据局限的问题。

总而言之,RAG知识库是当下AI技术发展到LLM-Based背景下,可以提供给人类最先进的知识获取的方式。

RAG知识库的技术架构

不同企业在实现RAG知识库时,技术细节上各有优化,但是总体而言,RAG有着通用的技术架构。

从我个人的角度,我把RAG的技术分为两部分:1.向量化检索;2.增强生成。

什么是向量化检索呢?举个例子,我们规定了3个维度,分别是:高度、体积、颜色。现在任何一个物体,我们都能推算出它在这三个维度上的值。我们把这个物体存入到仓库中时,标注了它的这3个维度。现在,我们的仓库中充满了物体,我们看到一个新物体,想要找出仓库中与之最为接近的物体,就只需要找出这3个维度的值与新物体最接近的哪些物体即可。这种方法可以大大提高检索的效率。这就是向量化检索,其中这个物体在3个维度上的表示,就是一个向量,而通过向量坐标,在数学上进行相似性运算是很方便的。同时,在进行向量化时,我们还会做一个隐藏技能,就是对原本文档进行分片,通过分片,既让知识向量化,又让知识体积变小,这样对后面把知识交给LLM去整合又有帮助。

增强生成,简单讲就是让LLM结合向量化检索的结果,直接返回知识点的内容(而非参考条目)给用户。

如果对技术的发展做不恰当的比方,RAG非常简单,就是在上一代知识技术(搜索引擎)的基础上,用LLM对搜索结果进行总结生成。但是,由于技术的局限,我们无法真的在现实中,直接通过LLM对搜索引擎搜索结果进行处理,这里面涉及到LLM本身的上下文长度限制、搜索引擎结果的质量等等问题。在企业内,我们有可能会自己构建一套搜索引擎,例如基于ES来实现权重查询。限定在企业内的知识,搜索结果少、质量高,理论上应该是可以实现的,但是,现实中,企业知识文件体积大,要从巨大的文件中只挖掘小的知识点,有点大刀小用。况且,搜索结果的质量还依赖于搜索词。

带着以上总总的问题思考,我们来看看当代典型的RAG知识库的技术架构是怎么设计的,它是怎么解决上述的这些问题的。

RAG知识库通用技术架构

RAG知识库通用技术架构

如图,在数据准备阶段,我们将不同的文件内容,进行embedding,此时,我们需要依赖一个Embedding Model(嵌入模型),目前很多大模型的服务商或者云服务商都提供了独立的Embedding Model服务,它的作用是将文件内容进行向量化。经过embedding之后,我们便得到了一堆向量,这里我们称之为Semantic Vector(语义向量),它们可存储可计算,对AI友好。之后我们将这些向量存储到一个向量数据库中,一般而言,向量数据库支持检索功能。不同的向量数据库特性和能力不同,因此,市面上有许多付费的向量数据库。

从这里可以看出,嵌入模型、向量数据库、大模型,这些底层服务对于厂商而言都是利润,而对于开发者而言,则是成本。当然,我们可以用自己的技术,在自己的服务器部署这些底层服务,从而降低成本。

数据检索阶段,用户输入一个问题,这个问题,搜索经过嵌入模型向量化,再用该向量拿去向量数据库进行检索,获得结果。这里得到的结果就是我们需要的知识点,它们来自各种原始数据中,但此处它们并不对应原始数据,而是原始数据的向量碎片,虽然它们关联了某些文件,但是它们现在的状态就是一个个的碎片,不利阅读。接下来,我们要把它们交给大模型去处理,我们需要构造一个合适的prompt,并把它们嵌入到该prompt中,由LLM来对该prompt进行响应。当LLM完成该prompt的响应后,我们就获得了较为精炼的知识内容。

以上是RAG的通用技术架构,我们可以在该架构基础上进行优化,例如对prompt进行优化,对向量数据库的召回率进行优化,对大模型的结果进行更深一层的智能逻辑处理(可以利用Agent技术)等等。但是,无论怎么变体,RAG的技术本质可以通过该架构体现。

基于LangChain在NodeJS中实现RAG

我的技术栈是JS,因此,我更多的是在nodejs中实现各种想法。langchain官方提供了RAG的引导文档,你几乎可以在理解了架构和langchain的设计基础上,无需辅助,照着文档完成。但作为开发者,我们希望更低成本的实现它,因此,我们要找到免费的技术选型。

通过上面技术架构我们可以知道,实际上,要构建最小的RAG,我们所依赖的服务,就3个:LLM、Embedding Model、Vector Database。有没有免费的替代呢?当然有,LLM和Embedding Model我们都可以通过Ollama来获得,向量数据库就使用本地化的faiss。

首先看下ollama中的嵌入模型

还不错,目前为止,有3个可以选。

接下来,就让我们一步一步的完成我们的代码。

ChatModel

import { ChatOllama } from "@langchain/ollama";

export function initOllamaChatModel() {
  const llm = new ChatOllama({
    model: "llama3",
    temperature: 0,
    maxRetries: 2,
    // other params...
  });
  return llm;
}

当然,这里,你需要先用ollama把llama3跑起来。

Embedding Model

import { OllamaEmbeddings } from "@langchain/ollama";

epxort function initOllamaEmbeddings() {
  const embeddings = new OllamaEmbeddings({
    model: "mxbai-embed-large", // Default value
    baseUrl: "http://localhost:11434", // Default value
  });
  return embeddings;
}

同样的道理,ollama把对应的模型跑起来。

TextLoader

我打算把所有的内容,都以纯文本的形势进行载入。你也可以使用其他的loader载入如txt、pdf、docx等文件或网页。

import { Document } from "@langchain/core/documents";

export async function loadPureText(text, metadata) {
    const docs = [
        new Document({
            pageContent: text,
            metadata,
        }),
    ];
    return docs;
}

TextSplitter

对大文本进行分片。

import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

export async function splitTextDocuments(docs) {
    const textSplitter = new RecursiveCharacterTextSplitter({
        chunkSize: 1000,
        chunkOverlap: 200,
    });
    return await textSplitter.splitDocuments(docs);
}

Vector Store

使用faiss作为向量数据库。

import { FaissStore } from "@langchain/community/vectorstores/faiss";

export function createFaissVectorStore(embeddings) {
  return new FaissStore(embeddings, {});
}

export async function searchFromFaissVectorStore(vectorStore, searchText, count) {
    const results = await vectorStore.similaritySearch(searchText, count);
    return results;
}

export async function addDocumentsToFaissVectorStore(vectorStore, docs, ids) {
    return await vectorStore.addDocuments(docs, { ids });
}

export async function deleteFromFaissVectorStore(vectorStore, ids) {
    return await vectorStore.delete({ ids });
}

/** 备份 */
export async function saveFaissVectorStoreToDir(vectorStore, dir) {
    await vectorStore.save(dir);
}

/** 恢复 */
export async function loadFaissVectorStoreFromDir(dir, embeddings) {
    const vectorStore = await FaissStore.load(dir, embeddings);
    return vectorStore;
}

Prompt

构建常用的RAG prompt模板。

import { ChatPromptTemplate } from "@langchain/core/prompts";

export function createRagPromptTemplate() {
    const promptTemplate = ChatPromptTemplate.fromTemplate(
`You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.
Question: {question}
Context: {context}
Answer:`
    );
    return promptTemplate;
}

RAGApplication

最后,是把上面的这些部分组合起来。

import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { StringOutputParser } from "@langchain/core/output_parsers";
import path from 'path';
import fs from 'fs';

export async function createRagApplication() {
    const llm = initOllamaChatModel();
    const embeddings = initOllamaEmbeddings();

    const faissStoragePath = path.join(__dirname, 'faiss');
    const isFaissExist = fs.existsSync(faissStoragePath);
    const vectorStore = isFaissExist ? await loadFaissVectorStoreFromDir(faissStoragePath, embeddings) : createFaissVectorStore(embeddings);
    const saveTo = () => {
        if (!isFaissExist) {
            fs.mkdirSync(faissStoragePath, { recursive: true });
        }
        return saveFaissVectorStoreToDir(vectorStore, faissStoragePath);
    };

    const retriever = vectorStore.asRetriever(options.retriever);
    const prompt = createRagPromptTemplate();

    const chain = await createStuffDocumentsChain({
        llm,
        prompt,
        outputParser: new StringOutputParser(),
    });

    /**
     *
     * @param {string} text
     * @param {{
     *   type: string; // 类型
     *   id: string | number; // 标识
     * }} meta
     */
    const addText = async (text, meta) => {
        const docs = await loadPureText(text, meta);
        const docsToAdd = await splitTextDocuments(docs);
        const { type, id } = meta;
        const ids = docsToAdd.map((_, i) => `${type}_${id}_${i}`);
        await addDocumentsToFaissVectorStore(vectorStore, docsToAdd, ids);
        await saveTo(); // 每次新增向量之后,自动保存到目录中
        return ids;
    };

    /**
     * @param {string[]} ids
     */
    const remove = async (ids) => {
        await deleteFromFaissVectorStore(vectorStore, ids);
        await saveTo();
    };

    const query = async (question) => {
        const context = await retriever.invoke(question);
        const results = await chain.invoke({
            question,
            context,
        })
        return results;
    };

    const stream = async (question) => {
        const context = await retriever.invoke(question);
        const results = await chain.stream({
            question,
            context,
        })
        return results;
    };

    return {
        addText,
        remove,
        query,
        stream,
    };
}

当然,这里你需要把前面的所有函数都引入进来。

Example

最后,我们来写一个例子,用以测试它是否正常工作:

const rag = await createRagApplication();

await rag.addText(`随着对大型模型应用探索的深入,检索增强生成技术(Retrieval-Augmented Generation)受到了广泛关注,并被应用于各种场景,如知识库问答、法律顾问、学习助手、网站机器人等。

不过,有很多朋友对于向量数据库和 RAG 的关系及技术原理并不清楚,本文将带大家深入了解 RAG 时代的新向量数据库。

01.RAG 的广泛应用及其独特优势
一个典型的 RAG 框架可以分为检索器(Retriever)和生成器(Generator)两块,检索过程包括为数据(如 Documents)做切分、嵌入向量(Embedding)、并构建索引(Chunks Vectors),再通过向量检索以召回相关结果,而生成过程则是利用基于检索结果(Context)增强的 Prompt 来激活 LLM 以生成回答(Result)。


https://arxiv.org/pdf/2402.19473

RAG 技术的关键在于其结合了这两种方法的优点:检索系统能提供具体、相关的事实和数据,而生成模型则能够灵活地构建回答,并融入更广泛的语境和信息。这种结合使得 RAG 模型在处理复杂的查询和生成信息丰富的回答方面非常有效,在问答系统、对话系统和其他需要理解和生成自然语言的应用中非常有用。相较于原生的大型模型,搭配 RAG 可以形成天然互补的优势:

避免“幻觉”问题:RAG 通过检索外部信息作为输入,辅助大型模型回答问题,这种方法能显著减少生成信息不准确的问题,增加回答的可追溯性。
数据隐私和安全:RAG 可以将知识库作为外部附件管理企业或机构的私有数据,避免数据在模型学习后以不可控的方式泄露。
信息的实时性:RAG 允许从外部数据源实时检索信息,因此可以获取最新的、领域特定的知识,解决知识时效性问题。
虽然大型模型的前沿研究也在致力于解决以上的问题,例如基于私有数据的微调、提升模型自身的长文本处理能力,这些研究有助于推动大型模型技术的进步。然而在更通用的场景下,RAG 依然是一个稳定、可靠且性价比高的选择,这主要是因为 RAG 具有以下的优势:

白盒模型:相较于微调和长文本处理的“黑盒”效应,RAG 模块之间的关系更为清晰紧密,这在效果调优上提供了更高的可操作性和可解释性;此外,在检索召回内容质量和置信度(Certainty)不高的情况下,RAG 系统甚至可以禁止 LLMs 的介入,直接回复“不知道”而非胡编乱造。
成本和响应速度:RAG 相比于微调模型具有训练时间短和成本低的优势;而与长文本处理相比,则拥有更快的响应速度和低得多的推理成本。在研究和实验阶段,效果和精确程度是最吸引人的;但在工业和产业落地方面,成本则是不容忽视的决定性因素。
私有数据管理:通过将知识库与大型模型解耦,RAG 不仅提供了一个安全可落地的实践基础,同时也能更好地管理企业现有和新增的知识,解决知识依赖问题。而与之相关的另一个角度则是访问权限控制和数据管理,这对 RAG 的底座数据库来说是很容易做到的,但对于大模型来说却很难。
因此,在我看来,随着对大型模型研究的不断深入,RAG 技术并不会被取代,相反会在相当长的时间内保有重要地位。这主要得益于其与 LLM 的天然互补性,这种互补性使得基于 RAG 构建的应用能在许多领域大放异彩。而 RAG 提升的关键一方面在 LLMs 能力的提升,而另一方面则依赖于检索(Retrieval)的各类提升和优化。

02.RAG 检索的底座:向量数据库
在业界实践中,RAG 检索通常与向量数据库密切结合,也催生了基于 ChatGPT + Vector Database + Prompt 的 RAG 解决方案,简称为 CVP 技术栈。这一解决方案依赖于向量数据库高效检索相关信息以增强大型语言模型(LLMs),通过将 LLMs 生成的查询转换为向量,使得 RAG 系统能在向量数据库中迅速定位到相应的知识条目。这种检索机制使 LLMs 在面对具体问题时,能够利用存储在向量数据库中的最新信息,有效解决 LLMs 固有的知识更新延迟和幻觉的问题。



尽管信息检索领域也存在选择众多的存储与检索技术,包括搜索引擎、关系型数据库和文档数据库等,向量数据库在 RAG 场景下却成为了业界首选。这一选择的背后,是向量数据库在高效地存储和检索大量嵌入向量方面的出色能力。这些嵌入向量由机器学习模型生成,不仅能够表征文本和图像等多种数据类型,还能够捕获它们深层的语义信息。在 RAG 系统中,检索的任务是快速且精确地找出与输入查询语义上最匹配的信息,而向量数据库正因其在处理高维向量数据和进行快速相似性搜索方面的显著优势而脱颖而出。

以下是对以向量检索为代表的向量数据库与其他技术选项的横向比较,以及它在 RAG 场景中成为主流选择的关键因素分析:



首先在实现原理方面,向量是模型对语义含义的编码形式,向量数据库可以更好地理解查询的语义内容,因为它们利用了深度学习模型的能力来编码文本的含义,不仅仅是关键字匹配。受益于 AI 模型的发展,其背后语义准确度也正在稳步提升,通过用向量的距离相似度来表示语义相似度已经发展成为了 NLP 的主流形态,因此表意的 embedding 就成了处理信息载体的首选。

其次在检索效率方面,由于信息可以表示成高维向量,针对向量加上特殊的索引优化和量化方法,可以极大提升检索效率并压缩存储成本,随着数据量的增长,向量数据库能够水平扩展,保持查询的响应时间,这对于需要处理海量数据的 RAG 系统至关重要,因此向量数据库更擅长处理超大规模的非结构化数据。

至于泛化能力这个维度,传统的搜索引擎、关系型或文档数据库大都只能处理文本,泛化和扩展的能力差,向量数据库不仅限于文本数据,还可以处理图像、音频和其他非结构化数据类型的嵌入向量,这使得 RAG 系统可以更加灵活和多功能。

最后在总拥有成本上,相比于其他选项,向量数据库的部署都更加方便、易于上手,同时也提供了丰富的 API,使其易于与现有的机器学习框架和工作流程集成,因而深受许多 RAG 应用开发者的喜爱。

向量检索正凭借其对于语义的理解能力、高效的检索效率、以及对多模态的泛化支持等优势,成为了大模型时代理想的 RAG 检索器,而随着 AI 和 embedding 模型的进一步发展,这些优势在未来或将更加突出。

03.RAG 场景对向量数据库的需求
虽然向量数据库成为了检索的重要方式,但随着 RAG 应用的深入以及人们对高质量回答的需求,检索引擎依旧面临着诸多挑战。这里以一个最基础的 RAG 构建流程为例:检索器的组成包括了语料的预处理如切分、数据清洗、embedding 入库等,然后是索引的构建和管理,最后是通过 vector search 找到相近的片段提供给 prompt 做增强生成。大多数向量数据库的功能还只落在索引的构建管理和搜索的计算上,进一步则是包含了 embedding 模型的功能。



但在更高级的 RAG 场景中,因为召回的质量将直接影响到生成模型的输出质量和相关性,因此作为检索器底座的向量数据库应该更多的对检索质量负责。为了提升检索质量,这里其实有很多工程化的优化手段,如 chunk_size 的选择,切分是否需要 overlap,如何选择 embedding model,是否需要额外的内容标签,是否加入基于词法的检索来做 hybrid search,重排序 reranker 的选择等等,其中有不少工作是可以纳入向量数据库的考量之中。而检索系统对向量数据库的需求可以抽象描述为:

高精度的召回:向量数据库需要能够准确召回与查询语义最相关的文档或信息片段。这要求数据库能够理解和处理高维向量空间中的复杂语义关系,确保召回内容与查询的高度相关性。这里的效果既包括向量检索的数学召回精度也包括嵌入模型的语义精度。
快速响应:为了不影响用户体验,召回操作需要在极短的时间内完成,通常是毫秒级别。这要求向量数据库具备高效的查询处理能力,以快速从大规模数据集中检索和召回信息。此外,随着数据量的增长和查询需求的变化,向量数据库需要能够灵活扩展,以支持更多的数据和更复杂的查询,同时保持召回效果的稳定性和可靠性。
处理多模态数据的能力:随着应用场景的多样化,向量数据库可能需要处理不仅仅是文本,还有图像、视频等多模态数据。这要求数据库能够支持不同种类数据的嵌入,并能根据不同模态的数据查询进行有效的召回。
可解释性和可调试性:在召回效果不理想时,能够提供足够的信息帮助开发者诊断和优化是非常有价值的。因此,向量数据库在设计时也应考虑到系统的可解释性和可调试性。
RAG 场景中对向量数据库的召回效果有着严格的要求,不仅需要高精度和快速响应的召回这类基础能力,还需要处理多模态数据的能力以及可解释性和可调试性这类更高级的功能,以确保生成模型能够基于高质量的召回结果产生准确和相关的输出。在多模态处理、检索的可解释性和可调试性方面,向量数据库仍有许多工作值得探索和优化,而 RAG 应用的开发者也急需一套端到端的解决方案来达到高质量的检索效果。`);

const results = await rag.stream('RAG检索的底座是什么?');
for await (const chunk of results) {
    process.stdout.write(chunk);
}

在命令行中运行上面这个js,就可以看到效果。如果运行正常,说明我们的编码没有问题。

需要注意的点

上面实现中,只提供了addText纯文本写到向量库中,你可以根据你自己的实际需求,利用langchain提供的loader,实现各种形式的载入。

另外,上面的实现中,只从RAG的通用架构角度进行了技术实现,而没有从应用出发,去进行缓存、去重等一系列的应用层设计。它相当于是一个通用的代码,任何nodejs项目都可以拿去用,再在它的上层进行更深度的业务封装。我自己就是在这样的基础上,封装了FAQ系统,当用户发起提问的时候,从知识库中捞取知识进行回答,同时,在业务上,我有增加了运营人员手工回答来覆盖AI回答,同时,又把人工回答再载入到知识库中,从而使得将来类似问题可以被更好的回答。

结语

虽然我在去年就在腾讯内网发布了如何用nodejs来实现RAG的文章,但是随着langchain的进步,以及市场上各类底层服务的完善,现在构建RAG已经变得轻而易举。本文算是我闲暇时,保持博客更新的一篇总结吧,毕竟对于不少朋友来说,还是需要有一篇类似的文章引导的。我在翻看langchain文档时,发现不少工具是nodejs独享的,python没有,这说明js社区的强大。而随着AI技术的不断普及化,以及LLM上下文长度的提升,在不考虑tokens的费用的情况下,或许我们真的可以做到不需要向量化来实现RAG知识库。对了,文章开头说的FAQ接口服务,我会在近期放出,你只需要持续关注我,适当的时候,它就会出现。

2024-08-15 625

为价值买单,打赏一杯咖啡

本文价值6.25RMB