AI 智能体与应用——高级索引

23 阅读45分钟

本章涵盖以下内容:

  • 使用高级 RAG 技术
  • 选择最优的文本分块策略
  • 使用多重 embedding 提升对粗粒度文本块的召回效果
  • 在检索时扩展细粒度文本块以补充上下文
  • 面向半结构化和多模态内容的索引策略

在第 7 章中,你已经学习了检索增强生成(Retrieval-Augmented Generation,RAG) 架构的基础知识——这是构建 LLM 驱动应用的核心模式之一。为了便于理解,我们当时采用的是一个被大幅简化的版本。这种最小化配置很适合入门学习,但在实际使用中往往会带来令人失望的结果:答案不准确、数据被遗漏、上下文利用不足——即使向量存储中明明已经包含了你所需要的信息。造成这些问题的原因,通常在于查询表达含糊、索引策略欠佳,或者没有有效利用元数据。

本章将重点讨论如何克服这些挑战。使用 LangChain 构建健壮的 LLM 应用,关键并不在于把组件简单连起来,而在于不断打磨设计本身——持续迭代检索策略、实验不同的 prompt,并应用高级 RAG 技术。真正的熟练,来自于对这些细节优化的掌握。

我们将从高级索引策略开始,比如在向量数据库中为较大的文本块生成多个 embedding。这种方法能够提升检索精度,并为响应生成提供更丰富、更准确的上下文。

8.1 提升 RAG 的准确性

要提升 RAG 的准确性,关键在于逐一审视内容摄取(content ingestion)问答(Q&A) 这两个工作流中的每一个步骤。每一个阶段都可能引入问题——但同时,每一个阶段也都提供了可优化的机会。我们先从内容摄取阶段开始。

8.1.1 内容摄取阶段

可以通过一个针对各类内容存储特点优化过的内容摄取流程,来提升检索准确性。如果只依赖基础索引方式,往往会导致索引深度不足,从而削弱检索效果。图 8.1 展示了摄取阶段中两个关键的改进点:一是优化 embedding 的计算方式,二是优化 embedding 与其对应文本块在向量存储中的关联方式。

image.png

图 8.1 在简单 RAG 架构的摄取阶段中,常见的准确性问题,往往源于索引过于粗糙——例如仅为每个文本块生成一个基础 embedding。高级索引技术则会为每个文本块生成多个 embedding,从而增强可搜索性。

即使一个问题本身表述得很清晰,如果索引策略过于简单,检索仍然可能失败。在向量索引中,chunk 的大小重叠长度(overlap) 至关重要:较小的 chunk 对精确问题可能效果很好,但面对范围更广的问题时往往表现不佳;而较大的 chunk 虽然适合更宽泛的问题,却可能缺乏回答具体问题所需的细节。为了解决这一点,你可以为每个 chunk 的不同特征生成额外的 embedding,如图 8.1 所示。这种多面向索引方式,能让每个文本块更灵活、更容易被检索到。本章中我们将讨论很多高级索引技术。

8.1.2 问答阶段

问答阶段的效果,很大程度上取决于系统对用户查询的解释与处理是否准确。图 8.2 中标出了许多可能干扰这一过程的问题。

image.png

图 8.2 在朴素 RAG 架构的 Q&A 阶段中常见的问题及其解决方案:(1)通过问题转换处理表述不佳的问题;(2)通过把原始用户问题转换成更适合向量数据库搜索的查询来提升检索精度;(3)通过加入结构化数据存储(例如关系型数据库)来纳入相关数据源;(4)为结构化数据存储生成数据库查询语句;(5)过滤掉从内容存储中检索出的无关上下文。

沿着上一张图所展示的 RAG 问答阶段工作流一步步看下去,你会遇到若干可能导致答案质量下降的陷阱。每一个问题都有相应的针对性解决方案,下面会一一说明,后面的表 8.1 也会进行归纳总结:

问题表述不清 —— 如果用户的问题本身表达含糊,向量存储就可能返回质量较差的上下文,从而导致糟糕的结果。LLM 在面对模糊查询和低质量上下文时会更难发挥作用。一个修复方式是:在把问题交给检索系统之前,先把它改写成更清晰、更具体的形式。

问题本身不适合检索 —— 把原始问题同时直接用于“检索”和“生成”,在某些情况下会失效,尤其是当查询过于宽泛或抽象时。宽泛的问题往往无法准确定位相关内容,从而导致检索出来的上下文不够精准。你可以通过把宽泛问题拆解成若干更具体的子问题,来获取更精确的信息。

内容存储中的数据相关性有限 —— 大多数 RAG 系统只依赖向量存储,但如果再加入结构化数据源,例如关系型数据库、表格或者图数据库,效果往往会更好。你可以根据所需数据的类型,把查询路由到合适的内容存储中,以提高答案准确性。

针对结构化数据的查询能力有限 —— 向量存储和 LLM 擅长处理自然语言,但关系型数据库和图数据库本身不能直接处理自然语言,这就形成了使用结构化内容的障碍。你可以借助 LLM 为每类数据源生成结构化查询语句(例如 SQL),以解决这个问题。

无关搜索结果被送入 LLM —— 即使问题更清晰、索引更合理,有时仍然会有无关数据混入检索结果,给答案带来噪声。可以通过过滤或后处理步骤,只保留最相关的结果。

答案准确率提升仍然不足 —— 有时,单独修复某一个问题,并不能带来预期中的改善。这种情况下,可以把多种技术组合起来——例如高级索引、问题转换、多存储路由——形成一种集成策略(ensemble strategy),以最大化精度。

表 8.1 朴素 RAG 架构中的常见问题与推荐解决方案

问题解决方案
检索返回了错误的内容块高级文档索引技术
问题表述不佳问题转换
问题本身不适合检索问题转换
内容存储中的数据相关性有限路由到多个内容存储
面向结构化数据的查询能力有限生成内容存储查询
检索出的无关结果被送入 LLM检索后处理

本章以及接下来的两章,将详细讨论这些问题及其解决方案。下面,我们先来看高级文档索引。

8.2 高级文档索引

要让 LLM 生成高质量答案,被向量存储(或其他文档存储)检索出来的文本块是否相关、是否准确,至关重要。而这些文本块的质量,又取决于几个关键因素:

分块策略(splitting strategy) —— 文档块的粒度会直接影响检索准确性。较小的 chunk 更适合精确查询,但缺乏更大的上下文;较大的 chunk 能提供更丰富的上下文,但可能丢失细节。选择合适的分块大小非常关键,而且可以通过多种技术进一步优化。此外,像 chunk overlap 和文档层级结构这样的因素,也会对上下文与相关性的平衡起重要作用。

embedding 策略 —— 你如何为每个 chunk 建立索引,同样重要。你可以使用 embedding、元数据,或二者结合。高级策略会使用多个索引来源,例如子文档 embedding、摘要、或某个 chunk 能回答的假设性问题,从而同时捕捉细粒度细节与更广泛的上下文。

句子扩展(sentence expansion) —— 一种既能保留细节又能在输出时提供更大 chunk 的方法,是在检索时把周围句子一起扩展进去。这样可以在不牺牲精确性的前提下提供额外上下文。

结构化与半结构化数据的索引 —— 如果你想用非结构化查询去检索结构化数据(例如数据库表格或多媒体内容),就需要专门的技术。这可能包括为数据库行、图像,甚至音频文件生成 embedding。

本章将逐一深入讲解这些方法。我们先从最优文档切分策略说起。

8.3 分块策略

在 RAG 的摄取阶段,文档会先被切分成若干 chunk,然后再存入向量数据库或其他文档存储。每个 chunk 会使用 embedding 建立索引,有时还会结合元数据(例如给它打上相关关键词标签)一同索引。向量相似度搜索依赖的是 embedding 索引,而元数据搜索依赖的是关键词索引。

想要提升文档 chunk 检索相关性的最简单方法,就是为你的具体用例选对文档切分策略。理想情况下,文档存储应该返回所有相关 chunk,并为 LLM 提供足够上下文,以便它生成准确答案。而这些 chunk 的大小,对检索效果有着决定性影响,如图 8.3 所示。

image.png

图 8.3 chunk 大小对答案准确率的影响。粗粒度 chunk 提供更多上下文,但聚焦性较弱,适合更宽泛的问题;细粒度 chunk 提供更少上下文,但聚焦性更强,适合更细致的问题。

较小、更细粒度的 chunk 更适合回答细节性问题,因为它们聚焦于具体主题。但它们包含的周边上下文较少,因此在面对更宽泛的问题时效果会下降。相反,较大的 chunk 对一般性问题更有效,因为它们提供了更多上下文,但会牺牲对细节的聚焦能力。

挑战就在于:你必须根据预期的问题类型来平衡 chunk 大小。小 chunk 在查询足够精确时表现很好,因为问题的向量表示会与相关 chunk 的向量表示高度匹配。但对于更宽泛的问题,小 chunk 可能会缺失必要上下文,从而让检索不够准确。大 chunk 则有助于覆盖更广主题,但代价是细粒度语义信息的损失。不过,正因为这些大 chunk 带来了更多上下文,它们在答案生成阶段往往会更有价值,因为它们为 LLM 提供了更多背景信息。

在细粒度 chunk 和粗粒度 chunk 之间找到合适平衡,取决于你的用例。你需要先问自己一个问题:你的用户问题更可能是细节型,还是宽泛型?chunk 大小应该尽量与预期查询类型匹配。

8.3.1 切分策略

chunk 的大小并不是唯一要考虑的因素。你还必须决定如何切分文档。主要有两种方式:

按文档层级结构切分(splitting by document hierarchy) —— 这种方法尊重文档本身的自然结构,例如章节、节、小段落等。如果文档本身是按主题组织的,那么这种方式非常有效,因为切出来的 chunk 往往代表语义连贯的子主题。LangChain 中的 HTMLHeaderTextSplitterMarkdownHeaderTextSplitter 就是针对特定文档类型设计的,它们有助于维持语义准确性。但这种方式的 chunk 大小可能差异很大。

按绝对大小切分(splitting by absolute size) —— 你可以按字符数、token 数、句子数或词数来定义 chunk 大小。这会得到更一致的 chunk 大小,但如果 chunk 在句子中间被切开,就可能丢失上下文。CharacterTextSplitter 及其变体支持不同粒度级别,但你需要自行测试最优大小。通常需要在多个固定大小范围上做评估,才能找出最适合你用例的设置。

8.3.2 需要考虑的因素

对于每一种切分策略,都需要记住以下几点:

文档类型 —— 如果你处理的是混合内容(例如文本、表格、图片),那么保持彼此相关的内容落在同一个 chunk 中就非常重要。在这种情况下,按文档层级结构切分通常会比固定大小切分更有效。

搜索类型 —— 如果你打算使用元数据搜索,那么关键词本身也可以随着 chunk 粒度的不同而进行细化。你甚至可以为每个 chunk 同时附加宽泛标签和细节标签,从而提高检索灵活性。

8.3.3 如何选择合适的策略

选择最佳策略往往需要一些试错,但随着经验积累,你最终会逐渐掌握文档层级与绝对大小之间的平衡点。表 8.2 总结了各类策略的优缺点,并列出了相应的 LangChain 类。

表 8.2 切分策略、优缺点及对应 LangChain 类

切分策略优点缺点LangChain 类
文档层级结构语义更准确chunk 大小差异很大HTMLHeaderTextSplitterMarkdownHeaderTextSplitter
按大小:token 数chunk 大小一致边界可能截断句子TokenTextSplitter
按大小:字符数chunk 大小一致截断句子可能降低语义价值CharacterTextSplitter
按句子、段落或词大多数情况下能保留语义小 chunk 可能缺少足够上下文RecursiveCharacterTextSplitter

每种方法都有其适用场景,具体选择取决于你的实际需求和文档结构。接下来几个小节中,我会详细展开这些策略,先从基于文档层级结构的切分开始。

8.3.4 按 HTML 标题切分

在这一节中,我将向你展示如何使用 HTMLHeaderTextSplitter 类,对来自 Wikivoyage(www.wikivoyage.org) 的多个在线文档进行切分。Wikivoyage 是一个与 Wikipedia 同属一个体系的旅游网站。我们将观察:当切分粒度不同的时候,回答准确性会受到怎样的影响。

首先,先配置好环境:创建一个新文件夹和虚拟环境,然后安装所需依赖包:

C:\Github\building-llm-applications\ch08> python -m venv env_ch08
C:\Github\building-llm-applications\ch08> env_ch08\Scripts\activate
(env_ch08) C:\Github\building-llm-applications\ch08> 
↪pip install -r requirements.txt

然后启动一个新的 Jupyter Notebook,或者打开你克隆仓库中已经存在的那个:

(env_ch08) C:\Github\building-llm-applications\ch08> jupyter notebook

把 notebook 保存为 08-advanced_indexing.ipynb,并导入所需库:

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
import getpass

OPENAI_API_KEY = getpass.getpass('Enter your OPENAI_API_KEY')

配置 ChromaDB collections

现在先创建一个 ChromaDB collection,用于保存更细粒度的 chunk:

cornwall_granular_collection = Chroma(   #1
    collection_name="cornwall_granular",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

cornwall_granular_collection.reset_collection()   #2
#1 Creates a ChromaDB collection
#2 Resets the collection if it already exists

这会初始化一个名为 cornwall_granular_collection 的新 Chroma collection。如果这个 collection 已经存在,就会被清空并重置。接着,再设置第二个 collection,用于存储更粗粒度的 chunk:

cornwall_coarse_collection = Chroma(   #1
    collection_name="cornwall_coarse",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

cornwall_coarse_collection.reset_collection()   #2
#1 Creates a ChromaDB collection
#2 Resets the collection in case it already exists

使用 AsyncHtmlLoader 加载 HTML 内容

最后一步,是用一个 HTML loader 摄取一些关于英国 Cornwall 的内容。Cornwall 以优美的海滨度假区著称:

from langchain_community.document_loaders import AsyncHtmlLoader
destination_url = "https://en.wikivoyage.org/wiki/Cornwall"
html_loader = AsyncHtmlLoader(destination_url)
docs = html_loader.load()

这段代码会抓取 Cornwall 页面内容,稍后我们将用它来同时生成细粒度 chunk 和粗粒度 chunk。接下来的步骤里,我会展示如何基于 HTML 标题对内容进行切分,并分析这对检索准确率的影响。

使用 HTMLSectionSplitter 把内容切成细粒度 chunk

在把 docs 对象中的内容存入向量数据库之前,你需要先决定采用哪种切分策略。由于这些内容来自 HTML 页面,因此你可以按照 H1H2 标签进行切分,它们本来就把页面分隔成了不同的 section。这样会生成更细粒度的 chunk,如下面的代码清单所示。

代码清单 8.1 使用 HTMLSectionSplitter 切分内容

from langchain_text_splitters import HTMLSectionSplitter

headers_to_split_on = [("h1", "Header 1"), ("h2", "Header 2")]
html_section_splitter = HTMLSectionSplitter(
    headers_to_split_on=headers_to_split_on)

def split_docs_into_granular_chunks(docs):
    all_chunks = []
    for doc in docs:
        html_string = doc.page_content  #1
        temp_chunks = html_section_splitter.split_text(
            html_string)  #2
        all_chunks.extend(temp_chunks) 

    return all_chunks
#1 Extracts the HTML text from the document
#2 Splits by H1 and H2 sections

现在你可以生成细粒度 chunk:

granular_chunks = split_docs_into_granular_chunks(docs)

然后把这些细粒度 chunk 插入到 Chroma collection 中:

cornwall_granular_collection.add_documents(documents=granular_chunks)

搜索细粒度 chunk

现在你可以针对这些细粒度 chunk 执行特定内容的搜索:

results = corwnall_granular_collection.similarity_search(
    query="Events or festivals in Cornwall",k=3)
for doc in results:
    print(doc)

使用 RecursiveCharacterTextSplitter 把内容切成粗粒度 chunk

如果你想要更大、更粗粒度的 chunk,可以使用 RecursiveCharacterTextSplitter。先创建必要对象:

from langchain_community.document_transformers import Html2TextTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter

html2text_transformer = Html2TextTransformer()
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=3000, chunk_overlap=300
)

接着,定义一个函数,把内容切分成粗粒度 chunk:

def split_docs_into_coarse_chunks(docs):
    text_docs = html2text_transformer.transform_documents(
        docs)   #1
    coarse_chunks = text_splitter.split_documents(
        text_docs) #2

    return coarse_chunks
#1 Converts HTML to plain text
#2 Splits text into larger chunks

然后生成粗粒度 chunk:

coarse_chunks = split_docs_into_coarse_chunks(docs)

并把它们插入到对应的 Chroma collection 中:

cornwall_coarse_collection.add_documents(documents=coarse_chunks)

搜索粗粒度 chunk

现在你可以在这些粗粒度 chunk 中搜索更泛化的内容:

results = corwnall_coarse_collection.similarity_search(
    query="Events or festivals in Cornwall",k=3)
for doc in results:
    print(doc)

从多个 URL 摄取内容

为了让搜索更全面,你还可以把更多内容加载到 collections 中。代码清单 8.2 展示了如何为多个英国旅游目的地创建新的 granular 和 coarse collections,并把对应内容 chunk 摄取进去。如果你希望尽量降低处理成本,可以考虑缩短 uk_destinations 列表。

代码清单 8.2 为多个英国目的地创建 collections

uk_granular_collection = Chroma(   #1
    collection_name="uk_granular",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)
uk_granular_collection.reset_collection()   #2

uk_coarse_collection = Chroma(  #1
    collection_name="uk_coarse",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)
uk_coarse_collection.reset_collection()   #3

uk_destinations = [
    "Cornwall", "North_Cornwall", "South_Cornwall", "West_Cornwall", 
    "Tintagel", "Bodmin", "Wadebridge", "Penzance", "Newquay",
    "St_Ives", "Port_Isaac", "Looe", "Polperro", "Porthleven",
    "East_Sussex", "Brighton", "Battle", "Hastings_(England)", 
    "Rye_(England)", "Seaford", "Ashdown_Forest"
]

wikivoyage_root_url = "https://en.wikivoyage.org/wiki"

uk_destination_urls = [f'{wikivoyage_root_url}/{d}
↪' for d in uk_destinations]

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)   #4
    docs = html_loader.load()   #5

    granular_chunks = split_docs_into_granular_chunks(docs)
    uk_granular_collection.add_documents(documents=granular_chunks)

    coarse_chunks = split_docs_into_coarse_chunks(docs)
    uk_coarse_collection.add_documents(documents=coarse_chunks)
#1 Creates a ChromaDB collection
#2 Resets the collection if it already exists
#3 Resets the collection if it already exists
#4 Loader for one destination
#5 Documents of one destination

现在你就可以同时执行 granular 搜索和 coarse 搜索:

granular_results = uk_granular_collection.similarity_search(
    query="Events or festivals in East Sussex",k=4)
for doc in granular_results:
    print(doc)

coarse_results = uk_coarse_collection.similarity_search(
    query="Events or festivals in East Sussex",k=4)
for doc in coarse_results:
    print(doc)

你可以试着改用其他查询,比如 "Beaches in Cornwall",观察 granular chunk 和 coarse chunk 的结果差异。这种实验方式能帮助你逐步调优“细节内容检索”和“宽泛内容检索”之间的平衡。

8.4 Embedding 策略

前面我提到过:关键词搜索比向量搜索更灵活,因为你可以给一个文档打上多个关键词标签。同样的思想,也可以用于 embedding——你可以为每个文档存储多个向量,这会提升向量搜索的灵活性与准确性。接下来几个小节中,我会带你了解多种 multi-vector 策略,重点会放在如何使用 LangChain 的 MultiVectorRetriever

这些策略的关键,在于一种双层 chunk 结构。上层是综合用 chunk(synthesis chunks) ——也就是最终会送进 LLM,用于生成答案的 chunk。下层则是检索用 chunk(retrieval chunks) ——它们更小、更细,用于生成更精确的 embedding,从而帮助找回那些上层综合用 chunk。我建议你把这些 multi-vector retriever 技术都在自己的用例上试一遍,因为它们通常都会带来类似的性能提升。不过,由于你的文本结构可能天然更适合某一种方法,因此都试过一轮,才能真正看出哪一种最适合你。

8.4.1 用 ParentDocumentRetriever 为子 chunk 建立 embedding

chunk 大小常见的一个难题,是如何在上下文细节之间平衡。大 chunk 对宽泛问题效果较好,但面对细节型问题时表现欠佳。小 chunk 虽然适合细节型问题,却经常缺乏生成完整答案所需的上下文。这就形成了一种权衡:如果 chunk 太小,回答可能不完整;但如果 chunk 太大,检索就可能不够精确。

解决这个问题的方法之一,是先把文档切成较大的 parent chunks,然后再在每个 parent chunk 内部切出更小的 child chunks。这些 child chunks 仅用于生成更细粒度的 embedding,而这些 embedding 最终会关联回 parent chunk,如图 8.4 所示。

image.png

图 8.4 子 chunk embedding。一个粗粒度文档块既会用它自身的 embedding 建立索引,也会用其内部更小子块生成的 embedding 建立索引,因此它既能匹配宽泛问题,也能匹配细节问题。

这种方法的优势在于:当面对宽泛查询时,系统会检索到 parent chunk,从而提供丰富上下文;而面对更细节化的查询时,child chunk embedding 会确保精确匹配,但最终返回的仍然是上下文更丰富的 parent chunk。这样的结构有助于 LLM 生成更准确、更符合上下文的答案。

下面我会向你展示如何使用 ParentDocumentRetriever 来实现这一技术。先导入所需库:

from langchain_classic.retrievers import ParentDocumentRetriever
from langchain_classic.storage import InMemoryStore
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

设置 ParentDocumentRetriever

这种方法会使用两类存储:一个是 document store,用来保存完整的 parent documents;另一个是 vector store,用来保存更小的 child chunks 以及它们的 embedding。每个 child chunk 都会带有一个指向其 parent document 的引用。整个方案首先把内容切分成较大的 coarse chunk,供后续综合使用,然后再把每个 coarse chunk 进一步切成更小的 child chunk,以供检索使用。代码清单 8.3 演示了如何配置 splitter 并搭建 retriever。正如你将看到的,文档会被保存在 InMemoryStore 中——这是一个通用的内存型键值存储,用于保存可序列化的 Python 对象,例如字符串、列表和字典。它非常适合用来做缓存和中间数据存储。

代码清单 8.3 针对 coarse chunk 和 granular chunk 的 parent / child splitter

parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=3000)   #1
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500)   #2

child_chunks_collection = Chroma(  #3
    collection_name="uk_child_chunks",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

child_chunks_collection.reset_collection()   #4

doc_store = InMemoryStore()   #5

parent_doc_retriever = ParentDocumentRetriever(   #6
    vectorstore=child_chunks_collection,
    docstore=doc_store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter
)
#1 Splitter to generate parent coarse chunks from original documents (parsed from web pages)
#2 Splitter to generate child granular chunks from parent
#3 Vector store collection to host child granular chunks
#4 Makes sure the collection is empty
#5 Document store to host parent coarse chunks
#6 Retriever to link parent coarse chunks to child granular chunks

把内容摄取到 document store 和 vector store 中

现在,你可以使用刚才配置好的 splitter 同时生成 coarse chunk 和 granular chunk,并把它们分别存入对应的存储中。对于每一个目的地 URL,对应的一次 parent_doc_retriever.add_documents() 调用都会完成这件事:

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)   #1
    html_docs =  html_loader.load()   #2
    text_docs = html2text_transformer.transform_documents(
       html_docs)   #3

    print(f'Ingesting {destination_url}')
    parent_doc_retriever.add_documents(
       text_docs, ids=None)   #4
#1 Loader for the destination web page
#2 HTML documents of one destination
#3 Transforms HTML documents into clean text documents
#4 Ingests coarse chunks into the document store and granular chunks into the vector store

验证内存型 document store

你可以通过下面的方式检查 coarse chunk 是否已经正确存进 document store:

list(doc_store.yield_keys())

针对细粒度信息执行搜索

现在,使用 ParentDocumentRetriever 针对子 chunk 执行一次搜索:

retrieved_docs = parent_doc_retriever.invoke("Cornwall Ranger")

第一个检索出来的文档(retrieved_docs[0])会包含丰富的上下文信息:

Document (metadata={'source': 'https://en.wikivoyage.org/wiki/South_Cornwall', 'title': 'South Cornwall – Travel guide at Wikivoyage', 'language': 'en'}, page_content="Trains from London take about 3 hr 20 min to Plymouth.\n\n### By car\n\n[edit]\n\nCornwall can be accessed by road via the A30 which runs from the end of the M5\nat Exeter, all the way through the heart of Devon and Cornwall down to Land's\nEnd. It is a grade-separated expressway as far as Carland Cross near Truro\n(the expressway is expected to be open as far as Camborne (between Redruth and\nHayle) by March 2024). You can also get to Cornwall via the A38, crossing the\nRiver Tamar at Plymouth via the Tamar Bridge, which levies a toll on eastbound\nvehicles. On summer Saturdays and during bank holiday weekends roads to\nCornwall are usually busy.\n\n## Get around\n\n[edit]\n\n### By bus\n\n[edit]\n\nThanks to Transport for Cornwall, all bus tickets are interchangeable across\nthe different companies. The **Cornwall All Day ticket** allows unlimited\ntravel for a calendar day. As of 2023, fares are £5 for adults and £4 for\nunder-19s. Payment … [SHORTENED]

与直接对 child chunk 做语义搜索进行比较

现在,把结果与仅对 child chunk 直接搜索的方式做个对比:

child_docs_only = child_chunks_collection
↪.similarity_search("Cornwall Ranger")

第一个返回结果会更短,而且缺乏上下文:

Document(metadata={'doc_id': '34645d23-ed05-4a53-b3af-c8ab21e3f513', 'language': 'en', 'source': 'https://en.wikivoyage.org/wiki/South_Cornwall', 'title': 'South Cornwall – Travel guide at Wikivoyage'}, 
page_content='The **Cornwall Ranger** ticket allows unlimited train travel in Cornwall and\nPlymouth for a calendar day. As of 2023, this costs £14 for adults and £7 for\nunder-16s.\n\n## See\n\n[edit]\n\nThe **Eden Project** , near St Austell...')

通过 ParentDocumentRetriever 得到的结果,在作为 LLM 综合回答时所需的上下文时尤其有价值,因为它不仅包含具体信息,还带上了周边背景。接下来,我将继续介绍另一种通过 embedding 策略进一步提升 RAG 检索准确率的方法。

8.4.2 用 MultiVectorRetriever 为子 chunk 建立 embedding

另一种把子 chunk embedding 与用于综合的大型 parent chunk 关联起来的方法,是使用 MultiVectorRetriever。首先导入所需库,其中包括 InMemoryByteStore。它是一个专门设计用来存储二进制数据的存储结构:key 是字符串,value 是 bytes,因此特别适合用来保存 embedding、模型或文件这类更适合以原始字节形式存储的数据:

from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
from langchain_classic.storage import InMemoryByteStore
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import uuid

设置 MultiVectorRetriever

你可以沿用与 ParentDocumentRetriever 相似的思路:先定义 parent splitter 和 child splitter,再把它们注入到 MultiVectorRetriever 中,如下一个代码清单所示。

代码清单 8.4 用于 MultiVectorRetriever 的 parent / child splitter

parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=3000)  #1
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500)  #2

child_chunks_collection = Chroma(  #3
    collection_name="uk_child_chunks",
    embedding_function=OpenAIEmbeddings(
        openai_api_key=OPENAI_API_KEY),
)

child_chunks_collection.reset_collection()  #4

doc_byte_store = InMemoryByteStore()  #5
doc_key = "doc_id"

multi_vector_retriever = MultiVectorRetriever(  #6
    vectorstore=child_chunks_collection,
    byte_store=doc_byte_store
)
#1 Splitter to generate parent coarse chunks from original documents (parsed from web pages)
#2 Splitter to generate child granular chunks from parent coarse chunks
#3 Vector store collection to host child granular chunks
#4 Makes sure the collection is empty
#5 Document store to host parent coarse chunks
#6 Retriever to link parent coarse chunks to child granular chunks

把内容摄取到 document store 和 vector store 中

下一步,就是像代码清单 8.5 那样,把内容加载并摄取进 MultiVectorRetriever。你可能会感觉这个过程更慢,这是正常的,因为它要额外管理多种向量表示,所以整体复杂度更高。

代码清单 8.5 把内容摄取到 document store 和 vector store

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)  #1
    html_docs =  html_loader.load()  #2
    text_docs = html2text_transformer.transform_documents(
        html_docs)  #3

    coarse_chunks = parent_splitter.split_documents(
        text_docs) #4

    coarse_chunks_ids = [str(uuid.uuid4()) for _ in coarse_chunks]
    all_granular_chunks = []
    for i, coarse_chunk in enumerate(
        coarse_chunks):  #5

        coarse_chunk_id = coarse_chunks_ids[i]

        granular_chunks = child_splitter.split_documents(
            [coarse_chunk])  #6

        for granular_chunk in granular_chunks:
            granular_chunk.metadata[doc_key] = \
               coarse_chunk_id  #7

        all_granular_chunks.extend(granular_chunks)

    print(f'Ingesting {destination_url}')
    multi_vector_retriever.vectorstore.add_documents(
        all_granular_chunks)  #8
    multi_vector_retriever.docstore.mset(
        list(zip(coarse_chunks_ids, coarse_chunks)))  #9
#1 Loader for one destination
#2 Documents of one destination
#3 Transforms HTML documents into clean text documents
#4 Splits the destination content into parent coarse chunks
#5 Iterates over the parent coarse chunks
#6 Creates child granular chunks from each parent coarse chunk
#7 Links each child granular chunk to its parent coarse chunk
#8 Ingests the child granular chunks into the vector store
#9 Ingests the parent coarse chunks into the document store

对细粒度信息执行搜索

摄取完成后,像使用 ParentDocumentRetriever 时一样,用 MultiVectorRetriever 发起一次搜索:

retrieved_docs = multi_vector_retriever.invoke(
    "Cornwall Ranger")

如果打印第一个结果,你会发现它同样是一个包含丰富上下文的大块文档:

Document(metadata={'source': 'https://en.wikivoyage.org/wiki/South_Cornwall', 'title': 'South Cornwall – Travel guide at Wikivoyage', 'language': 'en'}, 
page_content="Trains from London take about 3 hr 20 min to Plymouth.\n\n### By car\n\nCornwall can be accessed by road via the A30 which runs from the end of the M5\nat Exeter, all the way through the heart of Devon and Cornwall...")

与直接对 child chunk 做语义搜索进行比较

为了比较,再直接在 child chunk collection 上执行同样的搜索:

child_docs_only =  child_chunks_collection.similarity_search(
    "Cornwall Ranger")

从 child collection 中取回的第一个文档(child_docs_only[0])会更简洁,但缺少更大的上下文:

Document(metadata={'doc_id': '04c7f88e-e090-4057-af5b-ea584e777b3f', 'language': 'en', 'source': 'https://en.wikivoyage.org/wiki/South_Cornwall', 'title': 'South Cornwall – Travel guide at Wikivoyage'}, 
page_content='The **Cornwall Ranger** ticket allows unlimited train travel in Cornwall and\nPlymouth for a calendar day. As of 2023, this costs £14 for adults and £7 for\nunder-16s.\n\n## See\n\nThe **Eden Project** , near St Austell...')

这个结果和你在 ParentDocumentRetriever 中看到的现象很类似:返回更大的 parent chunk,能够为综合生成提供更有用的上下文,因此在复杂查询或细节查询场景下更合适。

下一节中,我会继续介绍更多利用高级 embedding 技术来提升 RAG 准确率的策略,我们先从对摘要做 embedding开始。

8.4.3 对文档摘要做 embedding

来自 coarse chunk 的 embedding 往往效果不佳,因为它们会把太多无关内容一并编码进去。一个大的 chunk 可能混入填充性文字或次要细节,从而稀释 embedding 的语义价值,让它变得不够聚焦、不够好用。

为了解决这个问题,你可以先为 coarse chunk 生成一个摘要,然后对这个摘要生成 embedding。接着,把这些摘要 embedding与原始 chunk embedding 一起存储,如图 8.5 所示。由于摘要本身更精炼、更聚焦,因此得到的 embedding 也会更密集、更适合检索,从而减少噪声并提升搜索精度。

image.png

图 8.5 chunk 摘要 embedding。一个粗粒度 chunk 会同时使用它自身的 embedding 和由其摘要生成的额外 embedding 来建立索引,从而在回答细节型问题时实现更准确的检索。

开始之前,先导入构建摘要与检索流程所需的库:

from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
from langchain_classic.storage import InMemoryByteStore
from langchain_chroma import Chroma
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
import uuid

设置 MultiVectorRetriever

首先,创建一个用来保存摘要的 collection,并设置 document store(InMemoryByteStore)。然后,像下面这样配置 MultiVectorRetriever

代码清单 8.6 设置 MultiVectorRetriever

parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=3000)  #1

summaries_collection = Chroma(  #2
    collection_name="uk_summaries",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

summaries_collection.reset_collection()  #3

doc_byte_store = InMemoryByteStore()  #4
doc_key = "doc_id"

multi_vector_retriever = MultiVectorRetriever(  #5
    vectorstore=summaries_collection,
    byte_store=doc_byte_store
)
#1 Splitter to generate parent coarse chunks from original documents (parsed from web pages)
#2 Vector store collection to host child granular chunks
#3 Makes sure the collection is empty
#4 Document store to host parent coarse chunks
#5 Retriever to link parent coarse chunks to child granular chunks

设置摘要链

使用一个 LLM 来为 coarse chunk 生成摘要。定义一条摘要链,它会提取文档内容、把内容喂给 prompt、再将 LLM 的输出解析成可用格式:

llm = ChatOpenAI(model="gpt-5-nano", openai_api_key=OPENAI_API_KEY)

summarization_chain = (
    {"document": lambda x: x.page_content}  #1
    | ChatPromptTemplate.from_template("Summarize the
    ↪following document:\n\n{document}")   #2
    | llm   #3
    | StrOutputParser())   #4
#1 Grabs the text content from the document
#2 Instantiates a prompt asking to generate summary of the provided text
#3 Sends the LLM the instantiated prompt
#4 Extracts the summary text from the response

把 coarse chunk 与摘要同时摄取到存储中

接下来,加载内容、把它切成 coarse chunk,并为这些 chunk 生成摘要。然后,把摘要存入 vector store,而把对应的 coarse chunk 存入 document store,如下一个代码清单所示。

代码清单 8.7 摄取 coarse chunk 及其摘要

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)  #1
    html_docs =  html_loader.load()  #2
    text_docs = html2text_transformer.transform_documents(
        html_docs)  #3

    coarse_chunks = parent_splitter.split_documents(
        text_docs)  #4

    coarse_chunks_ids = [str(uuid.uuid4()) for _ in coarse_chunks]
    all_summaries = []
    for i, coarse_chunk in enumerate(
        coarse_chunks):  #5

        coarse_chunk_id = coarse_chunks_ids[i]

        summary_text =  summarization_chain.invoke(
            coarse_chunk)  #6
        summary_doc = Document(page_content=summary_text, 
                               metadata={doc_key: coarse_chunk_id})

        all_summaries.append(summary_doc)  #7

    print(f'Ingesting {destination_url}')
    multi_vector_retriever.vectorstore.add_documents(
        all_summaries)  #8
    multi_vector_retriever.docstore.mset(
        list(zip(coarse_chunks_ids, coarse_chunks)))  #9
#1 Loader for one destination
#2 Documents of one destination
#3 Transforms HTML documents into clean text documents
#4 Splits the destination content into coarse chunks
#5 Iterates over the coarse chunks
#6 Generates a summary for the coarse chunk through the summarization chain
#7 Links each summary to its related coarse chunk
#8 Ingests the summaries into the vector store
#9 Ingests the coarse chunks into the document store

当你运行代码清单 8.7 时,可能会感觉处理速度比使用 child embedding 时更慢。这是正常的,因为这里每个 coarse chunk 都需要提交给 LLM 去生成摘要,这是一个计算成本更高的步骤。

使用 MultiVectorRetriever 执行搜索

一旦摄取完成,你就可以使用 MultiVectorRetriever 执行一次搜索,此时它使用的已经是每个旅行目的地的摘要:

retrieved_docs = multi_vector_retriever.invoke("Cornwall travel")

如果你打印第一个结果(retrieved_docs_only[0]),你会看到一个和使用 child embedding 时类似的大块文本。这些更大的 chunk 能提供更多上下文,因此在作为 LLM 输入时很有效。

与直接对摘要做语义搜索进行比较

为了比较效果,你可以直接对摘要本身执行一次搜索:

summary_docs_only =  summaries_collection.similarity_search(
    "Cornwall Travel")

print(summary_docs_only[0])

从摘要搜索得到的第一个结果会比较简洁,但缺乏更大范围的上下文:

Document(metadata={'doc_id': 'ee55d250-bc53-46ce-9204-8fd2c1a05662'}, page_content="Cornwall is a county located in the southwest of the United Kingdom, known for its distinctive character, warm climate, and beautiful coastline. It is popular among holidaymakers due to its rich Celtic heritage, cultural tourism, and historical connections to arts and mining, which is recognized by UNESCO. Over 30% of Cornwall is designated as an Area of Outstanding Natural Beauty (AONB). ...")

这个结果再次验证了我们前面观察到的规律:直接对摘要或 child chunk 做搜索,虽然能得到更聚焦的信息,但上下文有限;而 multi-vector 方案则能返回更大上下文,更适合作为综合回答的基础。下一节,我们继续介绍另一种高级 multi-vector embedding 技术。

8.4.4 对假设性问题做 embedding

当你查询向量存储时,系统会先把你的自然语言问题转换成向量,然后计算它与已存向量之间的相似度(例如余弦距离)。接着,和最接近向量关联的文档就会被返回。这种方法在“问题本身的语义”与“理想答案的语义”非常接近时效果不错。但很多时候,问题的表述方式和理想答案的措辞并不会高度一致,这就可能导致搜索错过真正相关的文档。

为了解决这个问题,你可以为每个 chunk 生成若干个它“可能能够回答的假设性问题(hypothetical questions) ”,然后用这些问题生成的 embedding 来为该 chunk 建立额外索引,如图 8.6 所示。这样可以增加已存向量与用户真实查询对齐的概率,从而即使原始文档的 embedding 与问题本身不够贴合,也仍然更容易检索到相关内容。

image.png

图 8.6 假设性问题 embedding。一个文档 chunk 会同时使用它自身的 embedding 和由它可能回答的假设性问题生成的额外 embedding 建立索引,从而能更准确地匹配用户查询。

开始之前,先导入配置带假设性问题 embedding 的 MultiVectorRetriever 所需的所有库:

from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
from langchain_classic.storage import InMemoryByteStore
from langchain_chroma import Chroma
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
import uuid
from typing import List
from pydantic import BaseModel, Field

设置 MultiVectorRetriever

它的配置方式和摘要 embedding 时很相似,只不过这次我们要使用一个专门存储假设性问题的 vector store,如下面的代码清单所示。

代码清单 8.8 带假设性问题的 MultiVectorRetriever

parent_splitter = RecursiveCharacterTextSplitter(
↪chunk_size=3000)   #1

hypothetical_questions_collection = Chroma(   #2
    collection_name="uk_hypothetical_questions",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

hypothetical_questions_collection
↪.reset_collection()   #3

doc_byte_store = InMemoryByteStore()   #4
doc_key = "doc_id"

multi_vector_retriever = MultiVectorRetriever(   #5
    vectorstore=hypothetical_questions_collection,
    byte_store=doc_byte_store
)
#1 Splitter to generate parent coarse chunks from original documents (parsed from web pages)
#2 Vector store collection to host child granular chunks
#3 Makes sure the collection is empty
#4 Document store to host parent coarse chunks
#5 Retriever to link parent coarse chunks to child granular

设置假设性问题生成链

接下来,创建一条链,用于为每个文档 chunk 生成假设性问题。这里我们会使用 LLM 的结构化输出(structured output) ,以确保生成的问题最终以字符串列表的形式返回:

class HypotheticalQuestions(BaseModel):
    """A list of hypotetical questions for given text."""

    questions: List[str] = Field(..., description
    ↪="List of hypothetical questions for given text")

llm_with_structured_output = ChatOpenAI(
    model="gpt-5-nano", 
    openai_api_key=OPENAI_API_KEY).with_structured_output(
        HypotheticalQuestions
)

完整的问题生成链如下一个代码清单所示。

代码清单 8.9 从文本生成假设性问题的链

hypothetical_questions_chain = (
    {"document_text": lambda x: x.page_content}   #1
    | ChatPromptTemplate.from_template(   #2
        "Generate a list of exactly 4 hypothetical questions 
        ↪that the below text could be used to answer:
        ↪\n\n{document_text}"
    )
    | llm_with_structured_output   #3
    | (lambda x: x.questions)  #4
)
#1 Grabs the text content from the document
#2 Instantiates a prompt asking to generate four hypothetical questions on the provided text
#3 Invokes the LLM configured to return an object containing the questions as a typed list of strings
#4 Grabs the list of questions from the response

把 coarse chunk 与对应假设性问题一起摄取进去

现在,生成 coarse chunk,再为每个 coarse chunk 生成假设性问题,并把它们分别存入对应 collection,如下一个代码清单所示。

代码清单 8.10 摄取 coarse chunk 及其假设性问题

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)  #1
    html_docs =  html_loader.load()  #2
    text_docs = html2text_transformer.transform_documents(
        html_docs)  #3

    coarse_chunks = parent_splitter.split_documents(
        text_docs)  #4

    coarse_chunks_ids = [str(uuid.uuid4()) for _ in coarse_chunks]
    all_hypothetical_questions = []
    for i, coarse_chunk in enumerate(
        coarse_chunks):  #5

        coarse_chunk_id = coarse_chunks_ids[i]

        hypothetical_questions = hypothetical_questions_chain.invoke(
            coarse_chunk)  #6
        hypothetical_questions_docs = [Document(
            page_content=question, metadata={doc_key: coarse_chunk_id})
                    for question 
                    in hypothetical_questions]  #7

        all_hypothetical_questions.extend(hypothetical_questions_docs)

    print(f'Ingesting {destination_url}')
    multi_vector_retriever.vectorstore.add_documents(
        all_hypothetical_questions)  #8
    multi_vector_retriever.docstore.mset(
        list(zip(coarse_chunks_ids, coarse_chunks))) #9
#1 Loader for one destination
#2 Documents of one destination
#3 Transforms HTML documents into clean text documents
#4 Splits the destination content into coarse chunks
#5 Iterates over the coarse chunks
#6 Generates a list of hypothetical questions for the coarse chunk through the question generation chain
#7 Links each hypothetical question to its related coarse chunk
#8 Ingests the hypothetical questions into the vector store
#9 Ingests the coarse chunks into the document store

使用 MultiVectorRetriever 执行搜索

摄取完成后,你就可以使用 MultiVectorRetriever 发起一次搜索,此时它使用的是已存储的假设性问题 embedding:

retrieved_docs = multi_vector_retriever.invoke(
    "How can you go to Brighton from London?")

第一个被检索出来的文档(retrieved_docs[0])会是一个包含丰富上下文的详细结果:

[Document(metadata={'source': 'https://en.wikivoyage.org/wiki/Brighton', 'title': 'Brighton – Travel guide at Wikivoyage', 'language': 'en'}, page_content='### By plane\n\n[edit]\n\nThe city's proximity to London means Brighton is well served by airports.\nBrighton can be reached from Gatwick by train in as little as 25 minutes\n£9.8011.90, Jan 2023).\n\n  * 50.8332-0.2923 Shoreham Airport (Brighton City Airport), Cecil Pashley Way, Shoreham-by-Sea, BN43 5FF (Probably best to get a train from Brighton to Shoreham \- about 15 minutes, then a taxi from there to the airport), ☏ +44 1273 467373, reception@flybrighton.com. This airport (**ESH** IATA) is 5 miles (8 km) to the west of Brighton. It is the nearest airport for light aircraft and also offers sightseeing flights. However, there are no scheduled flights from here. This is the oldest licensed airport in the UK. (updated Sep 2017)\n\n## Get around\n\n[edit]\n\n50°50′14″N 0°8′56″W\n\nMap of Brighton\n\nBrightonians often give directions relative to a prominent landmark, the\n**Clock Tower** , which stands due south of the rail station where Queen's\nRoad meets Dyke Road (oh yes it does), West Street, North Street and Western\nRoad.\n\nThe oldest part of the city is the **Lanes** , which is bounded by North\nStreet, West Street and East Street, through which runs Middle Street, and\nShip Street. Beware the spelling of the similar-named **North Laine** (meaning\n"north fields") which is a boutique and alternative shopping nirvana, to the\nnorth side of North Street.\n\nWestern Road, a major shopping street runs east–west from the Clock Tower,\nwhilst Eastern Road runs up a hill towards the main hospital from the area\nknown as the **Old Steine** (rhymes with clean) which has Brighton Pier at the\nseafront here.\n\n… [SHORTENED…].

与直接对假设性问题做搜索进行比较

现在,为了对比效果,直接在假设性问题 collection 上执行同样的搜索:

hypothetical_question_docs_only =
↪hypothetical_questions_collection.similarity_search(
    "How can you go to Brighton from London?")

你会看到返回结果是一些与查询高度贴近的假设性问题:

[Document(metadata={'doc_id': 'af848894-8591-4c28-8295-f3b833ffaa43'}, page_content='What if someone wanted to travel from Brighton to London quickly?'),
Document(metadata={'doc_id': '7fa14e56-270c-4461-88ab-9b546afb07b1'}, page_content='What if someone wants to attend a performance at Glyndebourne Opera House, how can they arrange their visit from Brighton?'),
Document(metadata={'doc_id': '7fa14e56-270c-4461-88ab-9b546afb07b1'}, page_content='If a traveler is looking for a day trip from Brighton to France, what options do they have?'),
Document(metadata={'doc_id': '7fa14e56-270c-4461-88ab-9b546afb07b1'}, page_content='How might a visitor plan their itinerary to include both Lewes and Worthing in one day from Brighton?')]

这个结果与前面几种技术所呈现的模式是一致的:使用假设性问题 embedding,可以让搜索引擎更聚焦于查询背后的真实意图,即便用户问题的表述和原始文档措辞并不一致,也仍然更容易找回相关文档。接下来,我们继续看另一种提升检索准确率的技术。

8.5 细粒度 chunk 扩展

前面已经讲过,把文档切成细粒度小 chunk 的主要缺点在于:虽然这些 chunk 很适合细节型问题,但它们通常缺乏生成完整答案所需的上下文。一种解决办法,就是使用 chunk expansion,如图 8.7 所示。

image.png

图 8.7 句子扩展。一个细粒度 chunk 可以通过把它前后相邻 chunk 的内容也一起带上,从而获得额外上下文。

核心思路是:为每个 chunk 存储一个“扩展版 chunk”,其中包含它前后相邻 chunk 的内容。这个扩展版 chunk 会被存进一个独立的 document store。因此,当向量存储检索到一个相关的细粒度 chunk 时,系统返回的其实是与之关联的“扩展版 chunk”,从而为 LLM 提供更丰富的上下文,以生成更完整的答案。

这种技术可以很容易地通过 MultiVectorRetriever 实现。下面我们来看具体怎么配置。

为 chunk expansion 设置 MultiVectorRetriever

首先,配置 MultiVectorRetriever:创建一个用来保存 granular chunk 的 collection,以及一个内存型 document store,用来保存扩展版 chunk。代码如下。

代码清单 8.11 用于细粒度 chunk 扩展的 MultiVectorRetriever

from langchain_classic.retrievers.multi_vector import MultiVectorRetriever
from langchain_classic.storage import InMemoryByteStore
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import uuid

granular_chunk_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500)   #1

granular_chunks_collection = Chroma(   #2
    collection_name="uk_granular_chunks",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY),
)

granular_chunks_collection.reset_collection()   #3

expanded_chunk_store = InMemoryByteStore()   #4
doc_key = "doc_id"

multi_vector_retriever = MultiVectorRetriever(   #5
    vectorstore=granular_chunks_collection,
    byte_store=expanded_chunk_store
)
#1 Splitter to generate granular chunks from original documents (parsed from web pages)
#2 Vector store collection to host child granular chunks
#3 Makes sure the collection is empty
#4 Document store to host expanded chunks
#5 Retriever to link parent coarse chunks to child granular chunks

摄取细粒度 chunk 和扩展版 chunk

现在,生成扩展版 chunk:它会把相邻 chunk 的内容也一起拼进去。下面的代码清单展示了所需逻辑。

代码清单 8.12 生成并存储扩展版 chunk

for destination_url in uk_destination_urls:
    html_loader = AsyncHtmlLoader(destination_url)  #1
    html_docs =  html_loader.load()  #2
    text_docs = html2text_transformer.transform_documents(
        html_docs)  #3

    granular_chunks = granular_chunk_splitter.split_documents(
        text_docs) #4

    expanded_chunk_store_items = []
    for i, granular_chunk in enumerate(
        granular_chunks):  #5

        this_chunk_num = i  #6
        previous_chunk_num = i-1  #6
        next_chunk_num = i+1  #6

        if i==0:  #7
            previous_chunk_num = None #7
        elif i==(len(granular_chunks)-1): #7
            next_chunk_num = None

        expanded_chunk_text = ""  #8
        if previous_chunk_num:  #8
            expanded_chunk_text += granular_chunks[
                previous_chunk_num].page_content
            expanded_chunk_text += "\n"

        expanded_chunk_text += granular_chunks[
            this_chunk_num].page_content  #8
        expanded_chunk_text += "\n"

        if next_chunk_num:  #8
            expanded_chunk_text += granular_chunks[  
                next_chunk_num].page_content
            expanded_chunk_text += "\n"

        expanded_chunk_id = str(uuid.uuid4())  #9
        expanded_chunk_doc = Document(
            page_content=expanded_chunk_text)  #10

        expanded_chunk_store_item = (expanded_chunk_id, 
                                     expanded_chunk_doc)
        expanded_chunk_store_items.append(
            expanded_chunk_store_item)

        granular_chunk.metadata[
            doc_key] = expanded_chunk_id  #11

    print(f'Ingesting {destination_url}')
    multi_vector_retriever.vectorstore.add_documents(
        granular_chunks)  #12
    multi_vector_retriever.docstore.mset(
        expanded_chunk_store_items)  #13
#1 Loader for one destination
#2 Documents of one destination
#3 Transforms HTML documents into clean text documents
#4 Splits the destination content into granular chunks
#5 Iterates over the granular chunks
#6 Determines the index of the current chunk and its previous and next chunks
#7 Determines the index of the current chunk and its previous and next chunks
#8 Assembles the text of the expanded chunk by including the previous and next chunk
#9 Generates the ID of the expanded chunk
#10 Creates the expanded chunk document
#11 Links each granular chunk to its related expanded chunk
#12 Ingests the granular chunks into the vector store
#13 Ingests the expanded chunks into the document store

使用 MultiVectorRetriever 执行搜索

摄取完成之后,使用 MultiVectorRetriever 执行一次搜索;这时系统会返回扩展版 chunk,从而提供更完整的上下文:

retrieved_docs = multi_vector_retriever.invoke("Cornwall Ranger")

第一个检索出来的文档会包含周边 chunk 的内容,因此能给 LLM 提供更丰富的背景信息:

Document(page_content="Buses only serve designated stops when in towns; otherwise, you can flag them\ndown anywhere that's safe for them to stop.\n\n### By train\n\n[edit]\n\n**CrossCountry Trains** and **Great Western Railway** operate regular train\nservices between the main centres of population, the latter company also\nserving a number of other towns on branch lines. For train times and fares\nvisit National Rail Enquiries.\nThe **Cornwall Ranger** ticket allows unlimited train travel in Cornwall and\nPlymouth for a calendar day. As of 2023, this costs £14 for adults and £7 for\nunder-16s.\n\n## See\n\n[edit]\n\nThe **Eden Project** , near St Austell, a fabulous collection of flora from\nall over the planet housed in two space age transparent domes, and a massive\nzip line.\n## See\n\n[edit]\n\ nThe **Lost Gardens of Heligan** , near Mevagissey, 80 acres (32 hectares) of\nstunning landscaped scenery with a huge complex of walled flower and vegetable\ngardens.\n\nThe **National Maritime Museum** Falmouth is the Home of the National Maritime\nMuseum's small boat collection and other exhibits.\n")

与直接对细粒度 chunk 做语义搜索进行比较

为了比较效果,直接在 granular chunk collection 上执行同样的搜索,不做扩展:

child_docs_only = granular_chunks_collection
↪.similarity_search("Cornwall Ranger")

你会发现返回结果更短、更聚焦,但也缺少周边上下文:

Document(metadata={'doc_id': '04c7f88e-e090-4057-af5b-ea584e777b3f', 'language': 'en', 'source': 'https://en.wikivoyage.org/wiki/South_Cornwall', 'title': 'South Cornwall – Travel guide at Wikivoyage'}, page_content='The **Cornwall Ranger** ticket allows unlimited train travel in Cornwall and\nPlymouth for a calendar day. As of 2023, this costs £14 for adults and £7 for\nunder-16s.\n\n## See\n\n[edit]\n\nThe **Eden Project** , near St Austell, a fabulous collection of flora from\nall over the planet housed in two space age transparent domes, and a massive\nzip line.')

这个较小的 chunk 缺少周边信息,可能不足以支撑生成完整答案。chunk expansion 提供了一种方式:在保留细粒度 embedding 优势的同时,补上更广的上下文。下一节,我们来看如何高效处理结构化与非结构化混合内容。

8.6 半结构化内容

当你处理的是混合了非结构化文本结构化数据(例如表格)的文档时,关键是要把这两类内容分开处理。你应该把结构化内容(如表格)提取出来,并为它们的摘要生成 embedding——就像处理文本 chunk 一样,前面在 1.4.3 节已经讲过类似思路。

你可以把 coarse text chunk 和完整表格一起存进 document store,同时把文本摘要和表格摘要的 embedding 存进 vector store,并通过 MultiVectorRetriever 进行统一管理,如图 8.8 所示。这样一来,结构化内容与非结构化内容都能够被无缝检索出来。

image.png

图 8.8 对结构化和非结构化内容做 embedding。像表格这样的结构化数据,也应像非结构化文本块一样,先做摘要、再生成 embedding。这样它们在面对细节型问题时,能够像文本 embedding 一样有效地匹配查询。使用 MultiVectorRetriever 可以统一管理这两类内容。

当一次搜索命中某个表格摘要的 embedding 时,系统会把整张表(它保存在 document store 中)返回给 LLM 做综合生成,从而提供完整答案所需的上下文。

8.7 多模态 RAG

你应该已经听说过“多模态 LLM(multimodal LLM)”这个概念。像 GPT-4V 这样的模型,不仅能处理文本,还能处理图像和音频。这就为把 RAG 架构扩展到支持多模态数据打开了大门。

它的思路和处理半结构化内容非常类似。在数据准备阶段,你可以使用一个多模态 LLM 为图像生成摘要,就像处理表格时那样。然后,再为这个图像摘要生成 embedding,并把这些 embedding 关联到保存在 document store 中的原始图像上,如图 8.9 所示。

image.png

图 8.9 多模态 RAG 工作流。(1)数据摄取阶段:使用多模态 LLM 生成图像摘要,把摘要 embedding 存入 vector store,并把原始图像保存在 document store 中。(2)检索阶段:如果摘要 embedding 与某个查询匹配,那么 MultiVectorRetriever 就会返回原始图像,并把它送入 LLM 进行综合生成。

在检索阶段,如果摘要 embedding 与用户查询匹配,MultiVectorRetriever 就会返回原始图像——就像它在半结构化文本场景下返回整张表格一样。随后,这张图像会连同其摘要一起传给多模态 LLM,为回答生成提供丰富上下文。

注意 本书不会深入讲解多模态 RAG,因为这是一个相当高级的话题,若要完整展开,需要超出本书范围的大量额外解释。不过,凭借你目前已经掌握的知识,其实已经有能力自己继续探索下去了。如果你想进一步了解,我推荐 Sebastian Raschka 的文章 “Understanding Multimodal LMMs”https://mng.bz/Jw0a)。

小结

  • 基础版的 RAG 实现常常会返回相关性较低的文档(语义上相似,但上下文上不对),或者无法跨多个 chunk 完成多跳推理。各种优化技术正是为了解决这些缺口。

  • RAG 的优化技术包括:高级文档分块策略、多向量索引(每个文档多个 embedding)、查询改写,以及把 dense retrieval 和 sparse retrieval 结合起来的混合搜索。

  • 高级索引可以通过以下方式提升检索效果:更细致的分块(按语义边界切,而不是固定字符数)、元数据过滤(日期范围、文档类型)、以及父子关系(小 chunk 负责搜索,大 chunk 负责上下文)。

  • 文档切分策略取决于内容结构:

    • 按大小切分 —— 例如按字符数(500–1,000 字符)、按句子数(2–5 句)、或按段落。适用于内容没有明确结构的场景。
    • 按结构切分 —— 例如按 Markdown 标题、书籍章节、技术文档中的 section 来切分。适用于文档本身具有清晰层级组织的场景。
  • HTML 和 Markdown 文档如果按其原生结构(如 h1/h2 标签、section 边界)来切分,往往能更好地保持语义连贯性,相比任意字符长度切分更有助于保留上下文。

  • 父子索引(parent-child indexing) 会把小 chunk 存起来用于精确搜索,但最终返回的却是这些小 chunk 所属的较大父块作为最终上下文。例如,一个 200 字符的 child chunk 会指向它所属的 2,000 字符 parent document。

  • ParentDocumentRetriever 会对 child chunk 做 embedding,同时保存它们与 parent document 的引用。你可以分别配置 child splitter(控制搜索粒度)和 parent splitter(控制上下文大小)。

  • 多向量检索在不同内容类型和查询模式下的表现会有所不同。你应该在自己的数据集上,把 ParentDocumentRetriever 与标准向量搜索做实际对比,验证它是否真的带来改进。

  • 上下文扩展(context expansion) 会在检索时一并取回匹配 chunk 前后相邻的 chunk。例如,某个查询命中了第 5 个 chunk,系统就会连带取回第 4 和第 6 个 chunk,以增强连续性。

  • 对于半结构化文档(表格、图表、表单),通常需要借助像 Unstructured.io 这样的提取库,或自定义解析器。你应当把表格摘要或提取出的结构化数据,和正文文本分开做 embedding。

  • MarkdownHeaderTextSplitter 会在切分时保留文档层级结构,并把父级标题写入每个 chunk 的 metadata。这使层级过滤和上下文理解更容易实现。

  • 你可以通过 ParentDocumentRetriever 构建一个 parent-child retriever,其中 child_splitter 用于小 chunk,parent_splitter 用于大 chunk,并把二者分别存入不同的向量存储。

  • 父子检索会带来额外的存储开销(child 和 parent 都要存)以及更高复杂度。因此在上线前,一定要把质量提升与额外存储和计算成本做充分对比评估。

  • SemanticChunker 可以利用 embedding 相似度来检测自然断点:
    from langchain_experimental.text_splitter import SemanticChunker
    但它会在摄取阶段增加额外的 embedding API 调用。

  • 不同向量存储对 metadata filtering 的支持程度并不相同。以 ChromaDB 为例,它支持 where 过滤,但不同平台的具体实现方式会不同,因此你要查阅自己所用向量存储的官方文档。

  • 上下文窗口检索(context window retrieval) 会在每个命中结果的前后额外取回 N 个 chunk。实现方式通常是:先取回匹配结果,再基于文档 ID 和位置元数据,把相邻 chunk 一起取出来。