LangChain——RAG 第一部分:索引你的数据

243 阅读37分钟

在上一章中,你了解了使用LangChain创建LLM应用程序的关键构建块。你还构建了一个简单的AI聊天机器人,该机器人由发送到模型的提示和模型生成的输出组成。但这个简单的聊天机器人有一些重大限制。

如果你的用例需要模型没有训练过的知识怎么办?例如,假设你想使用AI提问关于某个公司的问题,但相关信息存储在私人PDF文件或其他类型的文档中。虽然我们已经看到模型提供商在不断丰富他们的训练数据集,以包括世界上越来越多的公共信息(无论这些信息以什么格式存储),但LLM的知识库中仍然存在两个主要限制:

  1. 私人数据
    定义上,未公开的数据不会被包含在LLM的训练数据中。
  2. 时事
    训练LLM是一个成本高昂且耗时的过程,可能需要数年的时间,其中数据收集是最初的步骤之一。这导致了所谓的知识截止期,或者是LLM无法了解现实世界事件的日期;通常这是训练集最终定稿的日期。根据不同的模型,这个日期可能是几个月到几年以前。

无论是哪种情况,模型很可能会产生幻觉(生成误导性或错误的信息),并做出不准确的回应。调整提示也无法解决问题,因为它依赖于模型当前的知识。

目标:为LLM选择相关的上下文

如果你需要的私人/当前数据只是几页文字,那么这一章会简短很多:你只需要在每次发送给模型的提示中包括整个文本,就能使这些信息对LLM可用。

将数据提供给LLM的挑战首先是数量问题。你拥有的信息比每个发送给LLM的提示所能包含的要多。每次调用模型时,你应该包括哪些你大量文本中的小子集?换句话说,如何选择(借助模型)哪些文本对于回答每个问题最为相关?

在本章和下一章中,你将学习如何通过两个步骤来克服这个挑战:

  1. 索引你的文档,即以一种方式对它们进行预处理,使你的应用能够轻松找到每个问题最相关的文档。
  2. 从索引中检索这些外部数据,并将其作为LLM的上下文,以便基于你的数据生成准确的输出。

本章重点介绍索引,这是第一步,涉及将文档预处理成LLM能够理解和搜索的格式。这种技术叫做检索增强生成(RAG) 。但在我们开始之前,让我们讨论一下为什么你的文档需要预处理。

假设你想使用LLM分析特斯拉2022年年报中的财务表现和风险,该报告以PDF格式存储为文本。你的目标是能够提出类似“特斯拉在2022年面临的主要风险是什么?”这样的问题,并根据文档中风险因素部分的上下文得到类似人类的回答。

分解来说,为了实现这一目标,你需要采取四个关键步骤(如图2-1所示):

  1. 从文档中提取文本。
  2. 将文本分割成可管理的块。
  3. 将文本转换成计算机可以理解的数字。
  4. 将这些文本的数字表示存储在一个便于快速检索相关部分的地方,以便回答给定问题。

image.png

图2-1展示了文档预处理和转换的流程,这个过程被称为摄取(ingestion)。摄取就是将你的文档转换为计算机可以理解和分析的数字,并将其存储在一种特殊类型的数据库中,以便高效地检索。这些数字正式被称为嵌入(embeddings),而这种特殊类型的数据库被称为向量存储(vector store)。让我们更仔细地看看嵌入是什么,以及它们为什么如此重要,我们从比LLM驱动的嵌入更简单的例子开始。

嵌入:将文本转换为数字

嵌入指的是将文本表示为一系列(长)数字。这是一种有损表示——也就是说,你不能从这些数字序列中恢复出原始文本,因此通常会同时存储原始文本和这种数字表示。

那么,为什么要这么做呢?因为你可以获得处理数字时的灵活性和强大功能:你可以对单词进行数学运算!让我们看看为什么这很令人兴奋。

LLM之前的嵌入

早在LLM之前,计算机科学家就已经在使用嵌入——例如,用于使网站具备全文搜索功能,或者将电子邮件分类为垃圾邮件。让我们看一个例子:

假设有以下三句话:

  1. What a sunny day.
  2. Such bright skies today.
  3. I haven’t seen a sunny day in weeks.

列出它们中的所有独特单词:what、a、sunny、day、such、bright等等。

对于每句话,逐个单词判断,如果该单词不存在则标记为0,如果出现一次则标记为1,如果出现两次则标记为2,以此类推。

表2-1展示了结果。

表2-1. 三个句子的单词嵌入

单词What a sunny day.Such bright skies today.I haven’t seen a sunny day in weeks.
what100
a101
sunny101
day101
such010
bright010
skies010
today010
I001
haven’t001
seen001
in001
weeks001

在这个模型中,"I haven’t seen a sunny day in weeks"的嵌入是数字序列:0 1 1 1 0 0 0 0 1 1 1 1 1。这被称为“词袋模型(bag-of-words model)”,这些嵌入也被称为稀疏嵌入(或稀疏向量——“向量”是数字序列的另一种说法),因为很多数字将为0。大多数英语句子只使用了所有现有英语单词的一个非常小的子集。

你可以成功地使用这个模型来进行:

  1. 关键词搜索
    你可以找到哪些文档包含给定的单词或词组。
  2. 文档分类
    你可以为一组之前标记为垃圾邮件或非垃圾邮件的示例计算嵌入,求出它们的平均值,并获得每个类别(垃圾邮件或非垃圾邮件)的平均单词频率。然后,每个新文档将与这些平均值进行比较,并据此进行分类。

这里的局限性是,模型没有意识到语义,仅仅知道实际使用的单词。例如,“sunny day”和“bright skies”的嵌入看起来非常不同,实际上它们没有共同的单词,尽管我们知道它们有相似的意思。或者在电子邮件分类问题中,潜在的垃圾邮件发送者可以通过用同义词替换常见的“垃圾邮件词汇”来欺骗过滤器。

在下一节中,我们将看到语义嵌入如何通过使用数字来表示文本的意义,而不是文本中出现的确切单词,从而解决这个局限性。

基于LLM的嵌入

我们将跳过其中的所有机器学习发展,直接进入基于LLM的嵌入。你只需要知道,从前面一节中概述的简单方法到本节描述的复杂方法之间,经历了一个逐步发展的过程。

你可以将嵌入模型看作是LLM训练过程的一个分支。如果你记得前言中提到的,LLM的训练过程(通过从大量书面文本中学习)使得LLM能够完成一个提示(或输入),并给出最合适的延续(输出)。这种能力源于对单词和句子意义的理解,并且这种理解是在训练文本中单词如何组合使用的上下文中学到的。对提示意义(或语义)的理解可以被提取为输入文本的数字表示(或嵌入),并且也可以直接用于一些非常有趣的应用场景。

实际上,大多数嵌入模型仅为此目的而训练,通常遵循与LLM相似的架构和训练过程,因为这样更加高效,并且能够产生更高质量的嵌入。

因此,嵌入模型是一种算法,它接收一段文本并输出其意义的数字表示——技术上来说,是一个长的浮动小数(十进制)数字列表,通常包含100到2,000个数字,或者说是维度。这些也被称为密集嵌入,与前一节中的稀疏嵌入相对,因为这里通常所有维度都与0不同。

提示
不同的模型会生成不同的数字和不同大小的列表。所有这些都是每个模型特有的;也就是说,即使列表的大小相同,你也不能比较不同模型的嵌入。始终避免将不同模型的嵌入结合起来。

语义嵌入解释

考虑这三个单词:lion(狮子)、pet(宠物)、dog(狗)。直观地看,这些单词中哪一对具有相似的特征呢?显然的答案是“pet”和“dog”。但是计算机并没有能力接触到这种直觉或对英语语言的细微理解。为了让计算机区分狮子、宠物或狗,你需要能够将它们转换为计算机的语言,也就是数字。

图2-2展示了将每个单词转换为保留其意义的假设数字表示的过程。

image.png

图2-2展示了每个单词及其对应的语义嵌入。请注意,这些数字本身没有特定的意义,而是两个意思相近的单词(或句子)对应的数字序列应该比无关单词的数字序列更接近。如你所见,每个数字都是浮动小数值,每个数字代表一个语义维度。接下来,让我们看看“更接近”是什么意思:

如果我们将这些向量绘制在三维空间中,可能会像图2-3那样。

image.png

图2-3显示了宠物和狗的向量之间的距离比狮子的向量更接近。我们还可以观察到,每个图之间的角度会根据它们的相似性而有所变化。例如,宠物和狮子之间的角度比宠物和狗之间的角度要大,这表明后者的单词对有更多的相似性。两个向量之间的角度越小或距离越短,它们的相似性就越高。

一种有效的计算多维空间中两个向量相似度的方法是称为余弦相似度。余弦相似度计算向量的点积,并将其除以它们的模的乘积,输出一个介于-1和1之间的数字,其中0表示向量之间没有相关性,-1表示它们完全不相似,1表示它们完全相似。所以,在我们这里的三个单词的例子中,宠物和狗之间的余弦相似度可能是0.75,而宠物和狮子之间可能是0.1。

将句子转换为捕捉语义意义的嵌入,并执行计算以找到不同句子之间的语义相似度,使我们能够让LLM找到最相关的文档,以回答有关大量文本(例如我们的特斯拉PDF文档)的问题。现在你已经了解了整体概念,让我们回顾一下预处理文档的第一步(索引)。

嵌入的其他用途

这些数字和向量序列具有许多有趣的属性:

  1. 如你所学,如果你把向量看作是描述高维空间中的一个点,靠得更近的点具有更相似的含义,因此可以使用距离函数来衡量相似性。

  2. 紧密相邻的点可以认为是相关的;因此,聚类算法可以用来识别主题(或点的聚类),并将新的输入分类到这些主题中。

  3. 如果你将多个嵌入取平均值,那么平均嵌入可以表示该组的整体含义;也就是说,你可以通过以下方式嵌入一本长文档(例如本书):

    • 分别嵌入每一页
    • 将所有页面的嵌入取平均作为书籍的嵌入
  4. 你可以通过使用加法和减法等基础数学运算“穿越”语义空间:例如,操作 king – man + woman = queen。如果你取 king 的语义(或语义嵌入),减去 man 的语义,假设你会得到更抽象的君主(monarch)意义,此时,如果你加上 woman 的语义,你就接近了 queen 的语义(或嵌入)。

  5. 有些模型可以为非文本内容(例如图像、视频和声音)生成嵌入,除了文本之外。这使得例如能够为给定的句子找到最相似或最相关的图像。

我们在本书中不会探讨所有这些属性,但了解它们可以用于多种应用场景是很有用的,例如:

  • 搜索
    找到与新查询最相关的文档
  • 聚类
    给定一组文档,将它们分为不同的组(例如主题)
  • 分类
    将新文档分配到先前识别的组或标签中(例如,主题)
  • 推荐
    给定一篇文档,展示相似的文档
  • 检测异常
    识别与先前见过的文档非常不同的文档

我们希望这能给你一些直觉,嵌入是相当多功能的,并且可以在你未来的项目中得到很好的应用。

将文档转换为文本

如本章开头所提到的,预处理文档的第一步是将其转换为文本。为了实现这一点,你需要构建逻辑来解析并提取文档,同时尽量减少质量的损失。幸运的是,LangChain提供了文档加载器,它们处理解析逻辑,并允许你将数据从各种来源加载到一个包含文本和相关元数据的Document类中。

例如,考虑一个简单的.txt文件。你可以简单地导入LangChain的TextLoader类来提取文本,如下所示:

Python

from langchain_community.document_loaders import TextLoader

loader = TextLoader("./test.txt")
loader.load()

JavaScript

import { TextLoader } from "langchain/document_loaders/fs/text";

const loader = new TextLoader("./test.txt");

const docs = await loader.load();

输出:

[Document(page_content='text content \n', metadata={'line_number': 0, 'source': './test.txt'})]

上面的代码块假设你当前目录下有一个名为test.txt的文件。所有LangChain文档加载器的使用遵循类似的模式:

  1. 从长长的集成列表中选择适合你文档类型的加载器。
  2. 创建该加载器的实例,并配置相关参数,包括文档的位置(通常是文件系统路径或网址)。
  3. 通过调用load()方法加载文档,返回一个准备好传递到下一阶段的文档列表(稍后会详细介绍)。

除了.txt文件,LangChain还提供了其他常见文件类型的文档加载器,包括.csv、.json和Markdown,并且与Slack和Notion等流行平台有集成。

例如,你可以使用WebBaseLoader从网页URL加载HTML并将其解析为文本:

首先安装beautifulsoup4包:

pip install beautifulsoup4

Python

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader("https://www.langchain.com/")
loader.load()

JavaScript

// 安装cheerio:npm install cheerio
import { 
  CheerioWebBaseLoader 
} from "@langchain/community/document_loaders/web/cheerio";

const loader = new CheerioWebBaseLoader("https://www.langchain.com/");

const docs = await loader.load();

对于我们的特斯拉PDF用例,我们可以使用LangChain的PDFLoader从PDF文档中提取文本:

首先安装PDF解析库:

pip install pypdf

Python

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("./test.pdf")
pages = loader.load()

JavaScript

// 安装PDF解析库:npm install pdf-parse

import { PDFLoader } from "langchain/document_loaders/fs/pdf";

const loader = new PDFLoader("./test.pdf");

const docs = await loader.load();

文本已从PDF文档中提取并存储在Document类中。但有一个问题。加载的文档超过了100,000个字符,因此它无法适应绝大多数LLM或嵌入模型的上下文窗口。为了克服这个限制,我们需要将Document拆分成可管理的文本块,然后将其转换为嵌入并进行语义搜索,这将引导我们进入第二步(检索)。

提示
LLM和嵌入模型设计时对它们可以处理的输入和输出令牌的大小有硬性限制。这个限制通常被称为上下文窗口,通常适用于输入和输出的组合;也就是说,如果上下文窗口为100(我们稍后会讨论单位),而你的输入长度为90,则输出的最大长度为10。上下文窗口通常以令牌数来衡量,例如8,192个令牌。令牌,如前言中提到的,是将文本表示为数字的方式,每个令牌通常涵盖三到四个字符的英语文本。

将您的文本拆分成块

乍一看,将一大段文本拆分成多个块似乎是个简单的任务,但保持语义相关(即有意义的联系)的文本块在一起实际上是一个复杂的过程。为了简化将大文档拆分成小而有意义的文本块,LangChain 提供了 RecursiveCharacterTextSplitter,它的工作方式如下:

  1. 接收一个分隔符列表,按重要性顺序排列。默认情况下,这些分隔符为:

    • 段落分隔符:\n\n
    • 行分隔符:\n
    • 单词分隔符:空格字符
  2. 为了符合给定的块大小,例如 1000 个字符,首先从段落开始拆分。

  3. 对于任何大于所需块大小的段落,使用下一个分隔符进行拆分:按行拆分。一直拆分直到所有块的大小小于所需长度,或者没有更多的分隔符可用。

  4. 将每个块作为文档发出,同时传入原始文档的元数据,并附加关于在原文档中位置的额外信息。

让我们看一个例子:

Python 示例

from langchain_text_splitters import RecursiveCharacterTextSplitter

loader = TextLoader("./test.txt")  # 或其他加载器
docs = loader.load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
)
splitted_docs = splitter.split_documents(docs)

JavaScript 示例

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const loader = new TextLoader("./test.txt"); // 或其他加载器
const docs = await loader.load();

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

const splittedDocs = await splitter.splitDocuments(docs)

在上面的代码中,由文档加载器创建的文档被拆分成每个块最多 1000 个字符,每个块之间有 200 个字符的重叠,以保持上下文。结果仍然是一个文档列表,其中每个文档的长度不超过 1000 个字符,并且根据文本的自然划分进行拆分——段落、换行符,最后是单词。这使用文本的结构来保持每个块都是一致的、可读的文本片段。

RecursiveCharacterTextSplitter 还可以用于将代码语言和 Markdown 拆分成语义块。通过使用特定于每种语言的关键字作为分隔符来完成拆分,这确保了例如每个函数的主体会保存在同一个块中,而不是被拆分成多个块。通常,由于编程语言的结构比普通文本更为严谨,因此在块之间使用重叠的需求较少。LangChain 为一些流行语言(如 Python、JS、Markdown、HTML 等)提供了分隔符。以下是一个例子:

Python 示例

from langchain_text_splitters import (
    Language,
    RecursiveCharacterTextSplitter,
)

PYTHON_CODE = """
def hello_world():
    print("Hello, World!")

# Call the function
hello_world()
"""
python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON, chunk_size=50, chunk_overlap=0
)
python_docs = python_splitter.create_documents([PYTHON_CODE])

JavaScript 示例

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const PYTHON_CODE = `
def hello_world():
  print("Hello, World!")

# Call the function
hello_world()
`;

const pythonSplitter = RecursiveCharacterTextSplitter.fromLanguage("python", {
  chunkSize: 50,
  chunkOverlap: 0,
});
const pythonDocs = await pythonSplitter.createDocuments([PYTHON_CODE]);

输出结果:

[Document(page_content='def hello_world():\n    print("Hello, World!")'), Document(page_content='# Call the function\nhello_world()')]

注意几点:

  • 我们仍然使用 RecursiveCharacterTextSplitter,但现在我们为特定语言创建了一个实例,使用 from_language 方法。这个方法接受语言名称,并设置块大小等常规参数。
  • 我们现在使用 create_documents 方法,它接受一个字符串列表,而不是之前的文档列表。当你要拆分的文本不是通过文档加载器获取的,只是纯文本字符串时,这个方法非常有用。
  • 你还可以使用 create_documents 的可选第二个参数来传递与每个文本字符串关联的元数据。这个元数据列表应该与字符串列表的长度相同,它将用来填充每个返回的文档的元数据字段。

对于 Markdown 文本,我们还可以使用元数据参数来做进一步操作:

Python 示例

markdown_text = """
# LangChain

⚡ Building applications with LLMs through composability ⚡

## Quick Install

```bash
pip install langchain

As an open source project in a rapidly developing field, we are extremely open to contributions. """

md_splitter = RecursiveCharacterTextSplitter.from_language( language=Language.MARKDOWN, chunk_size=60, chunk_overlap=0 ) md_docs = md_splitter.create_documents([markdown_text], [{"source": "www.langchain.com"}])

**JavaScript 示例**  
```javascript
const markdownText = `
# LangChain

⚡ Building applications with LLMs through composability ⚡

## Quick Install

```bash
pip install langchain
```

As an open source project in a rapidly developing field, we are extremely 
  open to contributions.
`;

const mdSplitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
  chunkSize: 60,
  chunkOverlap: 0,
});
const mdDocs = await mdSplitter.createDocuments([markdownText], 
  [{"source": "https://www.langchain.com"}]);

输出结果:

[Document(page_content='# LangChain',     metadata={"source": "https://www.langchain.com"}), Document(page_content='⚡ Building applications with LLMs through composability 
    ⚡', metadata={"source": "https://www.langchain.com"}), Document(page_content='## Quick Install\n\n```bash',     metadata={"source": "https://www.langchain.com"}), Document(page_content='pip install langchain',     metadata={"source": "https://www.langchain.com"}), Document(page_content='```', metadata={"source": "https://www.langchain.com"}), Document(page_content='As an open source project in a rapidly developing field, 
    we', metadata={"source": "https://www.langchain.com"}), Document(page_content='are extremely open to contributions.',     metadata={"source": "https://www.langchain.com"})]

注意两点:

  1. 文本被拆分到 Markdown 文档中的自然停止点;例如,标题在一个块中,标题下方的文本在另一个块中,依此类推。
  2. 我们在第二个参数中传递的元数据被附加到每个生成的文档上,这使得你能够跟踪文档来源,并了解原文的位置。

生成文本嵌入

LangChain 还提供了一个 Embeddings 类,用于与文本嵌入模型进行交互,包括 OpenAI、Cohere 和 Hugging Face,并生成文本的向量表示。该类提供了两个方法:一个用于嵌入文档,另一个用于嵌入查询。前者接受一个文本字符串列表作为输入,而后者接受一个单独的文本字符串。

以下是使用 OpenAI 的嵌入模型嵌入文档的示例:

Python 示例

from langchain_openai import OpenAIEmbeddings

model = OpenAIEmbeddings()

embeddings = model.embed_documents([
    "Hi there!",
    "Oh, hello!",
    "What's your name?",
    "My friends call me World",
    "Hello World!"
])

JavaScript 示例

import { OpenAIEmbeddings } from "@langchain/openai";

const model = new OpenAIEmbeddings();

const embeddings = await embeddings.embedDocuments([
  "Hi there!",
  "Oh, hello!",
  "What' s your name?",
  "My friends call me World",
  "Hello World!"
]);

输出结果:

[  [    -0.004845875, 0.004899438, -0.016358767, -0.024475135, -0.017341806,    0.012571548, -0.019156644, 0.009036391, -0.010227379, -0.026945334,    0.022861943, 0.010321903, -0.023479493, -0.0066544134, 0.007977734,    0.0026371893, 0.025206111, -0.012048521, 0.012943339, 0.013094575,    -0.010580265, -0.003509951, 0.004070787, 0.008639394, -0.020631202,    ... 1511 more items  ],
  [    -0.009446913, -0.013253193, 0.013174579, 0.0057552797, -0.038993083,    0.0077763423, -0.0260478, -0.0114384955, -0.0022683728, -0.016509168,    0.041797023, 0.01787183, 0.00552271, -0.0049789557, 0.018146982,    -0.01542166, 0.033752076, 0.006112323, 0.023872782, -0.016535373,    -0.006623321, 0.016116094, -0.0061090477, -0.0044155475, -0.016627092,    ... 1511 more items  ],
  ... 3 more items
]

注意,您可以同时嵌入多个文档;推荐这样做,而不是逐一嵌入,因为这会更高效(由于这些模型的构建方式)。返回的结果是一个包含多个数字列表的列表——每个内部列表是一个向量或嵌入,正如前面所解释的那样。

现在,让我们看一个端到端的示例,使用到我们到目前为止看到的三种功能:

  1. 文档加载器:将任何文档转换为纯文本
  2. 文本拆分器:将每个大文档拆分成多个小文档
  3. 嵌入模型:为每个拆分文档创建数字表示,捕捉其语义

以下是代码:

Python 示例

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings

## 加载文档
loader = TextLoader("./test.txt")
doc = loader.load()

"""
[
    Document(page_content='Document loaders\n\nUse document loaders to load data 
        from a source as `Document`'s. A `Document` is a piece of text\nand 
        associated metadata. For example, there are document loaders for 
        loading a simple `.txt` file, for loading the text\ncontents of any web 
        page, or even for loading a transcript of a YouTube video.\n\nEvery 
        document loader exposes two methods:\n1. "Load": load documents from 
        the configured source\n2. "Load and split": load documents from the 
        configured source and split them using the passed in text 
        splitter\n\nThey optionally implement:\n\n3. "Lazy load": load 
        documents into memory lazily\n', metadata={'source': 'test.txt'})
]
"""

## 拆分文档
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=20,
)
chunks = text_splitter.split_documents(doc)

## 生成嵌入
embeddings_model = OpenAIEmbeddings()
embeddings = embeddings_model.embed_documents(
    [chunk.page_content for chunk in chunks]
)
"""
[[0.0053587136790156364,
 -0.0004999046213924885,
  0.038883671164512634,
 -0.003001077566295862,
 -0.00900818221271038, ...], ...]
"""

JavaScript 示例

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OpenAIEmbeddings } from "@langchain/openai";

// 加载文档
const loader = new TextLoader("./test.txt");
const docs = await loader.load();

// 拆分文档
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const chunks = await splitter.splitDocuments(docs)

// 生成嵌入
const model = new OpenAIEmbeddings();
await embeddings.embedDocuments(chunks.map(c => c.pageContent));

一旦生成了文档的嵌入,下一步就是将它们存储在一个被称为向量存储的特殊数据库中。

将嵌入存储在向量存储中

在本章前面,我们讨论了余弦相似度计算,用于衡量向量在向量空间中的相似性。向量存储是一种专门设计用于存储向量并高效快速地执行复杂计算(如余弦相似度)的数据库。

与专门存储结构化数据(如 JSON 文档或符合关系型数据库模式的数据)的传统数据库不同,向量存储处理的是非结构化数据,包括文本和图像。像传统数据库一样,向量存储能够执行创建、读取、更新、删除(CRUD)和搜索操作。

向量存储开启了广泛的应用场景,包括利用 AI 回答关于大文档的问题的可扩展应用,如图 2-4 所示。

image.png

将嵌入存储在向量存储中

在本章早些时候,我们讨论了余弦相似度计算,用于衡量向量在向量空间中的相似性。向量存储是一个专门设计用于存储向量并高效快速地执行复杂计算(如余弦相似度)的数据库。

与专门存储结构化数据(如 JSON 文档或符合关系型数据库模式的数据)的传统数据库不同,向量存储处理的是非结构化数据,包括文本和图像。像传统数据库一样,向量存储能够执行创建、读取、更新、删除(CRUD)和搜索操作。

向量存储开启了广泛的应用场景,包括利用 AI 回答关于大文档的问题的可扩展应用,如图 2-4 所示。

使用 PGVector 设置

为了使用 Postgres 和 PGVector,您需要遵循几个设置步骤:

  1. 确保您的计算机上安装了 Docker,并按照操作系统的安装说明进行操作。

  2. 在终端中运行以下命令,这将启动一个 Postgres 实例,并在本地端口 6024 上运行:

    docker run \
        --name pgvector-container \
        -e POSTGRES_USER=langchain \
        -e POSTGRES_PASSWORD=langchain \
        -e POSTGRES_DB=langchain \
        -p 6024:5432 \
        -d pgvector/pgvector:pg16
    
  3. 打开 Docker 仪表板,您应该会看到 pgvector-container 旁边有一个绿色的运行状态。

  4. 保存连接字符串以供后续代码使用:

    postgresql+psycopg://langchain:langchain@localhost:6024/langchain
    

使用向量存储

在前面的嵌入部分,我们加载文档、拆分文档、嵌入每个块,并将其存储在 PGVector 中。以下是一个完整的代码示例:

Python 示例

# 首先,安装 langchain-postgres
from langchain_community.document_loaders import TextLoader
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_postgres.vectorstores import PGVector
from langchain_core.documents import Document
import uuid

# 加载文档并拆分成块
raw_documents = TextLoader('./test.txt').load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
documents = text_splitter.split_documents(raw_documents)

# 嵌入每个块并将其插入向量存储
embeddings_model = OpenAIEmbeddings()
connection = 'postgresql+psycopg://langchain:langchain@localhost:6024/langchain'
db = PGVector.from_documents(documents, embeddings_model, connection=connection)

JavaScript 示例

import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OpenAIEmbeddings } from "@langchain/openai";
import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";
import { v4 as uuidv4 } from 'uuid';

// 加载文档并拆分成块
const loader = new TextLoader("./test.txt");
const raw_docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const docs = await splitter.splitDocuments(raw_docs)

// 嵌入每个块并将其插入向量存储
const embeddings_model = new OpenAIEmbeddings();
const db = await PGVectorStore.fromDocuments(docs, embeddings_model, {
  postgresConnectionOptions: {
    connectionString: 'postgresql://langchain:langchain@localhost:6024/langchain'
  }
})

如上所示,我们重用了前面部分的代码,首先使用加载器加载文档,然后将文档拆分成更小的块。接着,我们实例化要使用的嵌入模型,在此例中为 OpenAI 的嵌入模型。请注意,您可以使用 LangChain 支持的任何其他嵌入模型。

接下来,我们创建了一个新的代码行,用于根据文档、嵌入模型和连接字符串创建向量存储。此操作将执行以下几项任务:

  • 建立与计算机上运行的 Postgres 实例的连接(请参阅“使用 PGVector 设置”部分)。
  • 如果这是首次运行,它将执行必要的设置步骤,如创建用于存储文档和向量的表。
  • 使用您选择的模型为每个传入的文档创建嵌入。
  • 将嵌入、文档元数据和文档的文本内容存储到 Postgres 中,准备进行搜索。

搜索文档

现在让我们看一下如何搜索文档:

Python 示例

db.similarity_search("query", k=4)

JavaScript 示例

await pgvectorStore.similaritySearch("query", 4);

此方法将通过以下过程找到最相关的文档(即您先前索引的文档):

  • 搜索查询(在此示例中为单词 "query")将被发送到嵌入模型以检索其嵌入。
  • 然后,它将在 Postgres 上运行查询,以查找与查询最相似的 N 个(在此例中为 4)先前存储的嵌入。
  • 最后,它将获取与每个嵌入相关的文本内容和元数据。

模型现在可以返回按与查询的相似性排序的文档列表——最相似的排在最前面,其次是第二最相似,依此类推。

向现有数据库添加更多文档

您还可以向现有数据库添加更多文档。以下是一个示例:

Python 示例

ids = [str(uuid.uuid4()), str(uuid.uuid4())]
db.add_documents(
    [
        Document(
            page_content="there are cats in the pond",
            metadata={"location": "pond", "topic": "animals"},
        ),
        Document(
            page_content="ducks are also found in the pond",
            metadata={"location": "pond", "topic": "animals"},
        ),
    ],
    ids=ids,
)

JavaScript 示例

const ids = [uuidv4(), uuidv4()];

await db.addDocuments(
  [
    {
      pageContent: "there are cats in the pond",
      metadata: {location: "pond", topic: "animals"}
    }, 
    {
      pageContent: "ducks are also found in the pond",
      metadata: {location: "pond", topic: "animals"}
    },
  ], 
  {ids}
);

我们使用的 add_documents 方法将执行与 fromDocuments 相似的过程:

  • 为您传入的每个文档创建嵌入,使用您选择的模型。
  • 将嵌入、文档的元数据和文本内容存储到 Postgres 中,准备进行搜索。

在此示例中,我们使用了可选的 ids 参数为每个文档分配标识符,这样我们以后可以更新或删除它们。

删除操作示例

以下是删除操作的示例:

Python 示例

db.delete(ids=[1])

JavaScript 示例

await db.delete({ ids: [ids[1]] })

这将删除通过其全局唯一标识符(UUID)插入的第二个文档。现在让我们看看如何以更系统的方式进行此操作。

跟踪文档的变化

与向量存储合作时的一个关键挑战是处理经常变化的数据,因为变化意味着需要重新索引。重新索引可能会导致嵌入的高昂重新计算成本以及重复已有内容。

幸运的是,LangChain 提供了一个索引 API,使得保持文档与向量存储同步变得更加容易。该 API 使用一个类(RecordManager)来跟踪文档写入向量存储的情况。在索引内容时,会为每个文档计算哈希值,并将以下信息存储在 RecordManager 中:

  • 文档哈希(包含页面内容和元数据的哈希值)
  • 写入时间
  • 来源 ID(每个文档的元数据应包含信息,以确定文档的最终来源)

此外,索引 API 提供了清理模式,帮助您决定如何删除向量存储中现有的文档。例如,如果您更改了文档的处理方式(在插入前的处理),或者源文档发生了变化,您可能希望删除任何与新索引文档来源相同的已有文档。如果某些源文档被删除,您将希望删除向量存储中所有现有的文档,并用重新索引的文档替换它们。

可用的清理模式如下:

  • None 模式:不进行任何自动清理,允许用户手动清理旧内容。
  • Incremental 和 Full 模式:如果源文档或派生文档的内容发生了变化,将删除内容的旧版本。
  • Full 模式:还会删除任何未包含在当前正在索引的文档中的文档。

以下是使用索引 API 和 Postgres 数据库作为记录管理器的示例:

Python 示例

from langchain.indexes import SQLRecordManager, index
from langchain_postgres.vectorstores import PGVector
from langchain_openai import OpenAIEmbeddings
from langchain.docstore.document import Document

connection = "postgresql+psycopg://langchain:langchain@localhost:6024/langchain"
collection_name = "my_docs"
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
namespace = "my_docs_namespace"

vectorstore = PGVector(
    embeddings=embeddings_model,
    collection_name=collection_name,
    connection=connection,
    use_jsonb=True,
)

record_manager = SQLRecordManager(
    namespace,
    db_url="postgresql+psycopg://langchain:langchain@localhost:6024/langchain",
)

# 如果架构不存在则创建
record_manager.create_schema()

# 创建文档
docs = [
    Document(page_content='there are cats in the pond', metadata={"id": 1, "source": "cats.txt"}),
    Document(page_content='ducks are also found in the pond', metadata={"id": 2, "source": "ducks.txt"}),
]

# 索引文档
index_1 = index(
    docs,
    record_manager,
    vectorstore,
    cleanup="incremental",  # 防止重复文档
    source_id_key="source",  # 使用 source 字段作为 source_id
)

print("Index attempt 1:", index_1)

# 第二次尝试索引时,不会再次添加文档
index_2 = index(
    docs,
    record_manager,
    vectorstore,
    cleanup="incremental",
    source_id_key="source",
)

print("Index attempt 2:", index_2)

# 如果我们修改了一个文档,新的版本将被写入,所有共享相同来源的旧版本将被删除。
docs[0].page_content = "I just modified this document!"

index_3 = index(
    docs,
    record_manager,
    vectorstore,
    cleanup="incremental",
    source_id_key="source",
)

print("Index attempt 3:", index_3)

JavaScript 示例

/** 
1. 确保 Docker 已安装并正在运行(https://docs.docker.com/get-docker/)
2. 运行以下命令启动 Postgres 容器:

docker run \
  --name pgvector-container \
  -e POSTGRES_USER=langchain \
  -e POSTGRES_PASSWORD=langchain \
  -e POSTGRES_DB=langchain \
  -p 6024:5432 \
  -d pgvector/pgvector:pg16
3. 使用下面的连接字符串连接到 Postgres 容器
*/

import { PostgresRecordManager } from '@langchain/community/indexes/postgres';
import { index } from 'langchain/indexes';
import { OpenAIEmbeddings } from '@langchain/openai';
import { PGVectorStore } from '@langchain/community/vectorstores/pgvector';
import { v4 as uuidv4 } from 'uuid';

const tableName = 'test_langchain';
const connectionString =
  'postgresql://langchain:langchain@localhost:6024/langchain';

// 加载文档,拆分成块
const config = {
  postgresConnectionOptions: {
    connectionString,
  },
  tableName: tableName,
  columns: {
    idColumnName: 'id',
    vectorColumnName: 'vector',
    contentColumnName: 'content',
    metadataColumnName: 'metadata',
  },
};

const vectorStore = await PGVectorStore.initialize(
  new OpenAIEmbeddings(),
  config
);

// 创建一个新的记录管理器
const recordManagerConfig = {
  postgresConnectionOptions: {
    connectionString,
  },
  tableName: 'upsertion_records',
};
const recordManager = new PostgresRecordManager(
  'test_namespace',
  recordManagerConfig
);

// 如果架构不存在,则创建
await recordManager.createSchema();

const docs = [
  {
    pageContent: 'there are cats in the pond',
    metadata: { id: uuidv4(), source: 'cats.txt' },
  },
  {
    pageContent: 'ducks are also found in the pond',
    metadata: { id: uuidv4(), source: 'ducks.txt' },
  },
];

// 第一次索引将索引这两个文档
const index_attempt_1 = await index({
  docsSource: docs,
  recordManager,
  vectorStore,
  options: {
    cleanup: 'incremental',  // 防止重复文档被索引
    sourceIdKey: 'source',  // 元数据中将用该字段作为 source_id
  },
});

console.log(index_attempt_1);

// 第二次尝试索引时会跳过,因为文档已经存在
const index_attempt_2 = await index({
  docsSource: docs,
  recordManager,
  vectorStore,
  options: {
    cleanup: 'incremental',
    sourceIdKey: 'source',
  },
});

console.log(index_attempt_2);

// 如果我们修改了一个文档,新的版本将被写入,所有共享相同来源的旧版本将被删除。
docs[0].pageContent = 'I modified the first document content';
const index_attempt_3 = await index({
  docsSource: docs,
  recordManager,
  vectorStore,
  options: {
    cleanup: 'incremental',
    sourceIdKey: 'source',
  },
});

console.log(index_attempt_3);

首先,您需要创建一个记录管理器,它跟踪哪些文档之前已被索引。然后,您使用 index 函数将新的文档列表与向量存储同步。在这个示例中,我们使用了增量模式,因此任何具有与先前文档相同 ID 的文档将被新版本替换。

索引优化

基本的 RAG 索引阶段涉及对给定文档的文本拆分和块嵌入。然而,这种基本方法会导致检索结果不一致,并且在数据源包含图像和表格时,容易出现较高的幻觉发生率。

为了提高索引阶段的准确性和性能,有多种策略可供选择。我们将在接下来的章节中讨论其中三种:MultiVectorRetriever、RAPTOR 和 ColBERT。

MultiVectorRetriever

包含文本和表格混合的文档不能仅仅通过文本拆分成块并嵌入作为上下文:整个表格可能会轻易丢失。为了解决这个问题,我们可以将用于回答合成的文档与用于检索器的参考文档解耦。图 2-5 展示了如何操作。

image.png

例如,在包含表格的文档中,我们可以首先生成并嵌入表格元素的摘要,确保每个摘要包含指向完整原始表格的 ID 引用。接下来,我们将原始的被引用表格存储在单独的文档存储中。最后,当用户的查询检索到表格摘要时,我们将整个被引用的原始表格作为上下文传递给最终的 LLM 提示,以进行答案合成。这种方法使我们能够为模型提供回答问题所需的完整上下文信息。

以下是一个示例。首先,让我们使用 LLM 生成文档的摘要:

Python 示例

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres.vectorstores import PGVector
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI
from langchain_core.documents import Document
from langchain.retrievers.multi_vector import MultiVectorRetriever
from langchain.storage import InMemoryStore
import uuid

connection = "postgresql+psycopg://langchain:langchain@localhost:6024/langchain"
collection_name = "summaries"
embeddings_model = OpenAIEmbeddings()

# 加载文档
loader = TextLoader("./test.txt", encoding="utf-8")
docs = loader.load()

print("length of loaded docs: ", len(docs[0].page_content))

# 拆分文档
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(docs)

# 继续代码,从这里开始:
prompt_text = "Summarize the following document:\n\n{doc}"

prompt = ChatPromptTemplate.from_template(prompt_text)
llm = ChatOpenAI(temperature=0, model="gpt-3.5-turbo")
summarize_chain = {
    "doc": lambda x: x.page_content} | prompt | llm | StrOutputParser()

# 批量处理摘要链
summaries = summarize_chain.batch(chunks, {"max_concurrency": 5})

接下来,让我们定义向量存储和文档存储,用于存储原始摘要及其嵌入:

Python 示例

# 用于索引子块的向量存储
vectorstore = PGVector(
    embeddings=embeddings_model,
    collection_name=collection_name,
    connection=connection,
    use_jsonb=True,
)

# 父文档的存储层
store = InMemoryStore()
id_key = "doc_id"

# 在向量存储中索引摘要,同时将原始文档保留在文档存储中:
retriever = MultiVectorRetriever(
    vectorstore=vectorstore,
    docstore=store,
    id_key=id_key,
)

# 从摘要改为块,因为我们需要与文档的长度相同
doc_ids = [str(uuid.uuid4()) for _ in chunks]

# 每个摘要通过 doc_id 与原始文档相链接
summary_docs = [
    Document(page_content=s, metadata={id_key: doc_ids[i]})
    for i, s in enumerate(summaries)
]

# 将文档摘要添加到向量存储中进行相似性搜索
retriever.vectorstore.add_documents(summary_docs)

# 将原始文档存储在文档存储中,通过 doc_ids 与它们的摘要关联
# 这样我们可以先高效地搜索摘要,再根据需要获取完整的文档
retriever.docstore.mset(list(zip(doc_ids, chunks)))

# 向量存储检索摘要
sub_docs = retriever.vectorstore.similarity_search(
    "chapter on philosophy", k=2
)

# 根据查询检索相关的完整上下文文档:
retrieved_docs = retriever.invoke("chapter on philosophy")

最后,这里是 JavaScript 的完整实现:

JavaScript 示例

import * as uuid from 'uuid';
import { MultiVectorRetriever } from 'langchain/retrievers/multi_vector';
import { OpenAIEmbeddings } from '@langchain/openai';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { InMemoryStore } from '@langchain/core/stores';
import { TextLoader } from 'langchain/document_loaders/fs/text';
import { Document } from '@langchain/core/documents';
import { PGVectorStore } from '@langchain/community/vectorstores/pgvector';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';

const connectionString =
  'postgresql://langchain:langchain@localhost:6024/langchain';
const collectionName = 'summaries';

const textLoader = new TextLoader('./test.txt');
const parentDocuments = await textLoader.load();
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 10000,
  chunkOverlap: 20,
});
const docs = await splitter.splitDocuments(parentDocuments);

const prompt = PromptTemplate.fromTemplate(
  `Summarize the following document:\n\n{doc}`
);

const llm = new ChatOpenAI({ modelName: 'gpt-3.5-turbo' });

const chain = RunnableSequence.from([
  { doc: (doc) => doc.pageContent },
  prompt,
  llm,
  new StringOutputParser(),
]);

// 批量处理摘要链
const summaries = await chain.batch(docs, {
  maxConcurrency: 5,
});

const idKey = 'doc_id';
const docIds = docs.map((_) => uuid.v4());
// 创建摘要文档并使用元数据链接到原始文档
const summaryDocs = summaries.map((summary, i) => {
  const summaryDoc = new Document({
    pageContent: summary,
    metadata: {
      [idKey]: docIds[i],
    },
  });
  return summaryDoc;
});

// 用于存储原始块的存储层
const byteStore = new InMemoryStore();

// 用于摘要的向量存储
const vectorStore = await PGVectorStore.fromDocuments(
  docs,
  new OpenAIEmbeddings(),
  {
    postgresConnectionOptions: {
      connectionString,
    },
  }
);

const retriever = new MultiVectorRetriever({
  vectorstore: vectorStore,
  byteStore,
  idKey,
});

const keyValuePairs = docs.map((originalDoc, i) => [docIds[i], originalDoc]);

// 使用检索器将原始块添加到文档存储
await retriever.docstore.mset(keyValuePairs);

// 向量存储独立检索小块
const vectorstoreResult = await retriever.vectorstore.similaritySearch(
  'chapter on philosophy',
  2
);
console.log(`summary: ${vectorstoreResult[0].pageContent}`);
console.log(
  `summary retrieved length: ${vectorstoreResult[0].pageContent.length}`
);

// 检索器返回更大的块结果
const retrieverResult = await retriever.invoke('chapter on philosophy');
console.log(
  `multi-vector retrieved chunk length: ${retrieverResult[0].pageContent.length}`
);

通过这种方法,我们可以先检索到摘要,再根据需要检索完整文档,确保我们能够为 LLM 提供完整的上下文信息,以获得更准确的答案。

RAPTOR:递归抽象处理用于树状组织检索

RAG 系统需要处理两类问题:一种是引用特定事实、仅在单一文档中找到的低级问题,另一种是提炼涵盖多个文档的概念的高级问题。使用典型的 k 最近邻(k-NN)检索文档块来处理这两类问题可能会面临挑战。

递归抽象处理用于树状组织检索(RAPTOR)是一种有效的策略,它涉及创建捕捉更高级概念的文档摘要,对这些文档进行嵌入和聚类,然后对每个聚类进行摘要。这是递归进行的,产生一个具有越来越高级概念的摘要树。然后,摘要和初始文档一起被索引,覆盖低到高层次的用户问题。如图 2-6 所示。

image.png

ColBERT:优化嵌入

在索引阶段使用嵌入模型的一个挑战是,它们将文本压缩为固定长度的(向量)表示,以捕捉文档的语义内容。尽管这种压缩对于检索非常有用,但嵌入不相关或冗余的内容可能会导致最终 LLM 输出中的幻觉。

解决这个问题的一种方法是:

  1. 为文档和查询中的每个词生成上下文嵌入。
  2. 计算并评分每个查询词与所有文档词之间的相似度。
  3. 将每个查询嵌入与任何文档嵌入的最大相似度得分求和,以获得每个文档的得分。

这产生了一种细致且有效的嵌入方法,有助于更好的检索。幸运的是,被称为 ColBERT 的嵌入模型正好提供了解决此问题的方案。

以下是我们如何使用 ColBERT 对数据进行最佳嵌入的示例:

Python 示例

# RAGatouille 是一个简化使用 ColBERT 的库
#! pip install -U ragatouille

from ragatouille import RAGPretrainedModel
RAG = RAGPretrainedModel.from_pretrained("colbert-ir/colbertv2.0")

import requests

def get_wikipedia_page(title: str):
    """
    获取维基百科页面的完整文本内容。

    :param title: str - 维基百科页面的标题。
    :return: str - 页面内容的完整文本作为原始字符串。
    """
    # 维基百科 API 端点
    URL = "https://en.wikipedia.org/w/api.php"

    # API 请求的参数
    params = {
        "action": "query",
        "format": "json",
        "titles": title,
        "prop": "extracts",
        "explaintext": True,
    }

    # 为遵守维基百科的最佳实践设置自定义 User-Agent 头
    headers = {"User-Agent": "RAGatouille_tutorial/0.0.1"}

    response = requests.get(URL, params=params, headers=headers)
    data = response.json()

    # 提取页面内容
    page = next(iter(data["query"]["pages"].values()))
    return page["extract"] if "extract" in page else None

full_document = get_wikipedia_page("Hayao_Miyazaki")

## 创建索引
RAG.index(
    collection=[full_document],
    index_name="Miyazaki-123",
    max_document_length=180,
    split_documents=True,
)

# 查询
results = RAG.search(query="What animation studio did Miyazaki found?", k=3)
results

# 使用 langchain 检索器
retriever = RAG.as_langchain_retriever(k=3)
retriever.invoke("What animation studio did Miyazaki found?")

通过使用 ColBERT,您可以提高 LLM 所使用的上下文中检索文档的相关性,从而使结果更加准确。

总结

在本章中,您学习了如何使用 LangChain 的各个模块为您的 LLM 应用程序准备和预处理文档。文档加载器使您能够从数据源中提取文本,文本拆分器帮助您将文档拆分为语义相似的块,而嵌入模型将文本转换为其含义的向量表示。

此外,向量存储允许您对这些嵌入执行 CRUD 操作,并进行复杂计算以计算语义相似的文本块。最后,索引优化策略使您的 AI 应用能够提高嵌入的质量,并准确地检索包含半结构化数据(包括表格)的文档。

在第 3 章中,您将学习如何根据查询高效地从向量存储中检索最相似的文档块,提供模型可以看到的上下文,然后生成准确的输出。

  1. Arvind Neelakantan 等人,“Text and Code Embeddings by Contrastive Pre-Training”,arXiv,2022年1月21日。
  2. Parth Sarthi 等人,“RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval”,arXiv,2024年1月31日。论文发表于 ICLR 2024。
  3. Keshav Santhanam 等人,“ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction”,arXiv,2021年12月2日。