Langchain.js | Retriever👈| RAG的首字母🤔🤔🤔?

540 阅读16分钟

前言

书接上文 , 将 RAG 的基本流程原理使用图解的形式 , 具象化理解 , 我学到 , RAG 有两个阶段

  • 索引阶段
  • 查询阶段

索引阶段就是数据向量化 并且储存到向量数据库的过程

查询阶段 , 需要根据用户的问题去向量数据中检索 , 弥补 LLM 一些局限性 , 现在开始使用学习这个阶段的内容 , 争取在年前有段进展 ~🤡

文字有点多 , 你要忍一下 ~ 🤡

基础检索器

检索器(Retriever)是基于用户查询 , 检索储存在向量数据的问题的一个工具 , 使得我们除了 similaritySearch 这样基于相似性检索内容的方法 , 还有使用检索器的方法 ,比如 : asRetriever , 这个方法可以把之前提到的方法 , 转化为一个检索器 , 并且支持 LCEL 用法 ~

比如在下面这篇文章中

Langchian.js |Embedding & Vector Store👈| 数据向量化后这样储存😱

我使用如下代码检索 faiss向量数据库 中的数据

//加载向量储存
const vectorstore = await FaissStore.load(directory, embeddings);
//从向量数据库中创建一个检索器
const retriever = vectorstore.asRetriever(2);
//使用Runnable API进行进行检索
const res = await retriever.invoke("日本怎么称呼我们中国?");
console.log(res);

我们使用 asRetriever 创建检索器 retriever , 由于支持 LCEL 语法 , 且 retrieve 实现了 Runnable , 所以可以直接调用 invoke 方法 .

以上就是创建一个简单的检索器 , 并且进行一次简单的搜索 ~

为了方便些写 demo , 后面我将使用内存向量数据库MemoryVectorStore 代替本地向量数据库

多重提问检索器

所谓多重提问的检索器 , 不是说用户需要多重提问 , 而是说 : 用户提问一次 , 之后使用大模型从多个角度生成类似的提问 , 再到向量数据库中检索 , 从而提高结果的准确性.

比如 :怎么学 langchain ?

我们可以生成多种提问方式 :

  • 学习 langchan 的方法是什么 ?
  • 学习 langchain 的最佳方式是什么 ?
  • 有什么方法可以无痛切入 langchain ?

很显然 , 思路十分简单 , 而优点十分明显

  • 检索覆盖率高 , 秉持着“宁可错杀,不可放过”的原则检索所有相关文档
  • 适应多样性用户 , 每个人的表达都不一样 , 但是诉求总是相似的
  • 提高准确率 , 但凡有点相关就放入检索集合 , 之后按照相似性排序 , 确保答案是正确的

实现原理也肥肠 朴素 ,看下图 : 加一层 LLM 就行 , 我们可以使用 prompt 调教一下🤡👈 , 使其生成多个问题(实际上 langchain 已经写好了 prompt , 待会一起瞧瞧 ~)

总结 : 遇事不决 , 加一层 LLM ~🤡

改造官网 demo : How to generate multiple queries to retrieve data for 如下 :

import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { MultiQueryRetriever } from "langchain/retrievers/multi_query";
import { ChatOpenAI } from "@langchain/openai";
import dotenv from "dotenv";
dotenv.config();

async function main() {
  try {
    const embeddings = new OpenAIEmbeddings();
    const vectorstore = await MemoryVectorStore.fromTexts(
      [
        "建筑物由砖块建成",
        "建筑物由木材建成",
        "建筑物由石头建成",
        "汽车由金属制成",
        "汽车由塑料制成",
        "线粒体是细胞的动力工厂",
        "线粒体由脂质构成"
      ],
      // 为每个文本提供相应的元数据,确保数量一致
      [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }],
      embeddings
    );

    const model = new ChatOpenAI(
      {
        temperature: 0,
        modelName: "gpt-4o",
      }
    );

    const retriever = MultiQueryRetriever.fromLLM({
       // 用于改写的 LLM 模型,不限于 OpenAI 模型
      llm: model,
      // vector store 的 retriever,设置为每次检索三条数据 ,asRetriever()
      retriever: vectorstore.asRetriever(),
      // // 对每条输入用 LLM 改写生成四条同义不同表述的 query,默认值为 3
      queryCount: 4,
      //verbose: true,
    });

    //console.log(retriever.llmChain.prompt.template);

    const query = "线粒体是由什么组成的?";
    const retrievedDocs = await retriever.invoke(query);

    console.log(retrievedDocs);
  } catch (error) {
    console.error("发生错误:", error);
  }
}

// 调用 main 函数
main();

以上代码运行的结果是 :

[
  Document { pageContent: '线粒体是细胞的动力工厂', metadata: { id: 6 } },
  Document { pageContent: '线粒体由脂质构成', metadata: { id: 7 } },
  Document { pageContent: '汽车由金属制成', metadata: { id: 4 } },
  Document { pageContent: '建筑物由木材建成', metadata: { id: 2 } }
]

这些便是按照相似性检索的结果 ~ , 我们发现“线粒体由脂质构成" 比”线粒体是细胞的动力工厂 " 靠后 ,这显然需要改进 ,待会看

我们先看以下截取代码 , 打印的结果是什么 ?

    const retriever = MultiQueryRetriever.fromLLM({
      llm: model,
      retriever: vectorstore.asRetriever(),
      queryCount: 4,
      verbose: true,
    });

我提前打印了 retriever , 里面有很多结构 , 发现通过

console.log(retriever.llmChain.prompt.template);

可以打印检索器里面内置的提示词模板 , 内容如下

You are an AI language model assistant. Your task is
to generate {queryCount} different versions of the given user
question to retrieve relevant documents from a vector database.
By generating multiple perspectives on the user question,
your goal is to help the user overcome some of the limitations
of distance-based similarity search.

Provide these alternative questions separated by newlines between XML tags. For example:

<questions>
Question 1
Question 2
Question 3
</questions>

Original question: {question}

// 翻译版🤡
你是一个人工智能语言模型助手。你的任务是
生成给定用户问题的 {queryCount} 个不同版本,
以便从向量数据库中检索相关文档。
通过从多个角度生成用户问题,你的目标是帮助
用户克服基于距离的相似性搜索的一些局限性。
以 XML 标签分隔这些替代问题,每个问题之间用换行符隔开。
例如:
<questions>
  问题 1 
  问题 2 
  问题 3
</questions>。
原始问题:{question}。

这段提示词就是印证了 , 我上面说到的 ,通过 LLM 生成多个询问 , 需要用一段预先写好的提示词调教一下 LLM , 这里解开了上述疑惑

以下是 LLM 生成的多个提问

content": "<questions>\n
  线粒体的组成成分有哪些?\n
  构成线粒体的物质是什么?\n
  线粒体包含哪些元素?\n
  线粒体是由什么物质构成的?\n
</questions>"

上下文压缩检索器

检索的一个挑战是,通常你在将数据导入系统时不知道你的文档存储系统将面临的具体查询。

这意味着与查询最相关的信息可能被埋藏在大量无关文本的文档中。将整个文档传递到你的应用程序可能会导致更昂贵的LLM调用和较差的响应。

上下文压缩旨在解决这个问题。想法很简单:不是立即以原样返回检索到的文档,而是可以使用给定查询的上下文来压缩它们,从而只返回相关信息。 “压缩”在这里既指压缩单个文档的内容也指过滤掉大量文档

要使用上下文压缩检索器,需要:

  • 一个基础检索器(Retriever)
  • 文档压缩器(Document Compressor)

上下文压缩检索器将查询传递给基本检索器,获取初始文档并通过文档压缩器进行处理。

文档压缩器接收文档列表,通过减少文档内容或完全删除文档来缩短列表。

上述过程 , 图解如下 :

改造官方 demo 如下 :

How to do retrieval with contextual compression

import * as fs from "fs";
import { OpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";
import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract";
import dotenv from "dotenv";
dotenv.config();
const model = new OpenAI({
    temperature: 0.9,
    modelName: "gpt-4o",
})

const baseCompressor = LLMChainExtractor.fromLLM(model);

const text = fs.readFileSync("../data/少年中国说.txt", "utf8");

const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1000,
    chunkOverlap: 20,
});


const docs = await splitter.createDocuments([text]);
//console.log(docs)
const vectorStore = await MemoryVectorStore.fromDocuments(
    docs,
    new OpenAIEmbeddings()
);
/*
baseCompressor,也就是在压缩上下文时会调用 chain,这里接收任何符合 Runnable interface 的对象,也就是你可以自己实现一个 chain 作为 compressor
baseRetriever,在检索数据时用到的 retriever
*/
const retriever = new ContextualCompressionRetriever({
    baseCompressor,
    baseRetriever: vectorStore.asRetriever(2),
});

const retrievedDocs = await retriever.invoke(
    "中国少年应该怎么样做? 我记得一句是少年智则国智 ? 后面是什么忘了😭"
  );


  console.log(retrievedDocs);

输出结果如下 :

[
  Document {
    pageContent: '少年智则国智,少年富则国富,少年强则国强,少年独立则国独立,少年自由则国自由,少年进步则国进步,少年胜于欧洲则国胜 于欧洲,少年雄于地球则国雄于地球。',
    metadata: { loc: [Object] }
  }
]

这段代码怎么体现了上下文压缩 ? 为了我们可以跟踪 , 在上述代码设置

process.env.LANGCHAIN_VERBOSE = "true";

可以把 langchain 内部的执行过程打印出来

下面逐帧学习🤡👈

1. 检索器启动

[retriever/start] [1:retriever:ContextualCompressionRetriever] Entering Retriever run with input: {
  "query": "中国少年应该怎么样做? 我记得一句是少年智则国智 ? 后面是什么忘了😭"
}
  • 这是上下文压缩检索器开始运行的标志,输入的查询问题为 “中国少年应该怎么样做?我记得一句是少年智则国智?后面是什么忘了😭”。

2. 向量存储检索

[retriever/start] [1:retriever:ContextualCompressionRetriever > 2:retriever:VectorStoreRetriever] Entering Retriever run with input: {
  "query": "中国少年应该怎么样做? 我记得一句是少年智则国智 ? 后面是什么忘了😭"
}
  • 上下文压缩检索器调用向量存储检索器进行初始检索。向量存储检索器会在存储的文档向量中查找与查询问题最相关的文档。
[retriever/end] [1:retriever:ContextualCompressionRetriever > 2:retriever:VectorStoreRetriever] [588ms] Exiting Retriever run with output: {
  "documents": [
    {
      "pageContent": ...
    },
    {
      "pageContent": ...
    }
  ]
}
  • 向量存储检索器完成检索 , 返回了两篇相关文档。

3. 内容压缩处理

这里是核心思想

[chain/start] [1:chain:LLMChain] Entering Chain run with input: {
  "question": "中国少年应该怎么样做? 我记得一句是少年智则国智 ? 后面是什么忘了😭",
  "context": ...
}
  • 使用 LLMChain 对检索到的文档进行内容压缩处理。输入包含查询问题和检索到的文档内容。
[llm/start] [1:chain:LLMChain > 2:llm:OpenAIChat] Entering LLM run with input: {
  "prompts": [
    "Given the following question and context, 
    extract any part of the context *AS IS* that is relevant to answer the question. 
    If none of the context is relevant return NO_OUTPUT...."
  ]
}
  • LLMChain 调用 OpenAIChat 模型,提示模型从上下文中提取与问题相关的部分。
  • 压缩后再嵌入到 prompt 中 , 丢给大模型分析 , 更加切中要害
[llm/end] [1:chain:LLMChain > 2:llm:OpenAIChat] [1.52s] Exiting LLM run with output: {
  "generations": [
    [
      {
        "text": "少年智则国智,少年富则国富,少年强则国强,少年独立则国独立,少年自由则国自由,少年进步 则国进步,少年胜于欧洲则国胜于欧洲,少年雄于地球则国雄于地球。"
      }
    ]
  ]
}
  • OpenAIChat 模型完成处理 ,提取出与问题相关的内容。

4. 检索器结束

[retriever/end] [1:retriever:ContextualCompressionRetriever] [2.15s] Exiting Retriever run with output: {
  "documents": [
    {
      "pageContent": "少年智则国智,少年富则国富,少年强则国强,少年独立则国独立,少年自由则国自由,少年进步则国进步,少年胜于欧洲则国胜于欧洲,少年雄于地球则国雄于地球。",
      "metadata": {
        "loc": {
          "lines": {
            "from": 18,
            "to": 18
          }
        }
      }
    }
  ]
}
  • 上下文压缩检索器完成整个检索和压缩过程,最终返回一篇包含相关内容的文档。

整个过程是先使用向量存储检索器从文档库中查找与查询问题相关的文档,然后使用 LLMChainOpenAIChat 模型对检索到的文档进行内容压缩,提取出最相关的部分,最终返回给用户。

集成检索器

EnsembleRetriever 支持多个检索器的结果集成。

它使用 BaseRetriever 对象列表进行初始化。

EnsembleRetriever 根据互逆排名融合算法重新排序构成检索器的结果。

通过利用不同算法的优势, EnsembleRetriever 可以实现比任何单一算法更好的性能。

其中一种有用的模式是将关键词匹配检索器与密集检索器(如嵌入相似度)相结合,因为它们的优点是互补的。这可以被视为一种“混合搜索”。稀疏检索器擅长根据关键词找到相关文档,而密集检索器擅长根据语义相似度找到相关文档。

以下我们展示了简单自定义检索器的集成,该检索器直接返回包含输入查询的文档

// 引入集成检索器,用于将多个检索器组合起来进行检索
import { EnsembleRetriever } from "langchain/retrievers/ensemble";
// 引入内存向量存储,可将文档存储为向量,便于后续检索操作
import { MemoryVectorStore } from "langchain/vectorstores/memory";
// 引入 OpenAI 嵌入,利用 OpenAI 服务将文本转换为向量表示
import { OpenAIEmbeddings } from "@langchain/openai";
// 引入基础检索器类和其输入类型定义,为自定义检索器提供基础功能
import { BaseRetriever, BaseRetrieverInput } from "@langchain/core/retrievers";
// 引入文档类,用于表示存储的文本及其元数据
import { Document } from "@langchain/core/documents";
// 引入 dotenv 模块,用于加载环境变量
import dotenv from "dotenv";

// 加载 .env 文件中的环境变量
dotenv.config();

// 自定义一个简单的检索器类,继承自 BaseRetriever
class SimpleCustomRetriever extends BaseRetriever {
    // 命名空间,可用于标识和组织检索器
    lc_namespace = [];
    // 用于存储待检索的文档数组
    documents;

    // 构造函数,接收包含文档数组和基础检索器输入的对象
    constructor(fields) {
        // 调用父类的构造函数
        super(fields);
        // 将传入的文档数组赋值给类的 documents 属性
        this.documents = fields.documents;
    }

    // 异步方法,用于获取与查询相关的文档
    async _getRelevantDocuments(query) {
        // 过滤文档数组,返回页面内容包含查询关键词的文档
        return this.documents.filter((document) =>
            document.pageContent.includes(query)
        );
    }
}

// 定义第一组文档,每个文档包含页面内容和元数据
const docs1 = [
    new Document({ pageContent: "我喜欢苹果", metadata: { source: 1 } }),
    new Document({ pageContent: "我喜欢橙子", metadata: { source: 1 } }),
    new Document({
        pageContent: "苹果和橙子都是水果",
        metadata: { source: 1 },
    }),
];

// 使用第一组文档创建一个简单的关键词检索器
const keywordRetriever = new SimpleCustomRetriever({ documents: docs1 });

// 定义第二组文档,同样包含页面内容和元数据
const docs2 = [
    new Document({ pageContent: "你喜欢苹果", metadata: { source: 2 } }),
    new Document({ pageContent: "你喜欢橙子", metadata: { source: 2 } }),
];

// 异步操作,从第二组文档和 OpenAI 嵌入创建内存向量存储
const vectorstore = await MemoryVectorStore.fromDocuments(
    docs2,
    new OpenAIEmbeddings()
);

// 将向量存储转换为检索器
const vectorstoreRetriever = vectorstore.asRetriever();

// 创建集成检索器,将向量存储检索器和关键词检索器组合
const retriever = new EnsembleRetriever({
    // 要组合的检索器数组
    retrievers: [vectorstoreRetriever, keywordRetriever],
    // 每个检索器的权重,这里两个检索器权重相同
    weights: [0.5, 0.5],
});

// 定义查询关键词
const query = "苹果";
// 异步调用集成检索器进行查询,获取相关文档
const retrievedDocs = await retriever.invoke(query);

// 打印检索到的文档
console.log(retrievedDocs);

/*
  [
    Document { pageContent: '你喜欢苹果', metadata: { source: 2 } },
    Document { pageContent: '我喜欢苹果', metadata: { source: 1 } },
    Document { pageContent: '你喜欢橙子', metadata: { source: 2 } },
    Document {
      pageContent: '苹果和橙子都是水果',
      metadata: { source: 1 }
    }
  ]
*/

父文档检索器

在拆分文档的过程中 , 我们有这样的需求 :

  • 拆分的文档尽可能小 , 因为大模型接受的文本有限 , 一次性接受的 token 过多 , 会让大模型分神 , 就像我们听听力的时候 , 废话太多 , 总是把正确答案藏起来 ~
  • 拆分的文档足够长 , 为了每一个部分都保留着上下文 , 这样检索比较准确

根据块检索相关全部文档

How to retrieve the whole document for a chunk

多向量检索器

多向量检索器可以被视为父文档检索器的进阶版本。

除了像父文档检素器那样能够通素小片段来获得大块内容的功能,它还允许通过检索大块内容的摘要来获取其原文,或者

检索大块内容对应的假设问题来定位到相应的父文档。

可参考 youtube 教学视频

www.youtube.com/watch?v=gTC…

视频中,Lance讨论了多表示索引的概念,特别是在向量存储中的索引技术。他介绍了一种称为多表示索引的方法,该方法通过使用大型语言模型(LLM)对文档进行摘要,从而优化检索。该过程包括将文档分割并生成摘要,然后将摘要嵌入到向量存储中,同时将原始文档存储在文档存储中。通过这种方式,可以在检索时使用摘要找到相关文档,并在生成时使用完整文档,以确保LLM具有完整的上下文来回答问题。

  1. 通过摘要检索文档

具体执行流程如图所示。首先,将原始文档分解为若干文本块。接着,为每个文

块生成相应的摘要。这些摘要带有文本块的D并存入向量数据库。与此同时,已经拆分的文

本块也会连同其ID一起存入文档存储。当检索器收到查询请求时,它首先会基于查询内容检

素相关摘要,然后利用摘要中metadata所存储的文本块D在文档存储中定位并提取对应的文

本块。

  1. 通过假设提问检索文档

这种方法的核心思路在于围绕给定的分块内容构建假设性提问。具体而言,系统会针对每一个分块内容,生成多个与之紧密相关的假设性提问。这些生成的提问,不仅精准涵盖了分块中的关键信息,更为后续的检索工作提供了极具针对性的参考依据。

待假设性提问生成完毕,系统会将它们转化为向量形式,并存储到专门的向量存储库中。当用户发起检索请求时,系统会在该向量存储库中计算用户查询内容与这些假设性提问之间的相关度。一旦发现与用户查询高度匹配的假设性提问,系统便能借助该假设性提问所对应的分块索引,快速定位到原始的分块内容。具体的执行流程如图

自检查检索器

自查询检索器是一种具备自我查询能力的检索工具。当它接收到任何以自然语言形式提出的查询请求时,会按特定步骤展开工作。

首先,它借助查询构建器来构建一个结构化查询对象。这个结构化查询对象主要用于对向量存储中的元数据进行查询,能够精准地明确查询的关键要素和条件。

接着,查询翻译器会发挥作用,它将结构化查询对象中的内容转换为当前所使用的向量存储能够识别的查询语法。如此一来,就可以在向量存储中顺利执行该查询请求。

自查询检索器的优势显著,它不仅能够基于用户输入与存储的文档进行语义相关性匹配,还可以从用户的查询请求里提取出元数据的过滤条件,并依据这些条件开展过滤操作,从而更精准地筛选出符合要求的结果。

具体的执行流程可参考图

总结

检索器种类很多 , 根据实际需求才能更好理解 , 这篇文章就算给个大纲 , 做到预先了解 ~ , 不过这图画的是辛苦 ~ ,

各位点个赞吧 ~