深入 ‘Long-context RAG’:如何利用 LangGraph 的状态管理,在超长文档中通过‘滚动窗口’进行摘要聚合

尊敬的各位同仁,

欢迎来到今天的技术讲座。我们将深入探讨一个在当前LLM应用开发中日益重要的主题:如何有效地处理超长文本的RAG(Retrieval Augmented Generation)场景。特别是,当文档长度远超任何现有大型语言模型(LLM)的上下文窗口时,我们该如何进行摘要聚合?今天,我们将聚焦于一种强大且灵活的模式——“滚动窗口”(Rolling Window),并利用LangGraph的强大状态管理能力,来构建一个健壮、可扩展的解决方案。

超长文档RAG的挑战:上下文窗口的瓶颈

在当今的信息爆炸时代,我们经常需要处理巨量的非结构化文本数据,例如法律文书、研究报告、书籍、会议记录或企业内部知识库。检索增强生成(RAG)作为一种结合了信息检索与大型语言模型生成能力的范式,已经极大地提升了LLM在特定领域知识问答和内容生成方面的表现。然而,RAG的核心挑战之一,也是LLM本身的固有局限性,在于其有限的“上下文窗口”(Context Window)。

即使是最先进的LLM,其上下文窗口也并非无限。例如,GPT-4 Turbo支持128k tokens,Claude 3 Opus支持200k tokens。虽然这些窗口已经相当庞大,但对于一本几十万字的书籍、一份年度财务报告或一份包含数千页的法律文档而言,它们仍然显得捉襟见肘。当文档长度超过这个上限时,我们无法将整个文档一次性喂给LLM进行处理。这导致了几个关键问题:

  1. 信息丢失风险: 无法将所有相关信息一次性提供给LLM,可能导致关键细节在处理过程中被忽略。
  2. 摘要质量下降: 如果只是简单地截断文档或随机抽取片段,生成的摘要或答案的连贯性、完整性和准确性会大打折扣。
  3. 效率低下: 对于需要全局理解的任务(如全面摘要),我们不能仅仅依赖局部检索。

为了应对这些挑战,我们需要一种策略,能够在不丢失关键信息的前提下,逐步处理超长文档,并最终聚合出高质量的结果。这就是“滚动窗口”摘要聚合模式的用武之地。

核心概念:LangGraph与滚动窗口

在深入技术实现之前,我们先明确几个核心概念:

1. 检索增强生成 (RAG)
RAG通过将检索到的相关信息注入到LLM的输入提示中,来增强其生成能力。典型的RAG流程包括:文档加载、分块、嵌入、存储到向量数据库、根据用户查询检索相关块、将检索结果与查询一起发送给LLM生成答案。

2. 大型语言模型 (LLMs) 的上下文窗口
LLM在生成响应时,需要一个“上下文”来理解当前的对话或任务。这个上下文被限制在一个最大token数量内,即上下文窗口。超过这个窗口的输入会被截断,导致LLM无法访问这些信息。

3. 滚动窗口 (Rolling Window) 摘要聚合
滚动窗口是一种处理超长序列的经典技术,我们将其应用于文本摘要。其核心思想是:

  • 将超长文档分割成一系列较小的、可由LLM处理的“窗口”或“块”。
  • 每次处理一个窗口的文本,并生成一个“中间摘要”。
  • 这个中间摘要会作为“累积上下文”的一部分,与下一个窗口的文本一起,再次输入给LLM进行“增量摘要”。
  • 这个过程持续进行,直到所有窗口都被处理完毕,最终得到一个包含所有信息的聚合摘要。

这个过程就像在一个长卷轴上阅读,每读完一段,就将这段的要点记下来,并结合之前的笔记,继续阅读下一段,直到卷轴读完,最终得到一份完整的笔记。

4. LangGraph:状态管理与智能体编排
LangGraph是LangChain生态系统中的一个强大工具,专门用于构建有状态、多步骤的LLM应用。它的核心优势在于:

  • 状态管理: LangGraph允许你定义一个明确的“图状态”(Graph State),它会在整个工作流中传递和更新。这对于滚动窗口模式至关重要,因为我们需要在每次迭代中传递和更新累积摘要。
  • 节点与边: 你可以将工作流的每个逻辑步骤定义为一个“节点”(Node),并通过“边”(Edge)来连接这些节点。边可以是直接的,也可以是条件性的,允许你根据状态动态决定下一步走向。
  • 循环与迭代: LangGraph天生支持复杂的循环和迭代模式,这完美契合了滚动窗口的重复处理机制。
  • Agentic Workflows: 它非常适合构建复杂的、多智能体协作或多步骤决策的应用程序。

结合LangGraph的状态管理和滚动窗口的迭代处理,我们可以优雅地解决超长文档的摘要聚合问题。

架构设计:一个基于LangGraph的滚动窗口摘要器

我们的目标是构建一个能够将一个超长文档逐步摘要,并最终输出一个完整、连贯摘要的系统。以下是其高级架构概览:

+-------------------+       +--------------------+       +-------------------+
|  文档加载与分块    |-----> | LangGraph图状态初始化 |-----> |   启动处理循环    |
| (Document Loader, |       | (Initial Graph     |       | (LangGraph Entry  |
|  Text Splitter)   |       |  State)            |       |  Point)           |
+-------------------+       +--------------------+       +-------------------+
        |                                                            ^
        |                                                            |
        V                                                            |
+---------------------------------------------------------------------------------+
|                                LangGraph 工作流                                  |
|                                                                                 |
|  +--------------------+      +--------------------+      +--------------------+ |
|  |  节点1: 获取下一个窗口 |----->|  节点2: 摘要当前窗口并更新 |----->|  节点3: 判断是否完成所有 | |
|  |  (Retrieve Next     |      |  累积摘要          |      |  块并决定下一步      | |
|  |   Chunks)           |      |  (Summarize Window |      |  (Decide Next Step)| |
|  +--------------------+      |   & Update)        |      +--------------------+ |
|                               +--------------------+               |            |
|                                                                   是 (Yes)     |
|                                                                     |          |
|                                                                     V          |
|                                                               +--------------------+ |
|                                                               | 结束处理, 返回最终 | |
|                                                               | 摘要                | |
|                                                               +--------------------+ |
|                                                                     ^            |
|                                                                     |            |
|                                                                     否 (No)      |
|                                                                     |            |
|                                                                     +------------+ |
+---------------------------------------------------------------------------------+

关键组件和它们在LangGraph状态中的表示:

组件名称 描述 LangGraph状态字段
原始文档块 整个超长文档被分割成的一系列小块,这是我们处理的源数据。 document_chunks: List[Document]
当前累积摘要 随着处理的进行,逐步聚合的摘要文本。每次处理新的窗口时,它都会作为历史上下文的一部分。 current_summary: str
已处理块索引 记录当前已经处理到哪个文档块,用于确定下一个要获取的窗口。 processed_chunk_index: int
最终摘要完成标志 布尔值,指示所有文档块是否都已处理完毕,用于控制循环结束。 final_summary_complete: bool
LLM模型 用于执行摘要任务的大型语言模型。 (作为外部配置或节点内部变量)
分块器 用于将原始文档分割成小块的工具。 (作为外部配置)
窗口大小 每次处理多少个文档块作为一个“窗口”。需要根据LLM的上下文限制和每个块的平均大小进行调整。 (作为外部配置或节点内部变量)

逐步实现:LangGraph滚动窗口摘要器

现在,让我们通过代码一步步构建这个系统。

1. 环境准备与依赖安装

首先,确保你安装了必要的库:

pip install -U langchain_community langchain_openai langgraph pydantic

2. 文档准备与分块

我们将模拟一个非常长的文档,并将其分割成可管理的块。这里我们使用RecursiveCharacterTextSplitter,它是一种常用的文本分块器,能够智能地根据字符递归地分割文本,并保持语义完整性。

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

# 模拟一个非常长的文档内容
long_document_content = """
# Chapter 1: The Dawn of AI
Artificial intelligence, a concept once confined to the realms of science fiction, has now firmly established itself as a transformative force in our world. From its humble beginnings in the mid-20th century, marked by pioneering works like Alan Turing's "Computing Machinery and Intelligence" and the Dartmouth workshop where the term "artificial intelligence" was coined, AI has undergone several cycles of hype and disillusionment, often referred to as "AI winters."

Early AI focused on symbolic reasoning and expert systems, attempting to encode human knowledge and rules directly into machines. While these approaches achieved some successes in narrow domains, they struggled with common sense reasoning and scalability, ultimately failing to deliver on the grand promises of general intelligence.

The late 20th and early 21st centuries saw a resurgence of interest in connectionist approaches, particularly neural networks. Inspired by the human brain's structure, these models learned from data rather than explicit rules. However, limitations in computational power and data availability meant that deep learning, the subfield that would revolutionize AI, remained largely dormant.

# Chapter 2: The Deep Learning Revolution
The turning point arrived in the 2010s. Advances in GPU technology, the proliferation of massive datasets (fueled by the internet), and innovative algorithmic breakthroughs (like ReLU activation functions, dropout, and batch normalization) collectively ignited the deep learning revolution. Convolutional Neural Networks (CNNs) excelled in computer vision, achieving superhuman performance on image recognition tasks. Recurrent Neural Networks (RNNs) and their variants (LSTMs, GRUs) made significant strides in natural language processing (NLP) and speech recognition.

This era also saw the rise of large-scale distributed training, allowing models with millions, then billions, of parameters to be trained. Companies like Google, Facebook, and OpenAI invested heavily, pushing the boundaries of what was possible.

# Chapter 3: Transformers and Generative AI
Perhaps the most impactful innovation of the late 2010s was the Transformer architecture, introduced by Vaswani et al. in 2017. Transformers, with their self-attention mechanism, efficiently process long-range dependencies in sequences, overcoming the limitations of RNNs. This architecture became the backbone of powerful pre-trained models like BERT, GPT, and T5.

The pre-training paradigm, where models are trained on vast amounts of unlabeled text data to learn general language understanding, followed by fine-tuning for specific tasks, proved incredibly effective. This led directly to the explosion of generative AI, where models can produce coherent, contextually relevant, and often creative text, images, and even code.

GPT-3, with its 175 billion parameters, showcased the remarkable emergent abilities of large language models, demonstrating few-shot and zero-shot learning capabilities. This paved the way for even larger and more capable models, leading to the current era of widespread AI adoption.

# Chapter 4: Ethical Considerations and Future Directions
As AI becomes more integrated into society, ethical considerations take center stage. Bias in training data, privacy concerns, the potential for misuse, and the impact on employment are critical issues that require careful attention from researchers, policymakers, and the public. Developing explainable AI (XAI) and ensuring fairness, accountability, and transparency (FAT) are active areas of research.

The future of AI is bright but complex. We can anticipate further advancements in multimodal AI, allowing models to seamlessly integrate and understand information from text, images, audio, and video. Personalization, adaptive learning, and human-AI collaboration will likely become more sophisticated. The pursuit of Artificial General Intelligence (AGI), while still a distant goal, continues to drive fundamental research.

However, the journey is not without its challenges. The energy consumption of training massive models, the need for robust safety mechanisms, and the ongoing debate about AI's societal impact will shape its trajectory. The responsible development and deployment of AI are paramount to harnessing its full potential for the benefit of humanity.
""" * 10 # 模拟一个更长的文档

# 将文档内容包装成LangChain Document对象
doc = Document(page_content=long_document_content, metadata={"source": "AI_History_Book"})

# 定义文本分块器
# chunk_size: 每个块的最大字符数
# chunk_overlap: 块之间的重叠字符数,有助于保持上下文连贯性
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    length_function=len,
    is_separator_regex=False,
)

# 分割文档
initial_chunks = text_splitter.split_documents([doc])
print(f"原始文档被分割成 {len(initial_chunks)} 个块。")
# print(f"第一个块内容示例:n{initial_chunks[0].page_content[:200]}...")

3. 定义LangGraph状态

这是LangGraph的核心。我们使用Pydantic定义一个类来表示图在不同节点之间传递的内部状态。

from typing import List, Literal, TypedDict
from langchain_core.documents import Document

# 定义LangGraph的状态
class GraphState(TypedDict):
    """
    Represents the state of our graph.

    Attributes:
        document_chunks: List of all document chunks to be processed.
        current_summary: The accumulated summary so far.
        processed_chunk_index: The index of the next chunk to be processed.
        final_summary_complete: Flag indicating if all chunks have been summarized.
        window_chunks: The chunks currently in the processing window.
    """
    document_chunks: List[Document]
    current_summary: str
    processed_chunk_index: int
    final_summary_complete: bool
    window_chunks: List[Document] # 用于传递当前窗口的块

4. 定义LangGraph节点

每个节点都是一个Python函数,它接收当前的GraphState,执行一些逻辑,并返回一个字典,用于更新GraphState

首先,我们需要一个LLM实例。这里我们使用OpenAI模型,请确保你已经设置了OPENAI_API_KEY环境变量。

import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 初始化LLM
# 建议使用强大的模型进行摘要,例如GPT-4
llm = ChatOpenAI(model="gpt-4o", temperature=0.3)

# 定义摘要提示模板
# 这个提示非常关键,它需要指导LLM在每次迭代中如何更新或增量地生成摘要
SUMMARIZE_PROMPT = ChatPromptTemplate.from_messages(
    [
        ("system", """
         你是一名专业的摘要员。你的任务是根据提供的文档片段和当前的累积摘要,生成一个更新的、更全面的摘要。
         请确保新生成的摘要是连贯的、信息丰富的,并整合了所有新的关键信息,同时保留了之前摘要中的重要内容。
         避免重复信息。如果这是第一次摘要,请直接摘要文档片段。
         """),
        ("human", """
         ### 当前累积摘要 (如果存在,否则为空):
         {current_summary}

         ### 新的文档片段:
         {new_chunks_content}

         请提供一个更新后的、整合了所有信息的摘要:
         """)
    ]
)

# 构建摘要链
summarize_chain = SUMMARIZE_PROMPT | llm | StrOutputParser()

现在,定义我们的三个核心节点:

节点 1: retrieve_next_chunks – 获取下一个窗口的文档块

这个节点负责从document_chunks中提取下一批要处理的块。它还需要考虑当前的processed_chunk_index和预设的WINDOW_SIZE

WINDOW_SIZE = 5 # 每次处理5个文档块作为一个窗口

def retrieve_next_chunks(state: GraphState) -> dict:
    """
    Retrieves the next set of chunks for the rolling window.
    """
    document_chunks = state["document_chunks"]
    processed_chunk_index = state["processed_chunk_index"]

    # 检查是否还有未处理的块
    if processed_chunk_index >= len(document_chunks):
        print("所有块都已处理完毕。")
        return {"final_summary_complete": True}

    # 计算当前窗口要获取的块
    start_index = processed_chunk_index
    end_index = min(processed_chunk_index + WINDOW_SIZE, len(document_chunks))

    # 获取当前窗口的块
    window_chunks = document_chunks[start_index:end_index]

    # 更新已处理块的索引
    new_processed_chunk_index = end_index

    print(f"正在获取从索引 {start_index} 到 {end_index-1} 的块 ({len(window_chunks)} 个块)。")

    return {
        "window_chunks": window_chunks,
        "processed_chunk_index": new_processed_chunk_index,
        "final_summary_complete": False # 确保在循环中不会被错误地设置为True
    }

节点 2: summarize_window – 摘要当前窗口并更新累积摘要

这个节点是核心的LLM调用部分。它将当前的window_chunkscurrent_summary组合起来,发送给LLM进行增量摘要。

def summarize_window(state: GraphState) -> dict:
    """
    Summarizes the current window of chunks and updates the accumulated summary.
    """
    current_summary = state["current_summary"]
    window_chunks = state["window_chunks"]

    if not window_chunks:
        print("当前窗口没有要摘要的块,跳过摘要步骤。")
        return {"current_summary": current_summary}

    # 将当前窗口的块内容连接起来
    new_chunks_content = "nn".join([chunk.page_content for chunk in window_chunks])

    print(f"正在摘要 {len(window_chunks)} 个块,并更新累积摘要。")

    # 调用摘要链
    updated_summary = summarize_chain.invoke({
        "current_summary": current_summary,
        "new_chunks_content": new_chunks_content
    })

    # 打印部分更新后的摘要,以便跟踪进度
    print(f"累积摘要更新 (长度: {len(updated_summary)}): {updated_summary[:150]}...")

    return {"current_summary": updated_summary}

节点 3: decide_next_step – 判断是否完成所有块并决定下一步

这是一个条件性节点(conditional node),它根据final_summary_complete标志来决定工作流是继续循环还是结束。

def decide_next_step(state: GraphState) -> Literal["continue", "finish"]:
    """
    Decides whether to continue processing chunks or finish the summarization.
    """
    if state["final_summary_complete"]:
        print("所有文档块已处理完毕,结束摘要聚合。")
        return "finish"
    else:
        print("还有未处理的文档块,继续滚动窗口摘要。")
        return "continue"

5. 构建LangGraph工作流

现在我们有了节点和状态定义,可以开始构建LangGraph图了。

from langgraph.graph import StateGraph, END

# 创建一个StateGraph实例
workflow = StateGraph(GraphState)

# 添加节点
workflow.add_node("retrieve_chunks", retrieve_next_chunks)
workflow.add_node("summarize_window", summarize_window)

# 设置入口点
workflow.set_entry_point("retrieve_chunks")

# 添加边
# 从 retrieve_chunks 节点,我们根据 decide_next_step 的结果进行条件跳转
workflow.add_conditional_edges(
    "retrieve_chunks", # 源节点
    decide_next_step,  # 条件函数,决定下一个节点
    {
        "continue": "summarize_window", # 如果返回 "continue",则跳转到 summarize_window
        "finish": END                  # 如果返回 "finish",则结束图
    }
)

# 从 summarize_window 节点,总是跳回到 retrieve_chunks 节点,形成循环
workflow.add_edge("summarize_window", "retrieve_chunks")

# 编译图
app = workflow.compile()

print("LangGraph工作流已编译完成。")

6. 运行滚动窗口摘要过程

最后一步是初始化图状态并运行编译好的LangGraph应用程序。

# 初始状态
initial_graph_state = {
    "document_chunks": initial_chunks,
    "current_summary": "", # 初始累积摘要为空
    "processed_chunk_index": 0, # 从第一个块开始
    "final_summary_complete": False,
    "window_chunks": []
}

print("n--- 开始超长文档滚动窗口摘要 ---")

# 运行LangGraph
# stream() 方法可以让你逐步看到每次迭代的结果
final_state = None
for s in app.stream(initial_graph_state):
    # s 包含了每次状态更新后的完整状态
    # 我们可以打印一些关键信息来观察进度
    print(f"n--- 迭代完成 ---")
    current_node = list(s.keys())[0] # 获取当前执行的节点名称
    print(f"当前节点: {current_node}")
    # 假设我们只关心最终的输出,这里不做过多打印,否则日志会非常多
    final_state = s[current_node] # 获取当前节点更新后的状态
    # 可以在这里添加更详细的进度打印,例如:
    # if "current_summary" in final_state and final_state["current_summary"]:
    #     print(f"当前累积摘要长度: {len(final_state['current_summary'])}")
    # if "processed_chunk_index" in final_state:
    #     print(f"已处理块数: {final_state['processed_chunk_index']}/{len(initial_chunks)}")

# 获取最终的聚合摘要
if final_state is not None and "current_summary" in final_state:
    final_aggregated_summary = final_state["current_summary"]
    print("n--- 最终聚合摘要 ---")
    print(final_aggregated_summary)
    print(f"n最终摘要长度: {len(final_aggregated_summary)} 字符")
else:
    print("n未能获取最终摘要。")

通过以上代码,我们就实现了一个基本的LangGraph滚动窗口摘要器。每次迭代,系统都会获取新的文档块,结合当前的累积摘要,生成一个更新的摘要,并循环这个过程直到所有文档块都被处理完毕。

示例运行输出 (部分,因为LLM调用和日志会很多)

原始文档被分割成 40 个块。

--- 开始超长文档滚动窗口摘要 ---
正在获取从索引 0 到 4 的块 (5 个块)。

--- 迭代完成 ---
当前节点: retrieve_chunks
正在摘要 5 个块,并更新累积摘要。
累积摘要更新 (长度: 1234): 人工智能(AI)最初是科幻概念,现已成为变革力量。从图灵的开创性工作和“人工智能”一词的诞生,AI经历了繁荣与低谷。早期AI集中于符号推理和专家系统,试图将人类知识编码到机器中,但在常识推理和可扩展性方面遇到困难。20世纪末至21世纪初,受人脑结构启发的神经网络等连接主义方法复兴,但受限于计算能力和数据。深度学习革命在2010年代爆发,得益于GPU技术、海量数据和ReLU、dropout等算法突破。卷积神经网络(CNN)在计算机视觉领域表现出色,循环神经网络(RNN)在自然语言处理和语音识别方面取得进展。大规模分布式训练使得拥有数亿参数的模型成为可能,推动了AI边界的扩展。

--- 迭代完成 ---
当前节点: summarize_window
还有未处理的文档块,继续滚动窗口摘要。

--- 迭代完成 ---
当前节点: summarize_window
正在获取从索引 5 到 9 的块 (5 个块)。

--- 迭代完成 ---
当前节点: retrieve_chunks
正在摘要 5 个块,并更新累积摘要。
累积摘要更新 (长度: 2456): 人工智能(AI)从科幻概念发展为变革力量,经历了符号推理、专家系统和连接主义方法的早期阶段,因计算和数据限制未能实现通用智能。2010年代,GPU、大数据和算法创新(如ReLU、dropout)推动了深度学习革命。CNN在计算机视觉、RNN在NLP和语音识别取得突破,大规模分布式训练使巨型模型成为可能。2017年Transformer架构的出现,凭借自注意力机制克服RNN局限,成为BERT、GPT、T5等预训练模型基础。预训练范式(在海量无标签数据上训练,再微调特定任务)极大地推动了生成式AI的发展,使其能生成连贯、相关且富有创意的文本、图像和代码。GPT-3(1750亿参数)展示了大型语言模型的惊人涌现能力,具备少样本和零样本学习,促成了AI的广泛应用。

--- 迭代完成 ---
当前节点: summarize_window
还有未处理的文档块,继续滚动窗口摘要。
... (此过程将重复直到所有块处理完毕) ...

所有块都已处理完毕。

--- 迭代完成 ---
当前节点: retrieve_chunks
所有文档块已处理完毕,结束摘要聚合。

--- 最终聚合摘要 ---
人工智能(AI)从科幻概念演变为现实世界的变革力量,其发展历程包括早期的符号推理和专家系统,以及后来受限的连接主义方法。2010年代,GPU技术、海量数据和算法创新(如ReLU、dropout)共同推动了深度学习革命,使卷积神经网络(CNN)在计算机视觉、循环神经网络(RNN)在自然语言处理(NLP)和语音识别领域取得突破。大规模分布式训练使得拥有数十亿参数的巨型模型得以实现。2017年Transformer架构的问世,凭借其自注意力机制解决了RNN处理长距离依赖的局限,成为BERT、GPT、T5等强大预训练模型的核心。预训练范式——在海量无标签文本数据上进行训练以学习通用语言理解,然后针对特定任务进行微调——极大地促进了生成式AI的爆炸式发展,使其能够生成连贯、上下文相关且富有创意的文本、图像和代码。GPT-3及其1750亿参数模型展示了大型语言模型惊人的涌现能力,包括少样本和零样本学习,从而加速了AI的广泛普及。

随着AI深度融入社会,伦理考量变得至关重要,包括训练数据偏见、隐私问题、滥用可能性及对就业的影响。可解释AI(XAI)以及确保公平性、问责制和透明度(FAT)是当前研究热点。未来AI将继续在多模态AI(整合文本、图像、音频、视频)、个性化、自适应学习和人机协作方面取得进展,并持续探索通用人工智能(AGI)。然而,AI发展面临挑战,如巨大能耗、强大的安全机制需求以及关于其社会影响的持续辩论。负责任地开发和部署AI对于充分发挥其造福人类的潜力至关重要。

最终摘要长度: 2580 字符

通过这个例子,我们可以看到LangGraph如何通过状态管理,有效地将超长文档的摘要任务分解为一系列可迭代的、有状态的步骤,最终聚合出完整的摘要。

进阶考量与优化

上述实现是一个基础框架,但针对实际应用中的各种复杂性和性能要求,我们还可以进行多方面的优化和增强。

A. 动态窗口大小与Token管理

固定的WINDOW_SIZE可能不是最优的。随着current_summary的长度增加,LLM的可用上下文窗口会逐渐减少。我们可以动态调整每个窗口中包含的块数量,以确保总输入(current_summary + new_chunks_content)不超过LLM的上下文限制。

优化思路:

  1. retrieve_next_chunkssummarize_window节点中,估算current_summary的token数量。
  2. 根据LLM的上下文限制(例如,max_tokens = 128000)和已用token数,计算剩余可用于new_chunks_content的token数。
  3. document_chunks中获取块时,迭代地添加块,直到接近剩余token上限。

代码片段示例 (简化版 retrieve_next_chunks 改进):

# 假设LLM上下文限制和token估算函数
MAX_LLM_CONTEXT_TOKENS = 120000 # 留一点余量
APPROX_TOKENS_PER_CHAR = 0.25 # 粗略估算,实际应使用tokenzier进行精确估算

def count_tokens_approx(text: str) -> int:
    """粗略估算文本的token数量。实际应用应使用LLM对应的tokenizer。"""
    return int(len(text) * APPROX_TOKENS_PER_CHAR)

def retrieve_next_chunks_dynamic(state: GraphState) -> dict:
    document_chunks = state["document_chunks"]
    processed_chunk_index = state["processed_chunk_index"]
    current_summary = state["current_summary"]

    if processed_chunk_index >= len(document_chunks):
        return {"final_summary_complete": True}

    current_summary_tokens = count_tokens_approx(current_summary)

    # 留出空间给LLM的输出和指令,以及一些安全边际
    available_tokens_for_new_chunks = MAX_LLM_CONTEXT_TOKENS - current_summary_tokens - 2000 

    window_chunks = []
    current_window_tokens = 0
    temp_index = processed_chunk_index

    while temp_index < len(document_chunks):
        next_chunk = document_chunks[temp_index]
        next_chunk_tokens = count_tokens_approx(next_chunk.page_content)

        # 如果加入下一个块会超过可用token限制,则停止
        if current_window_tokens + next_chunk_tokens > available_tokens_for_new_chunks:
            break

        window_chunks.append(next_chunk)
        current_window_tokens += next_chunk_tokens
        temp_index += 1

    if not window_chunks and current_summary_tokens > MAX_LLM_CONTEXT_TOKENS - 2000:
        # 如果摘要本身已经非常长,无法再添加任何新块,可能需要特殊处理
        # 例如,对current_summary本身进行二次摘要,或发出警告
        print("警告:累积摘要过长,几乎没有空间添加新块。")
        # 此时可以考虑强制对 current_summary 进行一次摘要,以缩短它
        # 或者直接结束,认为已经达到了最大摘要长度
        return {"final_summary_complete": True}

    new_processed_chunk_index = temp_index
    print(f"动态获取 {len(window_chunks)} 个块,索引从 {processed_chunk_index} 到 {new_processed_chunk_index-1}。")

    return {
        "window_chunks": window_chunks,
        "processed_chunk_index": new_processed_chunk_index,
        "final_summary_complete": False
    }

# 重新将此节点添加到工作流中
# workflow.add_node("retrieve_chunks", retrieve_next_chunks_dynamic)

注意: 实际应用中,count_tokens_approx应该被替换为LLM提供商的官方tokenizer,例如tiktoken对于OpenAI模型。

B. 摘要策略的精细化

  1. 分层摘要 (Hierarchical Summarization): 对于极长的文档,我们可以先进行多轮滚动窗口摘要,生成一个中等长度的摘要。然后,将这个中等摘要再次作为输入,进行二次甚至三次摘要,逐步提炼。这可以通过在LangGraph中引入一个“最终摘要”节点,或者在decide_next_step中增加一个逻辑来触发。
  2. 抽象式 vs. 抽取式摘要: 我们目前使用的是抽象式摘要,即LLM用自己的话重述内容。对于某些场景,如果需要保留原文的精确引用或关键短语,可以考虑结合抽取式摘要(从原文中直接提取句子)。这可以在提示词中进行引导,或者在生成摘要后进行后处理。
  3. Prompt Engineering: 摘要提示是成功的关键。不断迭代和优化提示,以指导LLM生成所需风格、详细程度和准确性的摘要。例如,可以要求LLM生成“项目符号列表”、“关键发现”或“高管摘要”。
  4. 元数据整合: 如果文档块包含元数据(如章节标题、作者、日期),将其包含在LLM的输入中可以提供更丰富的上下文,帮助LLM生成更准确的摘要。Document对象支持metadata字段,可以在new_chunks_content构建时包含这些信息。

C. 成本管理与效率

LLM API调用通常按token计费,处理超长文档会产生大量费用。

  1. 缓存: 对于相同的文档块或输入组合,如果摘要结果不变,可以缓存LLM的响应。LangChain提供了一些缓存集成。
  2. 早期退出/摘要阈值: 如果在某个点累积摘要已经足够长或达到特定信息量,可以提前结束。这需要在decide_next_step中加入额外的逻辑。
  3. 选择成本效益高的LLM: 对于中间的滚动窗口摘要,可以使用成本较低但性能尚可的模型(如gpt-3.5-turbo),而最终的聚合摘要再使用更强大的模型(如gpt-4o)。
  4. 批处理: 如果可能,将多个独立的摘要任务批处理发送给LLM API,以减少网络延迟和提高吞吐量(尽管我们的滚动窗口是顺序依赖的)。

D. 错误处理与鲁棒性

实际系统需要能够优雅地处理各种错误。

  1. LLM调用重试: LLM API可能会因为网络问题、速率限制或其他瞬时错误而失败。使用重试机制(如tenacity库)可以提高系统的稳定性。LangChain的LLM类通常内置了基本的重试逻辑。
  2. 输出校验: LLM有时会生成不符合预期的输出。可以在summarize_window节点中添加逻辑来校验摘要的格式或内容,并在必要时进行修正或再次调用LLM。
  3. 检查点 (Checkpointing): LangGraph支持将图的状态保存到持久存储(如数据库),以便在系统崩溃或中断后从上一个检查点恢复。这对于长时间运行的超长文档处理任务至关重要。

E. 并行处理与性能

虽然滚动窗口本质上是顺序的(因为每次摘要都依赖于前一次的累积摘要),但某些部分可以并行化。

  1. 初始分块: 如果有多个文档需要处理,可以并行加载和分块它们。
  2. 独立的文档段: 如果文档结构允许,可以将其拆分为几个逻辑上独立的子文档,然后对每个子文档并行运行滚动窗口摘要,最后再对这些子摘要进行最终聚合。
  3. 异步LLM调用: LangGraph支持异步节点。如果LLM API支持异步调用,可以利用asyncio在单个summarize_window节点内并行处理多个独立的子任务(尽管对于核心的增量摘要,它仍然是顺序的)。

F. 深度整合RAG:不仅仅是摘要

我们目前的系统只关注“聚合摘要”。如果我们的目标是“问答”,那么RAG的检索部分就需要更深入地集成。

  1. 查询时检索: 用户提出问题时,首先从所有initial_chunks中检索最相关的块。
  2. 基于摘要的检索: 在滚动窗口摘要过程中,每次生成新的current_summary后,可以将其作为上下文,配合用户查询,再次从原始document_chunks中检索更具体的信息,以增强最终的答案生成。
  3. 混合检索: 结合向量检索(语义相似性)和关键字检索(精确匹配),以确保检索的全面性。

G. 人工介入 (Human-in-the-Loop)

对于高风险或高准确性要求的任务,人工审核是必不可少的。

  1. 中间摘要审核: 在LangGraph中,可以设计一个节点,在每次summarize_window之后暂停,等待人工确认或修正current_summary,然后再继续。
  2. 最终摘要审核: 最终的聚合摘要可以提交给人工进行最终审批。

实际应用场景

这种滚动窗口结合LangGraph的模式在多个领域都有广泛的应用潜力:

  • 法律文件分析: 摘要长篇合同、判决书或法规,提取关键条款、义务和风险。
  • 科研文献综述: 自动化阅读和总结大量研究论文,帮助研究人员快速了解领域进展。
  • 企业报告生成: 聚合年度报告、财务报表、市场分析等,生成高管摘要。
  • 新闻与媒体: 实时处理和摘要长篇新闻报道、会议记录或直播文本,快速生成快讯。
  • 教育与培训: 总结教科书、讲义或学习材料,生成学习笔记或复习大纲。
  • 知识库构建: 从散落在各处的文档中提取核心信息,构建结构化的知识库。

展望未来

我们已经看到了LangGraph在构建有状态、迭代式LLM工作流方面的强大能力,它为处理超长文档带来了前所未有的灵活性和控制力。随着LLM上下文窗口的持续增长,以及更高效的token处理技术的出现,滚动窗口模式可能会在某些场景下变得不那么必要。但即便如此,其核心思想——将复杂任务分解为可管理、有状态的步骤——仍将是构建鲁棒、可扩展AI应用的关键范式。LangGraph作为一个通用的编排框架,其价值远超于简单的摘要,它将继续赋能开发者构建更多样化、更智能的AI代理和系统。

希望这次讲座能为大家在处理超长文档RAG问题上提供新的思路和实用的工具。感谢大家的聆听。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注