上一章中,我们学习了 LangChain 用于创建 LLM 应用程序所使用的重要构建模块。还构建了一个简单的 AI 聊天机器人,包含发送给模型的提示以及模型生成的输出。但是,这个简单的聊天机器人存在一些重要的局限性。
如果我们的用例需要用到模型未经训练的知识该怎么办?例如,假设想使用 AI 来询问有关公司的问题,但信息在私有 PDF 或其他类型的文档中。虽然我们已经看到模型提供商在不断丰富其训练数据集,包含越来越多的公开信息(格式各异),但 LLM 的知识库中仍然存在两大局限:
- 私有数据
- 很显然,未公开的信息不会在 LLM 的训练数据中。
- 最新事件
- 训练一个 LLM 要经过昂贵且耗时的过程,可能跨越数年,而数据收集是最初的步骤之一。这导致了所谓的知识截止点,即 LLM 对此日期之后的真实世界事件一无所知;通常这是训练集最终确定的日期。根据不同的模型,有可能是过去几个月到几年的时间。
对这两种情况,模型很可能产生幻觉(找到具有误导性或虚假的信息)并以不准确的信息作为回应。调整提示也无法解决问题,因为它依赖于模型当前的知识。
相关代码请见GitHub仓库
目标:为 LLM 挑选相关上下文
如果你的 LLM 用例所需的唯一私有/最新数据只有一到两页文本,那么很容易,只需将全部文本放到发送给模型的每个提示中,即可将信息喂给 LLM。
使LLM获取信息的挑战首先是一个数量问题。在持有的信息量超出了发送给 LLM 的每个提示所能容纳的范围,那么每次调用模型时,应该从大量文本集合中包含哪个小子集?或者说,如何(在模型的帮助下)挑选出与回答每个问题最相关的文本?
在本章和下一章中,您将学习如何通过两个步骤克服这一挑战:
- 索引文档,即以一种应用程序可以轻松找到与每个问题最相关的文档的方式对其进行预处理。
- 从索引中检索这些外部数据,并将其用作上下文,以便 LLM 根据你的数据生成准确的输出。
本章重点介绍索引,即第一步,涉及到将文档预处理成 LLM 可以理解和搜索的格式。这种技术称为检索增强生成 (RAG)。但在开始之前,让我们讨论一下为什么你的文档需要预处理。
假设我们想使用 LLM 来分析 特斯拉 2022 年年度报告中的财务业绩和风险,该报告以 PDF 格式的文本存储。我们的目标是能够提出诸如“特斯拉在 2022 年面临哪些主要风险?”之类的问题,并根据文档风险因素部分的上下文获得类似人类的回答。
分解来看,为了实现这个目标,需要采取四个关键步骤(如图 2-1所示):
- 从文档中提取文本。
- 将文本分割成易于管理的小块。
- 将文本转换为计算机可以理解的数字。
- 将这些文本的数字表示存储在某个地方,以便能够轻松快速地检索文档的相关部分来回答给定的问题。
图 2-1. LLM 预处理文档的四个主要步骤
图 2-1表明了这种预处理和转换文档的流程,这个过程称为摄取 (ingestion)。摄取简单来说就是将文档转换为计算机可以理解和分析的数字,并将它们存储在一种特殊类型的数据库中以便高效检索。这些数字在形式上称为嵌入 (embeddings),而这种特殊类型的数据库称为向量存储 (vector store)。我们来更深入地了解什么是嵌入以及其重要性,先从 LLM 背后的嵌入更简单的内容开始。
嵌入:将文本转换为数字
嵌入指的是将文本表示为一个(长)数字序列。这是一种有损表示——也就是说,无法将这些数字序列恢复为原始文本,因此通常会同时存储原始文本和这种数字表示。
那干嘛还要画蛇添足呢?因为获得了处理数字所带来的灵活性和强大功能:比如可以对单词进行数学运算!我们来看看这有什么新奇之处。
LLM 纪元之前的嵌入技术
早在LLM 出现之前,计算机科学家就已经在使用嵌入技术了——例如,用于在网站中启用全文搜索功能或将电子邮件分类为垃圾邮件。我们来看一个例子:
- 看这三个句子:
- What a sunny day.(多么晴朗的一天。)
- Such bright skies today.(今天天空如此明亮。)
- I haven’t seen a sunny day in weeks.(我好几周没见过晴天了。)
- 列出其中去重后的单词:what、a、sunny、day、such、bright 等等。
- 对每个句子扫描单词,如果单词不存在则赋值为 0,如果在句子中出现一次则赋值为 1,如果出现两次则赋值为 2,以此类推。
表 2-1 展示了结果。
词语 | What a sunny day. | Such bright skies today. | I haven’t seen a sunny day in weeks. |
---|---|---|---|
what | 1 | 0 | 0 |
a | 1 | 0 | 1 |
sunny | 1 | 0 | 1 |
day | 1 | 0 | 1 |
such | 0 | 1 | 0 |
bright | 0 | 1 | 0 |
skies | 0 | 1 | 0 |
today | 0 | 1 | 0 |
I | 0 | 0 | 1 |
haven’t | 0 | 0 | 1 |
seen | 0 | 0 | 1 |
in | 0 | 0 | 1 |
weeks | 0 | 0 | 1 |
在这个模型中,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),这些嵌入也称为稀疏嵌入 (sparse embeddings)(或稀疏向量——向量是数字序列的另一个词),因为很多数字会是 0。大多数英语句子只使用现有英语单词全集中非常小的一个子集。
可以使用此模型进行:
- 关键词搜索
- 找到哪些文档包含给定的一个或多个词语。
- 文档分类
- 可以为先前标记为垃圾邮件或非垃圾邮件的示例集合计算嵌入,对其取平均,并获取每个类别(垃圾邮件或非垃圾邮件)的平均词频。然后,将新文档与这些平均值进行比较并相应地进行分类。
其局限性在于该模型没有对意义的感知,只知晓实际使用的词语。例如,sunny day 和 bright skies 的嵌入看起来非常不同。事实上,它们并没有共同的单词,尽管我们知道它们含义相近。或者,在电子邮件分类问题中,潜在的垃圾邮件发送者可以通过用同义词替换常见的“垃圾邮件词语”来欺骗过滤器。
在下一节中,我们将学习语义嵌入如何通过使用数字来表示文本的含义,而不是文本中找到的确切词语,来解决这个局限性。
基于 LLM 的嵌入
我们跳过介于两者之间的所有机器学习发展,直接进入基于 LLM 的嵌入。读者只需知道,从上一节概述的简单方法到本节描述的复杂方法,有一个渐进的演变过程。
可以将嵌入模型视为 LLM 训练过程的一个分支。前言中曾说到,LLM 训练过程(从大量书面文本中学习)使 LLM 能够以最合适的续写(输出)来完成提示(或输入)。这种能力源于对周围文本上下文中单词和句子含义的理解,这种理解是从训练文本中单词如何一起使用中学到的。这种对提示含义(或语义)的理解可以提取为输入文本的数字表示(或嵌入),并且可以直接用于一些非常有趣的用例。
实际上,大多数嵌入模型都是为此目的而专门训练的,遵循与 LLM 某种程度上相似的架构和训练过程,因为这样更高效并且能产生更高质量的嵌入。
那么,嵌入模型就是一种算法,它接收一段文本并输出其含义的数值表示——技术上讲,是一个长长的浮点(十进制)数列表,通常在 100 到 2000 个数量或者维度之间。这些也称为稠密嵌入,与上一节的稀疏嵌入相对应,因为这里通常所有维度都为 0。
小贴士:不同的模型产生不同数量和不同大小的列表。对不同模型也是不同的;也就是说,即使列表的大小一致,也无法比较来自不同模型的嵌入。应始终避免组合来自不同模型的嵌入。
语义嵌入讲解
思考这三个词:lion(狮子)、pet(宠物)和 dog(狗)。直观地看,这些词中哪一对在第一眼看上去具有相似的特征?显然是 pet 和 dog。但是计算机不具备这种直觉或对英语语言的深入理解。为了让计算机能区分狮子、宠物或狗,需要能够将它们翻译成计算机的语言,也就是数字。
图 2-2 说明了将每个单词转换为保留其含义的假设数字表示。
图 2-2. 单词的语义表示
图 2-2 展示了每个单词及其对应的语义嵌入。请注意,数字本身没有特定含义,但是意义相近的两个单词(或句子)的数字序列应该比不相关单词的数字序列更接近。如图中所示,每个数字都是一个浮点值,并且它们中的每一个都代表一个语义维度。我们来看看更接近是什么意思:
如果我们将这些向量绘制在一个三维空间中,可能会像图 2-3中这样。
图 2-3. 多维空间中词向量的图示
图 2-3 中显示 pet 和 dog 向量在距离上比 lion 更接近。我们还可以观察到,每个图之间的角度根据它们的相似程度而变化。例如,单词 pet 和 lion 之间的角度比 pet 和 dog 之间的角度要大,表明后两者具有更多的相似性。两个向量之间的角度越小或距离越短,它们的相似性就越接近。
一种高效计算多维空间中两个向量相似程度的方法称为余弦相似度。余弦相似度计算向量的点积,然后除以它们模长的乘积,输出一个介于 –1 和 1 之间的数字,其中 0 表示向量没有相关性,–1 表示它们绝对不相似,1 表示它们绝对相似。我们这里的三个词,pet 和 dog 之间的余弦相似度可能是 0.75,但 pet 和 lion 之间的余弦相似度可能是 0.1。
将句子转换为捕获语义含义的嵌入 ,然后执行计算查找不同句子之间的语义相似性的能力,使我们能够让 LLM 找到最相关的文档来回答有关像特斯拉 PDF 文档这样的大量文本的问题。现在读者已经了解了全局,让我们回顾一下预处理文档的第一步(索引)。
嵌入的其他用途
这些数字和向量序列具有许多有趣的特性:
- 之前已学到,如果您将向量视为描述高维空间中的一个点,那么彼此更近的点具有更相似的含义,因此可以使用距离函数来衡量相似性。
- 彼此靠近的点群可以说具有相关性;因此,可以使用聚类算法来识别主题(或点群)并将新的输入分类到这些主题中。
- 如果对多个嵌入进行平均,则平均嵌入可以说代表了该组的整体含义;也就是说,可以通过以下方式嵌入一个长文档(例如,本书):
- 分别嵌入每一页
- 将所有页面的嵌入平均值作为书籍嵌入
- 可以通过使用加法和减法的基本数学运算来“遍历”“意义”空间:例如,计算 国王 – 男人 + 女人 = 女王。如果取 国王 的意义(或语义嵌入),减去 男人 的意义,大概会得到更抽象的 君主 的意义,此时,如果加上 女人 的意义,就接近单词 女王 的意义(或嵌入)。
- 有些模型除了文本之外,还可以为非文本内容(例如图像、视频和声音)生成嵌入。例如,这样能找到与给定句子最相似或相关的图像。
我们不会在本书中探讨所有这些属性,但了解它们可用于诸多 应用 是有益的,例如:
- 搜索
- 为新查询查找最相关的文档
- 聚类
- 给定一批文档,将它们分组(例如,主题)
- 分类
- 将新文档分到先前识别的组或标签(例如,主题)
- 推荐
- 给定一个文档,展示相似的文档
- 检测异常
- 识别与先前见过的文档非常不相似的文档
希望以上能让读者对嵌入的多功能性及其在未来项目中的良好应用有一些直观的认识。
将自有文档转换为文本
本章开头提到,预处理文档的第一步是将其转换为文本。为实现这一点,需要构建逻辑来解析和提取文档,同时最大限度地降低质量上的损失。所幸,LangChain 提供了 文档加载器,它们处理解析逻辑,并能将来自各种来源的数据“加载”到一个由文本和相关元数据组成的 Document
类中。
例如,有一个简单的 .txt 文件。可以简便地导入 LangChain TextLoader
类来提取文本,如下所示:
Python
1 2 3 4 |
from langchain_community.document_loaders import TextLoader loader = TextLoader("./test.txt") loader.load() |
JavaScript
1 2 3 4 5 |
import { TextLoader } from "langchain/document_loaders/fs/text"; const loader = new TextLoader("./test.txt"); const docs = await loader.load(); |
输出:
1 2 3 4 5 6 7 |
[ Document { pageContent: 'text content\n', metadata: { source: './test.txt' }, id: undefined } ] |
以上代码假设在当前目录中有一个名为 test.txt
的文件。所有 LangChain 文档加载器的使用都遵循类似的模式:
- 首先从集成列表中选择适合文档类型的加载器。
- 创建所选加载器的实例,并添加配置参数,包括文档的位置(通常是文件系统路径或网址)。
- 通过调用
load()
加载文档,该方法返回一个文档列表,准备传递到下一阶段(稍后详述)。
除了 .txt 文件外,LangChain 还为其他主流文件类型(包括 .csv、.json 和 Markdown)提供了文档加载器,并可集成 Slack 和 Notion 等知名平台。
比如,可以使用 WebBaseLoader
从 Web URL 加载 HTML 并将其解析为文本。
安装 beautifulsoup4 包:
1 |
pip install beautifulsoup4 |
Python
1 2 3 4 5 6 |
from langchain_community.document_loaders import WebBaseLoader loader = WebBaseLoader('https://www.langchain.com/') docs = loader.load() print(docs) |
JavaScript
1 2 3 4 5 6 7 8 |
// 安装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(); console.log(docs); |
在前述的特斯拉 PDF 用例中,我们可以利用 LangChain 的 PDFLoader
从 PDF 文档中提取文本:
Python
1 2 3 4 5 6 7 8 |
# 安装 pdf 解析库 # pip install pypdf from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader('./test.pdf') pages = loader.load() print(pages) |
JavaScript
1 2 3 4 5 6 7 |
// 安装 pdf 解析库: npm install pdf-parse import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'; const loader = new PDFLoader('./test.pdf'); const docs = await loader.load(); console.log(docs); |
已从 PDF 文档中提取文本并存储在 Document
类中。但是有一个问题。加载的文档超过 100,000 个字符长,因此它无法适应绝大多数 LLM 或嵌入模型的上下文窗口。为克服这个限制,我们需要将 Document
分割成易于管理的文本块,以便稍后将其转换为嵌入并进行语义搜索,我们进入第二步(检索)。
小贴士:LLM 和嵌入模型设计时对它们可以处理的输入和输出令牌的大小有硬性限制。这个限制通常称为上下文窗口,并且通常适用于输入和输出的组合;也就是说,如果上下文窗口是 100(我们稍后会讨论单位),而输入的大小为 90,则输出长度最多只能为 10。上下文窗口通常以令牌量来衡量,例如 8,192 个令牌。如前面提到的,令牌是将文本表示为数字的一种方式,每个令牌通常占三到四个英文字符。
将文本分割成块
乍一看,将大段文本分割成块似乎很简单,但是将语义上相关(按意义相关)的文本块保留在一起是一个复杂的过程。为了更容易地将大型文档分割成小而有意义的文本片段,LangChain 提供了 RecursiveCharacterTextSplitter
,它执行以下操作:
- 按重要性顺序取一个分隔符列表。默认情况下,它们是:
- 段落分隔符:
\n\n
- 行分隔符:
\n
- 单词分隔符:空格字符
- 段落分隔符:
- 为了遵守给定的块大小,例如 1,000 个字符,先分割段落。
- 对于长于所需块大小的段落,按下一个分隔符行来分割。继续直到所有块都小于所需长度,或者没有其他分隔符可尝试。
- 将每个块作为
Document
释出,并传入原始文档的元数据以及有关原始文档中位置的附加信息。
让看示例:
Python
1 2 3 4 5 6 7 8 9 10 11 |
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.document_loaders import TextLoader loader = TextLoader('./test.txt', encoding="utf-8") docs = loader.load() splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) splitted_docs = splitter.split_documents(docs) print(splitted_docs) |
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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); console.log(splittedDocs); |
在上面的代码中,由文档加载器创建的文档被分割成每个 1000 个字符的块,块之间有 200 个字符的重叠以保持一些上下文。结果也是一个文档列表,其中每个文档的长度最多为 1000 个字符,并按照文本的自然分界(段落、换行符以及最后的单词)进行分割。这利用了文本的结构来保持每个块都是一致、可读的文本片段。
RecursiveCharacterTextSplitter
也可以用来将代码和 Markdown 分割成语义块。通过使用特定于每种语言的关键字作为分隔符来完成,这样可确保例如每个函数的主体都保存在同一个块中,而不是被分割到几个块中。通常,由于编程语言比文本具有更多的结构,因此在块之间使用重叠的需要较少。LangChain 自带许多种语言的分隔符,例如 Python、JS、Markdown、HTML 等等。以下是一个示例:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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]) print(python_docs) |
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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]); console.log(pythonDocs); |
输出:
1 |
[Document(metadata={}, page_content='def hello_world():\n print("Hello, World!")'), Document(metadata={}, page_content='# Call the function\nhello_world()')] |
注意,我们还像之前一样使用 RecursiveCharacterTextSplitter
,但这里使用 from_language
方法为特定语言创建实例。这个方法接受语言的名称,以及块大小等常用参数。另外,我们现在使用的是 create_documents
方法,它接收一个字符串列表,而不是前面使用的文档列表。因经在要分割的文本不是来自文档加载器,而是原始文本字符串时,此方法很有用。
您还可以使用 create_documents
的第二个可选参数来传递一个元数据列表,与每个文本字符串关联。此元数据列表的长度应与字符串列表的长度相同,并用于填充返回的每个 Document
的元数据字段。
我们来看一个 Markdown 文本的例子,使用元数据参数:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
from langchain_text_splitters import ( Language, RecursiveCharacterTextSplitter, ) 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": "https://www.langchain.com"}]) print(md_docs) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
from langchain_text_splitters import ( Language, RecursiveCharacterTextSplitter, ) 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": "https://www.langchain.com"}]) print(md_docs) |
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; 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' }] ); console.log(mdDocs); |
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
[ Document { pageContent: '# LangChain', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: '⚡ Building applications with LLMs through composability ⚡', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: '## Quick Install\n\n```bash\npip install langchain', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: '```', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: 'As an open source project in a rapidly developing field, we', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: 'are extremely', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined }, Document { pageContent: 'open to contributions.', metadata: { source: 'https://www.langchain.com', loc: [Object] }, id: undefined } ] |
注意以下两点:
- 文本沿着 Markdown 文档中的自然断点分割;例如,标题进入一个块,其下的文本行进入一个单独的块,以此类推。
- 我们在第二个参数中传递的元数据附加到每个结果文档,这样可跟踪文档的来源以及在哪里可以看到原始文档等。
生成文本嵌入
LangChain 还有一个 Embeddings
类,旨在与文本嵌入模型(包括 OpenAI、 Cohere 和 Hugging Face)交互,并生成文本的向量表示。此类提供两种方法:一种用于嵌入文档,另一种用于嵌入查询。前者接受文本字符串列表作为输入,而后者接受单个文本字符串。
以下是使用 OpenAI 的嵌入模型嵌入文档的示例:
Python
1 2 3 4 5 6 7 8 9 10 11 12 |
from langchain_openai import OpenAIEmbeddings model = OpenAIEmbeddings(model="text-embedding-3-small") embeddings = model.embed_documents([ "Hi there!", "Oh, hello!", "What's your name?", "My friends call me World", "Hello World!" ]) print(embeddings) |
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 |
import { OpenAIEmbeddings } from '@langchain/openai'; const model = new OpenAIEmbeddings(); const embeddings = await model.embedDocuments([ 'Hi there!', 'Oh, hello!', "What's your name?", 'My friends call me World', 'Hello World!', ]); console.log(embeddings); |
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
[ [ -0.02034595, -0.007112193, -0.022822518, -0.026264312, -0.037516817, 0.021628685, -0.0062136436, -0.009017244, 0.008445729, -0.01662475, 0.02692473, -0.007410651, -0.013589368, -0.024117954, 0.0065470273, -0.020231647, 0.024270358, -0.0147450995, 0.016396144, -0.016510447, -0.0072518964, -0.008045668, 0.0046991273, -0.002046343, -0.014859403, -0.005981862, -0.0020558683, -0.022987623, 0.019850638, -0.031522255, 0.0128972, 0.011639865, -0.008528281, -0.009487157, -0.0017812232, -0.02733114, -0.008223473, 0.0021003194, 0.024003651, -0.008718787, 0.023521038, 0.00089696184, 0.009703063, -0.013868776, -0.01734867, 0.010998498, -0.001170813, -0.007270947, -0.02249231, 0.0406919, 0.018872712, 0.00152166, -0.019774435, -0.022276403, -0.007855163, 0.001993954, -0.004505447, 0.010623838, -0.0005508774, -0.024168756, 0.0006211262, 0.010763542, -0.0033274903, 0.020765062, -0.010534936, -0.00044729025, 0.012408236, 0.0121415295, 0.0005084107, 0.009258551, 0.0019590282, 0.003994258, -0.0014526019, -0.014262486, -0.0050134608, -0.0006385892, -0.019241022, 0.017577276, -0.027737552, -0.0092268, 0.023025723, -0.03507835, 0.0067629335, 0.020739662, 0.015811928, 0.009893568, -0.007836113, 0.020231647, -0.023711544, -0.020422153, 0.009449056, 0.013322661, 0.0026099207, 0.017107364, -0.014529194, 0.028829781, -0.019672833, 0.042977966, 0.0056770537, -0.0074741524, ... 1436 more items ], [ 0.004446745, -0.014353534, 0.001978569, -0.015184007, -0.02655032, 0.024282016, -0.007883289, -0.003991225, -0.026946964, -0.00025041992, 0.04601064, -0.0026060713, -0.009953272, -0.021096474, 0.00057327375, -0.013163604, 0.027888993, 0.00089089834, 0.014737783, -0.013684199, -0.014663412, 0.00016762446, -0.0010241457, -0.007957659, -0.008155981, -0.01320079, 0.0067987167, -0.020030495, 0.012649207, -0.016906926, 0.0077159544, 0.007387484, 0.0033559755, -0.0009939327, -0.00947606, -0.023910163, -0.0054228595, -0.005900071, 0.026699062, -0.0106412, 0.018233202, 0.003035252, 0.0045737945, -0.012890912, -0.007722152, 0.012612022, 0.0007522281, -0.0099966545, 0.0021118165, 0.038821477, 0.030467175, -0.012308341, -0.024815006, -0.024976142, -0.010684582, 0.0041368674, -0.02647595, 0.022311194, 0.0029453875, -0.02480261, 0.0049704383, 0.015084846, -0.013101629, 0.019237207, -0.017489497, 0.011992266, 0.024901772, 0.004127571, -0.028905392, 0.007083804, -0.015122031, 0.009965667, -0.006581802, -0.010232162, -0.00085991056, -0.01008342, -0.0392677, -0.0050479076, -0.017167224, -0.00488987, 0.029178083, -0.051514067, -0.00048069778, 0.021146053, 0.018208412, 0.00559949, -0.006420666, 0.02123282, -0.014688202, -0.011676191, -0.0056552677, 0.010281742, 0.0041957437, 0.012184391, -0.009345911, 0.0007022603, -0.0002666885, 0.029277245, -0.009878901, -0.019063676, ... 1436 more items ], ... 其余3条 ] |
注意,可以同时嵌入多个文档;也应该优先选择这种方式,而不是逐个嵌入,因为这样会更有效率(和模型的构建方式有关)。这时会得到一个包含多个数字列表的列表——每个内部列表都是一个向量或嵌入。
现在使用我们目前学习的三个功能演示一个完整示例,:
- 文档加载器,用于将任何文档转换为纯文本
- 文本分割器,用于将每个大文档分割成许多小文档
- 嵌入模型,用于创建每个分割部分的含义的数字表示
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings # Load the document loader = TextLoader("./test.txt", encoding="utf-8") doc = loader.load() # Split the document splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) chunks = splitter.split_documents(doc) # Generate embeddings embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small") embeddings = embeddings_model.embed_documents( [chunk.page_content for chunk in chunks] ) print(embeddings) |
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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(); // Split the document const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000, chunkOverlap: 200, }); const chunks = await splitter.splitDocuments(docs); console.log(chunks); // Generate embeddings const model = new OpenAIEmbeddings(); const embeddings = await model.embedDocuments(chunks.map((c) => c.pageContent)); console.log(embeddings); |
文档生成嵌入后,下一步是将它们存储在一个称为向量数据库的特殊数据库中。
在向量数据库中存储嵌入
在前面,我们讨论了余弦相似度计算,用以衡量向量空间中向量之间的相似性。向量数据库是一种数据库,设计用于存储向量并高效快速地执行复杂计算,如余弦相似度。
与专门存储结构化数据(例如 JSON 文档或符合关系数据库模式的数据)的传统数据库不同,向量存储处理非结构化数据,包括文本和图像。与传统数据库一样,向量存储能够执行创建、读取、更新、删除 (CRUD) 和搜索操作。
向量存储解锁了各种各样的用例,包括利用 AI 回答有关大型文档问题的可扩展应用程序,如图 2-4所示。
图 2-4. 从向量数据库中加载、嵌入、存储和检索相关文档
图 2-4 说明了文档嵌入如何插入向量数据库,以及稍后在发送查询时如何从向量存储中检索相似的嵌入。
目前,有大量的向量存储提供商可供选择,每个提供商都专注于不同的功能。选择时应根据应用程序的主要城求,如多租户、元数据过滤功能、性能、成本和可伸缩性。
尽管向量数据库是为管理向量数据而构建的利基数据库,但使用它们也存在一些缺点:
- 大多数向量数据库相对较新,可能经不起时间的考验。
- 管理和优化向量数据库可能存在相对陡峭的学习曲线。
- 管理一个单独的数据库会增加应用程序的复杂性,并可能消耗宝贵的资源。
所幸,向量存储功能最近已通过 pgvector
扩展扩展到 PostgreSQL(一种流行的开源关系数据库)。这样我们可以使用已经熟悉的相同数据库,同时为事务表(例如您的用户表)以及向量搜索表提供支持。
PGVector 设置入门
使用 Postgres 和 PGVector,需要执行以下几个设置步骤:
- 确保计算机上已安装 Docker,请遵循适合所用操作系统的说明。
- 在终端中运行以下命令;它将在你的计算机上启动一个在端口 6024 上运行的 Postgres 实例:
1234567docker run \--name pgvector-container \-e POSTGRES_USER=langchain \-e POSTGRES_PASSWORD=langchain \-e POSTGRES_DB=langchain \-p 6024:5432 \-d pgvector/pgvector:pg16
打开 docker 仪表板容器或Docker Desktop,应该会在 pgvector-container 旁边看到绿色的运行状态。 - 保存连接字符串以便在代码中使用,我们稍后会用到
1postgresql+psycopg://langchain:langchain@localhost:6024/langchain
使用向量数据库
接上一节关于嵌入的内容,现在我们来看一个加载、分割、嵌入并将文档存储在 PGVector 中的示例:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# first, pip install 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 # Load the document, split it into chunks raw_documents = TextLoader('./test.txt').load() text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) documents = text_splitter.split_documents(raw_documents) # embed each chunk and insert it into the vector store embeddings_model = OpenAIEmbeddings() connection = 'postgresql+psycopg://langchain:langchain@localhost:6024/langchain' db = PGVector.from_documents(documents, embeddings_model, connection=connection) |
注:若出现couldn't import psycopg 'binary' implementation: No module named 'psycopg_binary'
,则执行pip install psycopg-binary
,若出现File "psycopg_binary/_psycopg/transform.pyx", line 500, in psycopg_binary._psycopg.Transformer.load_row sqlalchemy.exc.InterfaceError: (psycopg.InterfaceError) row must be included between 0 and 0
,则根据Python版本执行pip install "psycopg[binary]==3.1.9" --force-reinstall
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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'; // Load the document, split it into chunks 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(docs) // embed each chunk and insert it into the vector store 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
1 |
db.similarity_search("query", k=4) |
JavaScript
1 |
await pgvectorStore.similaritySearch("query", 4); |
此方法将通过以下流程查找最相关的文档(此前已建立索引):
- 搜索查询——本例中为单词
query
——将被发送到嵌入模型以检索其嵌入。 - 然后,在 Postgres 上运行查询,查找与查询内容最相似的 N 个(本例中为 4 个)此前存储的嵌入。
- 最后,将获取与这些嵌入中的每一个相关的文本内容和元数据。
- 模型现在可以返回一个按与查询的相似度排序的
Document
列表——最相似的在前,第二相似的在后,以此类推。
还可以向现有数据库添加更多文档。示例如下:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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
1 |
db.delete(ids=[1]) |
JavaScript
1 |
await db.delete({ ids: [ids[1]] }) |
这里通过使用其通用唯一标识符 (UUID) 删除插入的第二个文档。现在让我们看看如何以更系统的方式执行此操作。
跟踪文档更改
使用向量存储的一大挑战是处理定期更改的数据,因为更改意味着重新索引。而重新索引可能导致昂贵的嵌入重新计算和预先存在内容的重复。
所幸,LangChain 提供了一个索引 API,可以轻松地使文档与向量存储保持同步。该 API 利用一个类 (RecordManager
) 来跟踪文档写入向量存储的情况。在索引内容时,会为每个文档计算哈希值,并将以下信息存储在 RecordManager
中:
- 文档哈希(页面内容和元数据的哈希)
- 写入时间
- 源 ID(每个文档应在其元数据中包含信息以确定此文档的最终来源)。
此外,索引 API 还提供清理模式,帮助我们决定如何删除向量存储中的现有文档。例如,如果在插入前更改了文档的处理方式,或者源文档已更改,可能希望删除所有与正在索引的新文档来源相同的现有文档。如果某些源文档已被删除,将需要删除向量存储中的所有现有文档,并用重新索引的文档替换它们。
模式如下:
None
模式不执行自动清理,允许用户手动清理旧内容。- 如果源文档或派生文档的内容已更改,
Incremental
和full
模式将删除内容的先前版本。 Full
模式还将删除当前正在索引的文档中未包含的任意文档。
以下是将 Postgres 数据库设置为记录管理器的索引 API 使用示例:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
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", ) # Create the schema if it doesn't exist record_manager.create_schema() # Create documents 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 the documents index_1 = index( docs, record_manager, vectorstore, cleanup="incremental", # prevent duplicate documents source_id_key="source", # use the source field as the source_id ) print("Index attempt 1:", index_1) # second time you attempt to index, it will not add the documents again index_2 = index( docs, record_manager, vectorstore, cleanup="incremental", source_id_key="source", ) print("Index attempt 2:", index_2) # If we mutate a document, the new version will be written and all old versions sharing the same source will be deleted. 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
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'; // Load the document, split it into chunks const config = { postgresConnectionOptions: { connectionString, }, tableName: tableName, columns: { idColumnName: 'id', vectorColumnName: 'vector', contentColumnName: 'content', metadataColumnName: 'metadata', }, }; const vectorStore = await PGVectorStore.initialize( new OpenAIEmbeddings(), config ); // Create a new record manager const recordManagerConfig = { postgresConnectionOptions: { connectionString, }, tableName: 'upsertion_records', }; const recordManager = new PostgresRecordManager( 'test_namespace', recordManagerConfig ); // Create the schema if it doesn't exist 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' }, }, ]; // the first attempt will index both documents const index_attempt_1 = await index({ docsSource: docs, recordManager, vectorStore, options: { cleanup: 'incremental', // prevent duplicate documents by id from being indexed sourceIdKey: 'source', // the key in the metadata that will be used to identify the document }, }); console.log(index_attempt_1); // the second attempt will skip indexing because the identical documents already exist const index_attempt_2 = await index({ docsSource: docs, recordManager, vectorStore, options: { cleanup: 'incremental', sourceIdKey: 'source', }, }); console.log(index_attempt_2); // If we mutate a document, the new version will be written and all old versions sharing the same source will be deleted. 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 说明了如何操作。
图 2-5. 索引单个文档的多种表现形式
例如,对于包含表格的文档,我们可以先生成并嵌入表格元素的摘要,确保每个摘要都包含一个指向完整原始表格的 id
引用。接下来,我们将引用的原始表格存储在一个单独的文档存储中。最后,当用户的查询检索到表格摘要时,我们将整个引用的原始表格作为上下文传递给发送给 LLM 进行答案合成的最终提示。这种方法使我们能够为模型提供回答问题所需的完整信息上下文。
下面举个例子。首先,让我们使用 LLM 来生成文档的摘要:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
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() # Load the document loader = TextLoader("./test.txt", encoding="utf-8") docs = loader.load() print("length of loaded docs: ", len(docs[0].page_content)) # Split the document splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) chunks = splitter.split_documents(docs) # The rest of your code remains the same, starting from: 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() # batch the chain across the chunks summaries = summarize_chain.batch(chunks, {"max_concurrency": 5}) |
接下来,我们定义向量存储和文档存储来存储原始摘要及其嵌入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# The vectorstore to use to index the child chunks vectorstore = PGVector( embeddings=embeddings_model, collection_name=collection_name, connection=connection, use_jsonb=True, ) # The storage layer for the parent documents store = InMemoryStore() id_key = "doc_id" # indexing the summaries in our vector store, whilst retaining the original # documents in our document store: retriever = MultiVectorRetriever( vectorstore=vectorstore, docstore=store, id_key=id_key, ) # Changed from summaries to chunks since we need same length as docs doc_ids = [str(uuid.uuid4()) for _ in chunks] # Each summary is linked to the original document by the doc_id summary_docs = [ Document(page_content=s, metadata={id_key: doc_ids[i]}) for i, s in enumerate(summaries) ] # Add the document summaries to the vector store for similarity search retriever.vectorstore.add_documents(summary_docs) # Store the original documents in the document store, linked to their summaries # via doc_ids # This allows us to first search summaries efficiently, then fetch the full # docs when needed retriever.docstore.mset(list(zip(doc_ids, chunks))) # vector store retrieves the summaries sub_docs = retriever.vectorstore.similarity_search( "chapter on philosophy", k=2) |
最后,我们根据查询检索相关的完整上下文文档:
Python
1 2 |
# Whereas the retriever will return the larger source document chunks: retrieved_docs = retriever.invoke("chapter on philosophy") |
以下是 JavaScript 中的完整实现:
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
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(), ]); // batch summarization chain across the chunks const summaries = await chain.batch(docs, { maxConcurrency: 5, }); const idKey = 'doc_id'; const docIds = docs.map((_) => uuid.v4()); // create summary docs with metadata linking to the original docs const summaryDocs = summaries.map((summary, i) => { const summaryDoc = new Document({ pageContent: summary, metadata: { [idKey]: docIds[i], }, }); return summaryDoc; }); // The byteStore to use to store the original chunks const byteStore = new InMemoryStore(); // vector store for the summaries 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]); // Use the retriever to add the original chunks to the document store await retriever.docstore.mset(keyValuePairs); // Vectorstore alone retrieves the small chunks 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}` ); // Retriever returns larger chunk result const retrieverResult = await retriever.invoke('chapter on philosophy'); console.log( `multi-vector retrieved chunk length: ${retrieverResult[0].pageContent.length}` ); |
RAPTOR:树状结构检索的递归摘要处理
RAG 系统需要处理引用单个文档中特定事实的较低级别问题,以及提炼跨多个文档大意的较高级别问题。使用经典的 k-近邻 (k-NN) 对文档块进行检索来处理这两种类型的问题可能是一个挑战。
树状结构检索的递归摘要处理 (RAPTOR) 是一种有效的策略,它涉及创建捕获更高级别概念的文档摘要,对这些文档进行嵌入和聚类,然后对每个聚类进行摘要。这是递归完成的,产生一个具有越来越高级别概念的摘要树。然后将摘要和初始文档一起索引,从而覆盖从较低级别到较高级别的用户问题。图 2-6 对此进行了说明。
图 2-6. 递归摘要文档
ColBERT:优化嵌入
在索引阶段使用嵌入模型的挑战之一是它们将文本压缩成固定长度的(向量)表示,这些表示捕获了文档的语义内容。尽管这种压缩对于检索很有用,但嵌入不相关或冗余的内容可能会导致最终 LLM 输出中出现幻觉。
解决此问题的一种方法是执行以下操作:
- 为文档和查询中的每个标记生成上下文嵌入。
- 计算并对每个查询标记与所有文档标记之间的相似性打分。
- 将每个查询嵌入与其它文档嵌入的最大相似性得分相加,以获得每个文档的得分。
这导致了一种细粒度且有效的嵌入方法,以实现更好的检索。所幸,名为 ColBERT 的嵌入模型自带了解决此问题的方法。
以下是我们如何利用 ColBERT 对数据进行最佳嵌入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# 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): """ Retrieve the full text content of a Wikipedia page. :param title: str - Title of the Wikipedia page. :return: str - Full text content of the page as raw string. """ # Wikipedia API endpoint URL = "https://en.wikipedia.org/w/api.php" # Parameters for the API request params = { "action": "query", "format": "json", "titles": title, "prop": "extracts", "explaintext": True, } # Custom User-Agent header to comply with Wikipedia's best practices headers = {"User-Agent": "RAGatouille_tutorial/0.0.1"} response = requests.get(URL, params=params, headers=headers) data = response.json() # Extracting page content page = next(iter(data["query"]["pages"].values())) return page["extract"] if "extract" in page else None full_document = get_wikipedia_page("Hayao_Miyazaki") ## Create an index RAG.index( collection=[full_document], index_name="Miyazaki-123", max_document_length=180, split_documents=True, ) #query results = RAG.search(query="What animation studio did Miyazaki found?", k=3) results #utilize langchain retriever retriever = RAG.as_langchain_retriever(k=3) retriever.invoke("What animation studio did Miyazaki found?") |
通过使用 ColBERT,可以提高 LLM 用作上下文的检索文档的相关性。
小结
本章中,我们学习了如何使用 LangChain 的各种模块为 LLM 应用程序准备和预处理文档。文档加载器能够从数据源中提取文本,文本分割器帮助我们将文档分割成语义相似的块,而嵌入模型则文本转换为其含义的向量表示。
另外,引入向量数据库对这些嵌入执行 CRUD 操作,并进行复杂计算以计算语义相似的文本块。最后,索引优化策略使我们的 AI 应用能够提高嵌入质量,并对包含表格等半结构化数据的文档进行准确检索。