前端同学速通检索增强系统(RAG)

136 阅读26分钟

随着大模型技术的发展,在应用领域的逐渐聚焦于两个方向 RAG 和 Agent。RAG 是 LLM 中最典型也是最流行的设计模式,其全称是 Retrieval Augmented Generation,可以被翻译成检索增强生成技术,其核心的流程 检索 => 增强 => 生成

LLM 的局限性

现在的 LLM 有什么问题?

  1. 大模型幻觉。 LLM 本身是基于从大量数据中训练出来的概率模型来一个个生成 token,他没有逻辑和事实推理能力,它是基于概率产生的 “伪智能” ,而不是底层基于理解的 “真智能” 。但这对应用层无所谓,因为这个概率模型已经足够强大,在应用层已经表现出了足够使用的“逻辑和推理能力”。

    1.   为什么既说 “不是底层基于逻辑和推理能力”,又说“足够使用的逻辑和推理能力”,那到底有没有智能?我们用一个经典的例子说明,我们给猴子一个打字机,让他随便打字,如果这个实验拉长到时间是无限的,总有一天,他会打出一部完整的莎士比亚的小说。因为时间是无限的,他是随机打字,那就一定会在某个时间点所有概率都碰上了,成了一本莎士比亚的小说。那猴子到底懂不懂莎士比亚?那肯定是完全不懂的,它不具备逻辑,只是一切概率性的巧合凑到一起了罢了。
    2.   而 llm 你可以理解成为一个更大概率打出莎士比亚的猴子,它不理解内容背后的逻辑,但因为它的模型足够大,训练数据集足够大,他输出正确内容的概率也足够大。所以,从外界看来,他就像真正理解内容一样,也就像具有真正的逻辑和推理能力。
  2. 领域知识的欠缺。 这部分分两种情况,第一种是对知识的更新慢,例如你问他最新的新闻他肯定是不知道的,因为他的训练数据集不可能每天更新;第二种是特定领域的知识不了解,例如你要创建一个 Boomcut chat bot,他本身训练数据集这方面的知识肯定没有,就很容易出现幻想问题,然后瞎回答。

所以,RAG 就是针对这两点进行解决的。

RAG 的原理

在我们了解了 llm 是基于概率性的底层逻辑后,那么解决方法就是尽可能提供与答案相关的上下文,来增强它正确输出的可能性,形象的说就是,我们把一本莎士比亚文集选放到猴子身边,让他一边看一边打字。

RAG 的基本流程就是:

  1. 用户输入提问
  2. 检索:根据用户提问对 向量数据库 进行相似性检测,查找与回答用户问题最相关的内容
  3. 增强:根据检索的结果,生成 prompt。 一般都会涉及 “仅依赖下述信息源来回答问题” 这种限制 llm 参考信息源的语句,来减少幻想,让回答更加聚焦
  4. 生成:将增强后的 prompt 传递给 llm,返回数据给用户

所以 RAG 就是哪里有问题解决哪里,既然大模型无法获得最新和内部的数据集,那我们就使用外挂的向量数据库为 llm 提供最新和内部的数据库。既然大模型有幻想问题,我们就将回答问题所需要的信息和知识编码到上下文中,强制大模型只参考这些内容进行回答。

RAG 的流程

在理解了RAG 的基本原理后,我们快速和宏观的介绍如何打造一个 RAG chat bot 的全流程。

  1. 加载数据 因为想要根据用户的提问进行语意检索,我们需要将数据集放到向量数据库中,所以我们需要将不同的数据源加载进来。这里就涉及到多种数据源,例如 pdf、code、现存数据库、云数据库等等。

  2. 切分数据 大模型的上下文窗口都有限制,而我们很多数据源都很容易比这个大,而且用户的提问经常涉及多个数据源,所以我们需要对数据集进行语意化的切分,根据内容的特点和目标大模型的特点、上下文窗口等,对数据源进行合适的切分。这里听起来比较容易,但考虑到数据源的多种多样和自然语言的特点,事实上切分函数的选择和参数的设定是非常难以控制的。理论上我们是希望每个文档块都是语意相关,并且相互独立的。

  3. 嵌入(embedding) 这部分对没有机器学习相关背景的同学不容易理解。这里可以用最简单的词袋(words bag)模型来描述一下最简单的 embedding 过程。

    1. 词袋模型就是最简化的情况,把一篇 句子/文章 中的单词提前出来,就像放到一个袋子里一样,认为单词之间是独立的,并不关心词与词之间的上下文关系。
    2. 假设我们有十篇英语文章,那我们可以把每个文章拆分成单词,并且还原成最初的形势(例如 did、does => do),然后我们统计每个词出现的次数。 我们简化一下假设最后结果就是
    3.     第一篇文章: apple: 10, phone:12
          第二篇文章: apple: 8, android: 10, phone: 18
          第三篇文章: banana: 6, juice: 10
      
    4. 那我们尝试构建一个向量,也就是一个数组,每个位置有一个值,代表每个单词在这个文章中出现的次数
    5.     变量 [apple, banana, phone, android, juice] 
      
    6.   那每篇文章,都能用一个变量来表示
    7.     第一篇文章: [10, 0, 12, 0, 0]
          第二篇文章: [8, 0, 18, 10, 0]
          第三篇文章: [0, 6, 0, 0, 10] 
      
    8. 这样我们就能把一篇文章用一个向量来表示了,然后我们可以用最简单的余弦定理去计算两个向量之间的夹角,以此确定两个向量的距离。
    9. 这样,我们就有了通过向量和向量之间的余弦夹角的,来衡量文章之间相似度的能力,是不是很简单。

这是最简单的 embedding 原理,不过是所有的 embedding 和相似性搜索都是类似的原理。

回到 RAG 流程中,将切分后的每一个文档块使用 embedding 算法转换成一个向量,存储到向量数据库中(vector store)中。这样,每一个原始数据都有一个对应的向量,可以用来检索。

4.检索数据 当所有需要的数据都存储到向量数据库中后,我们就可以把用户的提问也 embedding 成向量,用这个向量去向量数据库中进行检索,找到相似性最高的几个文档块,返回。

5.增强 prompt

在有了跟用户提问最相关的文档块后,我们根据文档块去构建 prompt。 一般格式都类似于

你是一个 xxx 的聊天机器人,你的任务是根据给定的文档回答用户问题,并且回答时仅根据给定的文档,尽可能回答用户问题。
如果你不知道,你可以回答“我不知道”。
这是文档: {docs}
用户的提问是: {question} 

6.生成 然后就是将组装好的 prompt 传递给 chatbot 进行生成回答。

构建RAG应用

AI智能体平台

目前市面上有很多可以快速构建 RAG 应用的产品如:Coze,Dify,RAGFlow等

  1. Coze

Coze由字节跳动推出,主打低门槛、强对话体验,适合C端用户常用的对话类应用场景,如客服和语音助手。该平台功能全面,涵盖了插件系统、记忆库、工作流等关键功能,并且支持用户自定义知识库和插件。

  1. Dify

Dify主要面向开发者人员,提供高效的开发工具和国际化支持,特别适合技术团队快速构建智能应用。其提供灵活易用的API接口和多语言支持,使得开发者能够在短时间内实现全球化的智能应用。

  1. RAGFlow

RAGFlow是一款基于深度文档理解构建的开源 RAG 引擎。RAGFlow个人可以为各种规模的企业及提供一套专业的RAG工作流程。

上面几个产品都是AI智能体平台,是一个调用大模型和各种工具等SaaS应用。

上手都比较简单,不需要懂代码细节,拖拖拽拽填写参数就能快速构建出一个RAG应用。

但对于程序员来说肯定不能停留在搭建应用这个浅显的层面,要深入到开发角度。这个就和低代码平台搭建页面一样,关注不到页面具体是怎么生成的。

LLM 开发框架

LLM 开发框架是AI智能体平台的底层,类似于页面和React这种UI渲染框架的关系。

对于前端来说,目前比较合适的框架有两个:LangChainLlamaIndex,因为这个两个框架除了常规的Python外,还支持了TS,使得前端程序员可以快速构建出 web / node AI应用。

LangChain

LangChain是一个基于大语言模型的框架,它并不开发LLM,而是为各种LLM实现通用的接口,将相关的组件“链”在一起,简化LLM应用的开发。它支持模型集成、提示工程、索引、记忆、链、代理等多种组件功能。

LangChain 的核心是其链式架构,它允许开发者将不同的组件(如模型、提示、索引、记忆等)组合成一个处理流程。这种设计旨在灵活地处理各种复杂任务

LlamaIndex

LlamaIndex是一个基于向量搜索的索引框架,主要用于提升大型语言模型在长文本或大量数据上的查询效率。它侧重于处理数据的索引和检索,使得模型能够快速定位到最相关的信息。

LlamaIndex 专注于索引和检索,主要通过向量搜索来提高大型语言模型在处理大量数据时的效率。

实战

这里我使用 LlamaIndex 来制作 RAG 应用,因为它就是为 RAG 场景而生,简化了很多步骤。

环境准备:

  1. 本地 ollama 环境
  2. 本地 deepseek-r1 大语言模型
  3. 本地 nomic-embed-text embedding模型
1. 文档加载

LlamaIndex提供了很多工具来加载不同的文档,支持csv、pdf、json、md、html、text、图片、音视频等

也可以方便的加载指定目录下的所有文件

这里以常见的公司考勤QA为参考,使用 TextFileReader 加载txt文本

import { TextFileReader } from "@llamaindex/readers/text";

const reader = new TextFileReader();
const document = await reader.loadData(
    path.join(__dirname, "../data/test.txt")
);

读取结果:

[
  Document {
    id_: '/Users/xy/Desktop/project/rag-test/data/test.txt_1',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。\n' +
      '\n' +
      'Q3:当天加班很晚,第二天可以迟到吗?\n' +
      '答:如果当天加班到22:00(含)以后,并且当天没有迟到,那么第二天最晚到岗时间可以延迟到10:30,10:30之后才算迟到。\n' +
      '\n' +
      'Q4:当天因项目加班到夜里12点,第二天"肝不动"了怎么办?\n' +
      '答:如果当天未迟到,且当天因项目需要加班到夜里12点(含)之后, 通过飞书--审批--"肝不动申请",申请第二天上午不出勤,下午则正常出勤,13:30之前打卡。\n' +
      '\n' +
      'Q5:一个月需要补卡的次数超过了6次怎么办?\n' +
      '答:系统默认补卡次数上限为6次,超过6次就不可以再申请补卡口拉,超过6次的部分将被记为缺卡。\n' +
      '\n' +
      'Q6:缺卡未补卡会有什么影响?\n' +
      '答:如果当日缺卡一次(缺上班卡或下班卡),不会扣除当日日薪,但是当日的工时为零,会影响整体的月均工时,并且会扣除当日午餐、晚餐餐补;如果当日缺卡两次(即上下班均无打卡记录),记为缺勤,按考勤制度扣除当日薪资。\n' +
      '\n' +
      'Q7:半天假的请假区间如何计算呢?\n' +
      '答:请上午半天假时,请假时间请选择9:00--12:00;请下午半天假时,请假时间请选择13:30--18:30。另外,正常上班时间因私外出都需要请假哦~\n' +
      '\n' +
      'Q8:如果打早退卡怎么办?\n' +
      '答:需补齐请假手续哦。\n' +
      '\n' +
      'Q9:当日工作时间未满8小时会有什么影响?会被扣除薪资吗?\n' +
      '答:不会扣薪资,但需要注意保证月平均工作时间满足8小时。\n' +
      '\n' +
      'Q10:如有杭州市内的外勤需求,如何申请?\n' +
      '答:飞书上直接发起申请,流程:飞书--飞书人事-更多-自助服务-远程办公申请',
    metadataSeparator: '\n'
  }
]
2. 文本分割

这一步是将大文本切分为一个个独立的小文本,LlamaIndex内置了三种文本分割器:

  • SentenceSplitter:它会将 Document 中的文本拆分为句子
  • MarkdownNodeParser:它会将 markdown 拆分为多个节点,然后将这些节点解析为 Document 对象
  • CodeSplitter:它将按 AST 节点拆分代码,然后将节点解析为 Document 对象

因为考勤内容是纯文本知识,所以这里我用 SentenceSplitter 来切分内容。

这里我们指定每个片段最大200字符,需要根据 "\n\n" 拆分段落。在SentenceSplitter中,会优先尝试段落分割,分割后再尽可能的组合段落。

举个例子:

段落 1 有 100 字符,段落 2 有 150 字符,段落 3 有 40 字符,最后分割会将段落1作为单独 chuck,段落 2 和 3 合并作为第二个 chunk

const sentenceSplitter = new SentenceSplitter({
    chunkSize: 200,
    chunkOverlap: 0,
    separator: "",
    paragraphSeparator: "\n\n",
});

const nodes = sentenceSplitter.getNodesFromDocuments(document);

拆分结果:

[
  TextNode {
    id_: 'ebdf768f-d1cb-4942-bcf5-cfa22152f9bf',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    metadataSeparator: '\n',
    startCharIdx: 0,
    endCharIdx: 96
  },
  TextNode {
    id_: '9cc9af95-cf01-49b7-8bfc-9e9a11687d83',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q3:当天加班很晚,第二天可以迟到吗?\n' +
      '答:如果当天加班到22:00(含)以后,并且当天没有迟到,那么第二天最晚到岗时间可以延迟到10:30,10:30之后才算迟到。',
    metadataSeparator: '\n',
    startCharIdx: 98,
    endCharIdx: 181
  },
  TextNode {
    id_: '97396b40-b3fb-4c4c-8176-48181bb6e6b6',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q4:当天因项目加班到夜里12点,第二天"肝不动"了怎么办?\n' +
      '答:如果当天未迟到,且当天因项目需要加班到夜里12点(含)之后, 通过飞书--审批--"肝不动申请",申请第二天上午不出勤,下午则正常出勤,13:30之前打卡。',
    metadataSeparator: '\n',
    startCharIdx: 183,
    endCharIdx: 294
  },
  TextNode {
    id_: '36ce16c7-fdc2-4a25-ba06-bbf10f6ffeb6',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q5:一个月需要补卡的次数超过了6次怎么办?\n答:系统默认补卡次数上限为6次,超过6次就不可以再申请补卡口拉,超过6次的部分将被记为缺卡。',
    textTemplate: '',
    metadataSeparator: '\n',
    startCharIdx: 296,
    endCharIdx: 365
  },
  TextNode {
    id_: 'ad20893c-b12b-4434-9823-e4cae2d584d3',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q6:缺卡未补卡会有什么影响?\n' +
      '答:如果当日缺卡一次(缺上班卡或下班卡),不会扣除当日日薪,但是当日的工时为零,会影响整体的月均工时,并且会扣除当日午餐、晚餐餐补;如果当日缺卡两次(即上下班均无打卡记录),记为缺勤,按考勤制度扣除当日薪资。',
    metadataSeparator: '\n',
    startCharIdx: 367,
    endCharIdx: 487
  },
  TextNode {
    id_: 'a807500d-3b61-4310-9f9a-53f8ca94ac20',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q7:半天假的请假区间如何计算呢?\n' +
      '答:请上午半天假时,请假时间请选择9:00--12:00;请下午半天假时,请假时间请选择13:30--18:30。另外,正常上班时间因私外出都需要请假哦~\n' +
      '\n' +
      'Q8:如果打早退卡怎么办?\n' +
      '答:需补齐请假手续哦。',
    textTemplate: '',
    metadataSeparator: '\n',
    startCharIdx: 489,
    endCharIdx: 611
  },
  TextNode {
    id_: 'e37005d8-9b3c-4cc4-a7db-55535dc973b7',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: 'Q9:当日工作时间未满8小时会有什么影响?会被扣除薪资吗?\n' +
      '答:不会扣薪资,但需要注意保证月平均工作时间满足8小时。\n' +
      '\n' +
      'Q10:如有杭州市内的外勤需求,如何申请?\n' +
      '答:飞书上直接发起申请,流程:飞书--飞书人事-更多-自助服务-远程办公申请',
    metadataSeparator: '\n',
    startCharIdx: 613,
    endCharIdx: 733
  }
]
3.元数据提取

LlamaIndex有一个Extractor的概念,可以理解为对数据的预处理。内置了4种特征提取器:

  • SummaryExtractor - 自动提取一组节点的摘要
  • QuestionsAnsweredExtractor - 提取每个节点可以回答的一组问题
  • TitleExtractor - 按文档在每个节点的上下文中提取标题并组合它们
  • KeywordExtractor - 在每个节点的上下文中提取关键字

元数据的提取并不是 RAG 流程中必须的,这一步属于一个优化过程。当一个大文本被分割成若干chunk后,每个chunk可能无法保证内容上的完整,这时候就可以借助大模型对每个chunk进行内容扩展,比如生成摘要、关键字、标题,这样可以增大索引准确性。更有甚者,还可以为每个chunk生成可以回答的问题。

这里受限于本地机器的性能,我会选择一个chunk做数据提取:

TextNode {
    id_: 'ebdf768f-d1cb-4942-bcf5-cfa22152f9bf',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    metadataSeparator: '\n',
    startCharIdx: 0,
    endCharIdx: 96
  },

SummaryExtractor 摘要提取

const summary = await new SummaryExtractor({
    llm: Settings.llm,
}).generateNodeSummary(nodes[0]);
[
  TextNode {
    id_: 'a0846f97-64b3-49c7-83e3-bafd7bc122c3',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt',
      sectionSummary: '考勤制度中,公司目前仍然实施弹性打卡制度,上下班时间分别为9:00-10:00。迟到不会影响薪资,但会导致取消当日晚餐餐补。'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    endCharIdx: 96,
    metadataSeparator: '\n'
  }
]

QuestionsAnsweredExtractor 问答提取

const question = await new QuestionsAnsweredExtractor({
    llm: Settings.llm,
    questions: 2,
    promptTemplate: `请参考以下内容:\n\n{context}\n\n,为我生成{{numQuestions}}个问题`,
}).processNodes([nodes[0]]);
[
  TextNode {
    id_: 'ca419d8f-fbc0-4aac-bfdf-437f15057f1e',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt',
      questionsThisExcerptCanAnswer: 'Q: 员工在需要请假时,是否需要提前向公司申请?  答:员工在需要请假时,应当提前向相关负责人申请,并遵守公司的请假审批流程。  Q: 员工旷工一天会有怎样的处理?  答:员工旷工将被视为无故缺勤,具体处罚包括扣除当月薪资并可能取消奖金或其他福利待遇。'
    },
    embedding: undefined,
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    textTemplate: '[Excerpt from document]\n\nExcerpt:\n-----\n\n-----\n',
    endCharIdx: 96,
    metadataSeparator: '\n'
  }
]

TitleExtractor 标题提取

const title = await new TitleExtractor({
    llm: Settings.llm,
    nodeTemplate: `请参考以下内容:\n\n{context}\n\n,为我生成一个简短标题,不超过10个字`,
}).processNodes([nodes[0]]);
[
  TextNode {
    id_: '8b7af406-13fa-4649-a83e-c74ffa5e4e68',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt',
      documentTitle: '弹性打卡及迟到处罚规定'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    endCharIdx: 96,
    metadataSeparator: '\n'
  }
]

KeywordExtractor 关键字提取

const keywords = await new KeywordExtractor({
    llm: Settings.llm,
    keywords: 5,
    promptTemplate: `请参考以下内容:\n\n{context}\n\n,为我生成{{maxKeywords}}个关键词`,
}).processNodes([nodes[0]]);
[
  TextNode {
    id_: '8b7af406-13fa-4649-a83e-c74ffa5e4e68',
    metadata: {
      file_path: '/Users/xy/Desktop/project/rag-test/data/test.txt',
      file_name: 'test.txt',
      documentTitle: '1. 弹性打卡  \n' +
        '2. 考勤制度  \n' +
        '3. 弹性工作时间  \n' +
        '4. 迟到处罚  \n' +
        '5. 薪资扣除'
    },
    text: '考勤制度Q&A\n' +
      'Q1:公司实行弹性打卡吗?\n' +
      '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
      '\n' +
      'Q2:迟到了会被扣薪资嘛?\n' +
      '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
    endCharIdx: 96,
    metadataSeparator: '\n'
  }
]
4.embedding

要将文本转换成大模型可以理解的语言,最重要的一步就是 embedding,这一步可以将文本数据转换成向量数据。

这里我使用的是本地 ollama 部署的 nomic-embed-text 模型,LlamaIndex 接入 embedding 非常简单:

import { OllamaEmbedding } from "@llamaindex/ollama";
import { Document, Settings, VectorStoreIndex } from "llamaindex"; 

Settings.embedModel = new OllamaEmbedding({ model: "nomic-embed-text" });
const index = await VectorStoreIndex.fromDocuments([document]); 
5.检索

检索是 RAG 前置流程的最后一步,也是最重要的一步,通过这步找到与问题最相关的文档片段。LlamaIndex内置了6种常用的检索方式:

  • VectorIndexRetriever 将获取前 k 个最相似的节点。非常适合密集检索以查找最相关的节点。

  • SummaryIndexRetriever: 将获取所有节点,无论查询如何。当需要完整的上下文时,例如分析大型数据集,这是理想的选择。

  • SummaryIndexLLMRetriever 根据 LLM 与查询的相关性对节点进行评分和筛选。

  • KeywordTableLLMRetriever 使用 LLM 从查询中提取关键字,并根据关键字匹配检索相关节点。

  • KeywordTableSimpleRetriever 使用基于频率的方法来提取关键字和检索节点。

  • KeywordTableRAKERetriever 使用 RAKE(快速自动关键字提取)算法从查询中提取关键字,专注于基于关键字的检索的共现和上下文。

这里我使用 K近邻算法 进行检索:

const index = await VectorStoreIndex.fromDocuments(nodes);
const retriever = new VectorIndexRetriever({ index });

const queryEngine = index.asQueryEngine({ retriever });
const answer = await queryEngine.query({
    query: "迟到了会被扣薪资嘛",
});
EngineResponse {
  metadata: {},
  message: {
    content: '迟到的情况下,不会直接扣除薪资,但可能会取消午餐和晚餐的餐补' + 
    '并且在考勤方面受到一定影响,可能导致月均工时减少,从而可能影响薪资扣减。',
    role: 'assistant'
  },
  raw: null,
  sourceNodes: [
      {
        node: TextNode {
          id_: '33ce84c9-e879-4600-8cd6-a905789d8a37',
          metadata: [Object],
          text: 'Q6:缺卡未补卡会有什么影响?\n' +
            '答:如果当日缺卡一次(缺上班卡或下班卡),不会扣除当日日薪,但是当日的工时为零,会影响整体的月均工时,并且会扣除当日午餐、晚餐餐补;如果当日缺卡两次(即上下班均无打卡记录),记为缺勤,按考勤制度扣除当日薪资。',
          textTemplate: '',
          endCharIdx: 120,
          metadataSeparator: '\n'
        },
        score: 0.6371971046112181
      },
      {
        node: TextNode {
          id_: 'b599650e-6598-4c61-9a1a-fff11f812430',
          metadata: [Object],
          text: '考勤制度Q&A\n' +
            'Q1:公司实行弹性打卡吗?\n' +
            '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
            '\n' +
            'Q2:迟到了会被扣薪资嘛?\n' +
            '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
          textTemplate: '',
          endCharIdx: 96,
          metadataSeparator: '\n'
        },
        score: 0.6050933609843168
      }
  ]
  stream: false
}
6.检索结果优化

从上面的步骤我们可以看到,即使最后的回答还算比较准确,但是从召回的内容来看,我们输入的问题 “迟到了会被扣薪资嘛” ,在第二个引用片段中有几乎一样的问题,理论上第二个片段的召回分数应该更高才对,结果只有 0.60,还不及第一个片段的 0.63,这在少量文本中还算能用,如果文本量特别大,就很有可能造成实际相关性很高的片段被丢失,这是不能接受的。

Rerank

文章引用:

在RAG模型中,检索器负责从大规模的语料库中检索与输入问题相关的文档。然而,由于语料库的广泛性和多样性,检索器可能返回的文档的相关性会有所不同。这种不确定性带来了两个主要挑战:

  • 文档相关性差异: 检索器返回的文档可能在相关性上存在差异,有些文档可能与输入问题高度相关,而有些文档可能相关性较低。这种差异性使得直接使用检索器返回的文档进行生成可能会导致结果的不准确或不相关。
  • 信息不完整性: 检索器返回的文档通常只是初步筛选,其中可能包含了一些噪音或不相关的信息。这使得生成器在生成结果时面临着信息不完整的挑战,需要进一步处理以提高结果的质量。

Rerank 是指在 RAG 模型中对检索器返回的文档进行再排序的过程。其目的是通过重新排列候选文档,使得生成器更好地利用相关信息,并生成与输入问题更加相关和准确的结果。

它可以分为以下几个步骤::

  1. 检索文档:RAG模型通过检索器从大规模语料库中检索相关文档,这些文档可能包含了与输入问题相关的信息
  2. 特征提取:对检索到的文档进行特征提取,通常会使用各种特征,如语义相关性、词频、TF-IDF值等。这些特征能够帮助模型评估文档与输入问题的相关性。
  3. 排序文档:根据提取的特征,对检索到的文档进行排序,将与输入问题最相关的文档排在前面,以便后续生成器使用。
  4. 重新生成:排序完成后,生成器将重新使用排在前面的文档进行文本生成,以生成最终的输出结果。

目前市面上比较有名的rerank有三款:Cohere Rerank 、 BGE Re-Ranker(开源)、Jina Reranker

这里我使用 Cohere 来做测试,它提供了低速的免费使用额度。

const index = await VectorStoreIndex.fromDocuments(nodes);
const retriever = new VectorIndexRetriever({ index });

const nodePostprocessor = new CohereRerank({
    apiKey: "8nChSvbIvOasZZoBZhQWQB2jZB3mQqBPRU2GNdA6",
    topN: 5,
});
const queryEngine = index.asQueryEngine({
    retriever,
    nodePostprocessors: [nodePostprocessor],
});

const answer = await queryEngine.query({
    query: "迟到了会被扣薪资嘛",
});
EngineResponse {
  metadata: {},
  message: {
    content: 根据提供的上下文信息: +
    '答: 不会扣工资, 会取消当天的午餐、晚餐餐补。'
    role: 'assistant'
  },
  raw: null,
  sourceNodes: [
  {
        node: TextNode {
          id_: 'b2cc3a25-d807-426b-b21d-a7cb7347655f',
          metadata: [Object],
          text: '考勤制度Q&A\n' +
            'Q1:公司实行弹性打卡吗?\n' +
            '答:目前仍然实施弹性打卡制度。上班打卡弹性区间为9:00--10:00。\n' +
            '\n' +
            'Q2:迟到了会被扣薪资嘛?\n' +
            '答:迟到不扣工资,会取消当日午餐、晚餐餐补。',
          textTemplate: '',
          endCharIdx: 96,
          metadataSeparator: '\n'
        },
        score: 0.9964342
      },
      {
        node: TextNode {
          id_: '6058614e-d563-4167-a1b5-0484b748f2de',
          metadata: [Object],
          text: 'Q6:缺卡未补卡会有什么影响?\n' +
            '答:如果当日缺卡一次(缺上班卡或下班卡),不会扣除当日日薪,但是当日的工时为零,会影响整体的月均工时,并且会扣除当日午餐、晚餐餐补;如果当日缺卡两次(即上下班均无打卡记录),记为缺勤,按考勤制度扣除当日薪资。',
          textTemplate: '',
          endCharIdx: 120,
          metadataSeparator: '\n'
        },
        score: 0.56187606
      }
    ]
  stream: false
}

通过上面的输出结果我们可以看到,最相关的片段已经被提前了,并且相关系数达到 0.99 !

问题扩展

除了rerank外,我们还可以从问题入手,比如 “迟到会扣钱嘛?”, 这种问题肯定比 “会扣钱嘛?” 要好,所以我们可以对简短的问题,利用大模型进行语义丰富。这里就不展示代码了。

最终效果