如何提升RAG系统的准确性

253 阅读12分钟

本文讨论了代理分块作为一种先进的技术,通过创建更具上下文连贯性的文本块来提高检索增强生成 (RAG) 系统的性能。

文章“如何在 RAG 中实现接近人类水平的分块性能”深入探讨了有效的分块方法对于 RAG 应用的重要性。它批评了传统的递归字符分割和语义分割,强调了它们在捕捉主题连贯性方面的局限性。作者介绍了代理分块,它使用语言模型根据主题相关性评估句子并将其分组为块,即使它们在文本中的位置很远。事实证明,这种方法对于嵌入文本或事先不知道上下文的内容非常有效。本文提供了详细的实施指南,包括用于提出文本和使用 LLM 代理创建块的代码片段,强调了对结构化输出和动态更新块摘要和标题的需求。虽然承认代理分块的成本和延迟,但作者认为,该方法能够生成高质量、上下文丰富的块,这证明了它在精度至关重要的场景中的使用是合理的。

  • 作者认为良好的分块对于 RAG 系统的性能至关重要,影响输出质量和速度。
  • 递归字符拆分很受欢迎,但不足以维持块内的主题一致性。
  • 语义分割因其捕捉主题变化的能力而受到赞赏,但也因未解决具有相似含义的句子被分离的问题而受到批评。
  • 代理分块因其对句子的主动评估而受到青睐,使其能够有效地对相关内容进行分组,而不管它们在文档中的位置如何。
  • 作者强调了命题句子的重要性,以确保它们是自足的,从而使施事分块有效地发挥作用。
  • 提倡使用 LLM 代理进行分块,因为它能够动态创建和更新块,确保相关性和准确性。
  • 作者承认代理分块的成本较高且处理时间较慢,但坚持认为对于某些应用而言,其优点大于缺点。
  • 文章指出,主体组块对于记录这类内容特别有用,因为事先无法得知上下文,而传统方法则不够完善。

分好快就可以造就好的RAG。

分块、嵌入和索引是 RAG 的关键方面。使用适当分块技术的 RAG 应用在输出质量和速度方面表现良好。

在设计 LLM 管道时,我们使用不同的策略来拆分文本。递归字符拆分是最流行的技术。它使用具有固定标记长度的滑动窗口方法。但是,这种方法不能保证它能够在其窗口大小内充分容纳主题。此外,部分上下文可能会落入不同的块中。

我喜欢的另一种技术是语义分割。语义分割会在两个连续句子之间发生重大变化时将文本拆分。它没有长度限制。因此,它可以包含很多句子,也可以包含很少的句子。但它更有可能更准确地捕捉不同的主题。

即使是语义分割方法也存在问题。

如果相距较远的句子含义却更接近会怎样?

语义分割无法解决这个问题。如果有人谈论政治,突然谈论气候变化,然后又回来谈论政治,语义分割可能会产生三个块。如果你或我手动将其分块,我们只会创建两个——一个关于政治,另一个关于气候变化。

如果您想了解我们如何在 Python 中实现语义分割,这里有我最近写的一篇关于该主题的文章。

我们大多数的自发性思维都是这样的。我们转向不同的方向,然后又回到主题上。因此,对此类文档进行分块不可避免地需要更聪明的方法。

如果您尝试嵌入人们谈话、播客的记录,或者任何您认为事先不知道内容以做出有根据的假设的情况,递归字符拆分和语义拆分都无济于事。

这就是为什么我们需要一种代理方法来进行组块。代理可以像我们一样行动,并更积极地创建组块。

Agentic Chunking 如何工作?

在 Agentic Chunking 中,LLM 处理文章中的每个句子,并将其分配给具有相似句子的块,如果没有块匹配,则创建一个块。

我们使用代理分块来解决递归字符和语义分割的局限性。它之所以有效,是因为它不依赖于固定的标记长度或语义含义的变化,而是主动评估每个句子并将其分配给一个块。因此,代理分块可以将文档中的两个相关句子分组,即使它们彼此相距很远。

但要做到这一点,两个句子必须是独立的、完整的句子。请考虑以下示例。

1969 年 7 月 20 日,宇航员尼尔·阿姆斯特朗踏上月球。他当时正领导美国宇航局的阿波罗 11 号任务。阿姆斯特朗踏上月球表面时说了一句名言:“这是个人的一小步,却是人类的一大步。”

在代理分块中,传递给代理的句子没有上下文信息。在上面的段落中,第二句“他领导着 NASA 的阿波罗 11 号任务”没有引用其前一句。因此,LLM 无法弄清楚这句话中的“他”是谁。因此,这段话应该转换为以下内容。

1969年7月20日,宇航员尼尔·阿姆斯特朗踏上月球。

尼尔·阿姆斯特朗 (Neil Armstrong) 领导着美国宇航局的阿波罗 11 号任务。

尼尔·阿姆斯特朗踏上月球表面时说过一句名言:“这是个人的一小步,却是人类的一大步。”

这一过程通常被称为propositioning

现在,LLM 可以单独检查每个句子,并将其分配给一个词组,或者如果它不相关,则创建一个词组。这是可能的,因为每个句子都有一个主语。

实现 Agentic 分块

现在,我们对代理组块的工作原理有了大致的了解。我们还知道,句子需要被提出才能发挥作用。

然而,有很多不同的方法可以实现这一点;没有一个单独的软件包可以为我们做到这一点。

我经常使用Greg Kamradt 的 GitHub repo。 但 Greg 的代码只是实现代理拆分的百万种方法之一。如果您有兴趣了解有关分块技术的更多信息,Greg 创建了一个出色的教程。我建议您在此处查看。

预处理文本(propositioning)

既然我们现在了解了propositioning,我们可以创建自己的提示,让 LLM 为我们完成这件事。幸运的是,Langchain 中心有一个出色的提示。

我们来拉取提示模板,创建一个 LLM 链,并进行测试。

obj = hub.pull("wfh/proposal-indexing")

# You can explore the prompt template behind this by running the following:
# obj.get_prompts()[0].messages[0].prompt.template

llm = ChatOpenAI(model="gpt-4o")


# A Pydantic model to extract sentences from the passage
class Sentences(BaseModel):
    sentences: List[str]

extraction_llm = llm.with_structured_output(Sentences)


# Create the sentence extraction chain
extraction_chain = obj | extraction_llm


# Test it out
sentences = extraction_chain.invoke(
    """
    On July 20, 1969, astronaut Neil Armstrong walked on the moon . 
    He was leading the NASA's Apollo 11 mission. 
    Armstrong famously said, "That's one small step for man, one giant leap for mankind" as he stepped onto the lunar surface.
    """
)



>>['On July 20, 1969, astronaut Neil Armstrong walked on the moon.',
 "Neil Armstrong was leading NASA's Apollo 11 mission.",
 'Neil Armstrong famously said, "That's one small step for man, one giant leap for mankind" as he stepped onto the lunar surface.']

上述代码使用 Pydantic 模型来提取句子。这是从文本中提取结构化输出的推荐方法。

但在长篇文本中,我们无法非常有效地做到这一点。一个句子中的“他”可能指的是尼尔·阿姆斯特朗,但在另一个段落中,“他”可能指的是亚历山大·格雷厄姆·贝尔。这取决于文件的内容。

因此,一个好的想法是将文本分成段落,并在每个段落内进行陈述。

paragraphs = text.split("\n\n")

propositions = []

for i, p in enumerate(paragraphs):
    propositions = extraction_chain.invoke(p
    
    propositions.extend(propositions)

上述代码片段将在每个段落的上下文中创建一个命题列表。

使用 LLM 代理创建块。

现在,通过比例计算,我们有了单独的句子;它们不言自明。该文件已准备好供代理处理。

代理在这里做了一些事情。

代理从一个名为chunks的空字典开始,它在其中存储它创建的所有块。每个块包含具有相似主题的命题。代理的目标是将这些命题分组为以下格式的块:

{
   "12345": {
       "chunk_id": "12345",
       "propositions": [
           "The month is October.",
           "The year is 2023."
       ],
       "title": "Date & Time",
       "summary": "This chunk contains information about dates and times, including the current month and year.",
   },
   "67890": {
       "chunk_id": "67890",
       "propositions": [
           "One of the most important things that I didn't understand about the world as a child was the degree to which the returns for performance are superlinear.",
           "Teachers and coaches implicitly told us that the returns were linear.",
           "I heard a thousand times that 'You get out what you put in.'"
       ],
       "title": "Performance Returns",
       "summary": "This chunk contains information about performance returns and how they are perceived differently from reality.",
   }
}

当遇到新命题时,代理要么将其添加到现有块中,要么在未找到合适块的情况下创建新块。现有块是否匹配的决定取决于传入的命题和块的当前摘要。

此外,如果将新命题添加到块中,代理可以更新块的摘要和标题以反映新信息。这可确保元数据在块演变过程中保持相关性。

让我们一步一步地对它们进行编码。

步骤 I:创建块的函数

当我们第一次开始时,没有任何块。因此,我们必须创建一个块来存储我们的第一个命题。不仅如此。每当代理决定某个命题需要一个新的块时,我们都需要一个函数来创建块。因此,让我们定义一个函数来执行此操作。

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(temperature=0)

chunks = {}

def create_new_chunk(chunk_id, proposition):
    summary_llm = llm.with_structured_output(ChunkMeta)

    summary_prompt_template = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                "Generate a new summary and a title based on the propositions.",
            ),
            (
                "user",
                "propositions:{propositions}",
            ),
        ]
    )

    summary_chain = summary_prompt_template | summary_llm

    chunk_meta = summary_chain.invoke(
        {
            "propositions": [proposition],
        }
    )

    chunks[chunk_id] = {
        "summary": chunk_meta.summary,
        "title": chunk_meta.title,
        "propositions": [proposition],
    }

我们将块存储在函数外部,因为它会被该函数和其他函数多次更新。

在上面的代码中,我们使用 LLM 为我们的块生成标题和摘要。这是我们列表中第一个命题的摘要。

第二步:向词块添加命题的函数

当我们浏览文档时,每个后续块都需要添加到一个块中。当我们添加一个块时,标题和摘要可能准确反映其内容,也可能不准确。因此,我们会重新评估它们,并在必要时重写它们。

from langchain_core.pydantic_v1 import BaseModel, Field

class ChunkMeta(BaseModel):
    title: str = Field(description="The title of the chunk.")
    summary: str = Field(description="The summary of the chunk.")

def add_proposition(chunk_id, proposition):
    summary_llm = llm.with_structured_output(ChunkMeta)

    summary_prompt_template = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                "If the current_summary and title is still valid for the propositions return them."
                "If not generate a new summary and a title based on the propositions.",
            ),
            (
                "user",
                "current_summary:{current_summary}\n\ncurrent_title:{current_title}\n\npropositions:{propositions}",
            ),
        ]
    )

    summary_chain = summary_prompt_template | summary_llm

    chunk = chunks[chunk_id]

    current_summary = chunk["summary"]
    current_title = chunk["title"]
    current_propositions = chunk["propositions"]

    all_propositions = current_propositions + [proposition]

    chunk_meta = summary_chain.invoke(
        {
            "current_summary": current_summary,
            "current_title": current_title,
            "propositions": all_propositions,
        }
    )

    chunk["summary"] = chunk_meta.summary
    chunk["title"] = chunk_meta.title
    chunk["propositions"] = all_propositions

上述函数将把命题添加到现有块中。同样,我们使用另一个 LLM 调用来决定是否更改标题和摘要。为了简化操作,我们为 LLM 配置了一个繁琐的模型,这样输出现在就是一个结构化对象,而不是随机文本。

步骤 III:将命题推送到相关块的代理

虽然我们上面定义的两个函数可以完成工作,但它们不知道现有块是否足以容纳传入的命题。如果有,我们必须使用add_proposition相应的 chunk_id 和命题调用该函数。如果没有,我们需要调用该create_new_chunk函数来创建一个。

以下函数可以实现该功能。

def find_chunk_and_push_proposition(proposition):

 class ChunkID(BaseModel):
     chunk_id: int = Field(description="The chunk id.")

 allocation_llm = llm.with_structured_output(ChunkID)

 allocation_prompt = ChatPromptTemplate.from_messages(
     [
         (
             "system",
             "You have the chunk ids and the summaries"
             "Find the chunk that best matches the proposition."
             "If no chunk matches, return a new chunk id."
             "Return only the chunk id.",
         ),
         (
             "user",
             "proposition:{proposition}" "chunks_summaries:{chunks_summaries}",
         ),
     ]
 )

 allocation_chain = allocation_prompt | allocation_llm

 chunks_summaries = {
     chunk_id: chunk["summary"] for chunk_id, chunk in chunks.items()
 }

 best_chunk_id = allocation_chain.invoke(
     {"proposition": proposition, "chunks_summaries": chunks_summaries}
 ).chunk_id

 if best_chunk_id not in chunks:
     best_chunk_id = create_new_chunk(best_chunk_id, proposition)
     return

 add_proposition(best_chunk_id, proposition)

上述函数使用 LLM 来决定是否创建新的块或将命题推送到现有的块。它还调用相应的函数来执行此操作。

这是简化的代码和对代理分块的解释。您需要在此过程中做出许多设计决策。例如,您可以进行相似性搜索,而不是让 LLM 比较摘要和传入的命题。

最后

代理分块无疑是一种强大的文档分块技术。与语义分块一样,它也能创建有意义的块。但它更进一步,即使句子相距很远,也能为同一个主题添加句子。

主要考虑因素是它进行的 LLM 调用次数——每次调用都会花费金钱并增加流程的延迟。众所周知,Agentic 分块是一种缓慢且昂贵的分块策略。因此,您必须注意大型项目的预算。