How to Handle Small Context Window Limits in RAG Systems

TL;DR · AI 摘要
在RAG系统中,通过使用摘要路由和上下文预算,可以有效处理小上下文窗口限制问题。
核心要点
- 使用摘要进行检索,使用原始块进行回答,可以优化上下文窗口的使用。
- 通过递归减少摘要,可以构建分层索引以提高效率。
- 在生产环境中,应替换简化摘要器和内存相似性搜索,以提高模型质量。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 处理RAG系统中的小上下文窗口限制
- 摘要路由
- 使用摘要进行检索
- 使用原始块进行回答
- 上下文预算
- 优化模型输入
- 分层索引
- 递归减少摘要
金句 / Highlights
值得收藏与分享的关键句。
使用摘要进行检索,使用原始块进行回答,可以优化上下文窗口的使用。
通过递归减少摘要,可以构建分层索引以提高效率。
在生产环境中,应替换简化摘要器和内存相似性搜索,以提高模型质量。
如何在 RAG 系统中处理小上下文窗口限制
2026年6月18日
/
#AI
检索增强生成(Retrieval-augmented generation,简称 RAG)是一种模式,其中应用程序检索相关源材料,并将其添加到模型提示中,以便模型可以基于该上下文进行回答。
在 RAG 系统中,更大的上下文窗口不应被视为良好上下文管理的替代方案,尽管它可以使最终用户的体验更加宽容。这就像在强大的 GPU 上运行未优化的图形:额外的容量可以暂时隐藏低效率,但它并不能消除底层的优化问题。
但是,即使是非常大的上下文窗口仍然有硬性限制。如果你不断添加标记(tokens),最终仍会超出限制。这个问题在消费级硬件上更加明显,因为有限的内存和计算能力通常意味着更小的可用上下文窗口。
我在使用具有 12 GB VRAM 的消费级笔记本电脑进行本地模型实验时遇到了这个问题。RAG 在小规模测试中运行良好,但一旦文档变大,系统虽然会检索到有用的片段,但仍然无法很好地回答问题。
问题并不总是检索造成的。有时,正确的片段已经被找到,但最终的提示中没有空间容纳它。
本文将介绍我为解决这个问题所实施的解决方案:
文档摘要 → 片段摘要 → 原始片段 → 最终答案
该模式基于以下三条规则:
- 使用摘要进行检索。
- 使用原始片段进行回答。
- 使用上下文预算来决定哪些内容可以到达模型。
为了使演示简单且方便,配套的代码库使用了小型的 Python 和 TypeScript 示例,以及简化的内存检索存储和简化的答案提取器。这使你能够在不安装完整的依赖栈、下载模型、运行大型语言模型(LLM)服务器、设置嵌入服务或配置向量数据库的情况下,实际看到本文的核心思想。
该设置过程很容易成为一篇独立的文章,因此本教程将可运行的示例集中在小上下文 RAG 模式上:使用摘要进行检索、使用原始片段进行回答,并使用可见的上下文预算。
该代码库演示了数据流和调试模式,而不是生产级别的模型质量。在生产环境中,你希望用自己的模型、嵌入存储、重排序器和分词器来替换简化的摘要器、内存相似性搜索和标记估算器。
目录
- 你将实现的内容
- 先决条件
- 为什么基本的 RAG 在小上下文窗口下可能失败
- 摘要路由是如何工作的
- 如何表示文档和片段
- 如何将文档拆分为原始片段
- 如何对片段和文档进行摘要
- 如何递归地减少摘要
- 如何实现分层索引
- 如何通过摘要进行检索
- 如何实现预算化的原始上下文
- 如何运行演示
- 如何解释 250 与 1200 标记测试
- 这与现有 RAG 技术的关系
- 何时使用此模式
- 结论
你将实现的内容
在本教程中,你将实现一个小型的教育性 RAG 管道,通过在三个层次上处理文档来管理上下文窗口限制:
- 文档记录包含一个简短的摘要,用于选择可能的文档。
- 片段记录包含一个简短的摘要,用于选择文档中的可能片段,以及原始源文本。
- 原始上下文包含选定的原始块,这些块被压缩到一个固定的令牌预算中。
重要的区别在于,摘要仅用于决定在何处查找信息。它们不会作为最终的证据使用。
这很重要,因为摘要会丢失信息。它们会压缩信息,并可能遗漏回答用户问题所需的细节。相比之下,原始块更大,但保留了原始措辞。
演示会为每个问题打印一个跟踪记录:
- 文档摘要命中
- 块摘要命中
- 包含的原始块
- 被跳过的原始块
- 答案
该跟踪记录是调试接口。它显示检索是否失败,或者由于上下文预算太小,提示组装是否跳过了有用的证据。
先决条件
要跟随本文,你需要以下之一:
- Python 3.10 或更新版本
或者:
- Node.js 22 或更新版本
- npm
如果你已经熟悉以下内容,将从本文中获益最多:
- 基本的 Python 或 TypeScript 语法
- 在终端中运行命令
- 阅读小型数据类、函数以及列表或映射
- LLM 提示和上下文窗口的一般概念
- 基本的 RAG 思想:检索相关源文本,将其添加到提示中,并基于该上下文进行回答
你不需要有向量数据库、嵌入 API、LangChain、LlamaIndex 或本地 LLM 设置的先前经验。
示例不需要 LLM 提供商、嵌入 API 或向量数据库。它们使用以下方式:
- 用句子提取代替 LLM 摘要
- 用词袋余弦相似度代替嵌入搜索
- 用基于字符的固定令牌估计代替分词器
我做出这些实现选择是为了节省你的时间,使示例更容易尝试,同时保留原始目的。它们还使检索路径可见。
为什么基本的 RAG 在小的上下文窗口中可能失败
基本的 RAG 循环通常如下所示:
加载文档 → 将文档拆分为块 → 对块进行嵌入 → 检索最相关的块 → 将检索到的块放入提示中 → 让模型回答问题。
这是一个良好的起点。但它将两个不同的问题隐藏在一个短语中:“检索最相关的块”。
首先,你需要找到相关材料。这是检索质量。
其次,你需要决定哪些检索到的材料实际上适合最终的提示。这是上下文预算分配。
在大型托管模型上,你可能不会立即注意到这个问题。但在本地模型或较小的上下文窗口上,你会很快注意到它。
失败的模式如下:
- 检索器找到有用的块。
- 提示构建器尝试将它们添加进去。
- 上下文预算已满。
- 一些块被跳过。
- 最终模型从未看到这些被跳过的块。
- 答案不完整或表示“我不知道”。
当你检查检索并看到相关块被返回时,这可能会让人感到困惑。但检索返回一个块并不等同于模型看到该块。
如果你在受限硬件上开发 RAG 系统,这种区别变得非常重要。
摘要路由的工作原理
你不需要直接搜索所有原始块,而是可以通过摘要创建一个路由层。
在索引时:
- 加载文档。
- 将每个文档拆分为块。
- 对每个块进行摘要。
- 将块摘要减少为一个文档摘要。
- 将文档摘要存储在文档摘要存储中。
- 将块摘要存储在每个文档的块摘要存储中。
- 将原始块保留在查找表中。
以下是索引管道的结构:
在提问时:
- 搜索文档摘要以选择可能的文档。
- 仅在这些文档中搜索块摘要。
- 将块摘要的匹配结果转换回原始块 ID。
- 可选地添加相邻的块。
- 将原始块打包到最终的上下文预算中。
- 仅从原始块中回答问题。
查询路径使用摘要进行路由,然后在回答前切换回原始块:
这为你提供了两个有用的特性:
- 摘要使检索更加便宜。
- 原始块使答案保持 grounded(基于事实)。
它还为你提供了一个调试的地方。如果系统给出的答案较弱,请检查跟踪信息。是否匹配了正确的文档摘要?是否匹配了正确的块摘要?原始块是否适合最终的上下文?是否因为预算而被跳过?
如何表示文档和块
这些数据结构特意设计得较小,因为它们只包含此管道所需的最基本信息。在实际系统中,你可能会添加更多的元数据。
以下是 Python 版本:
from dataclasses import dataclass
@dataclass(frozen=True)
class SearchDocument:
page_content: str
metadata: dict[str, str | int]
@dataclass(frozen=True)
class DocumentRecord:
doc_id: str
source: str
text: str
summary: str
@dataclass(frozen=True)
class ChunkRecord:
chunk_id: str
doc_id: str
source: str
index: int
text: str
summary: str
previous_chunk_id: str | None
next_chunk_id: str | NoneDocumentRecord 存储完整的文档和摘要。ChunkRecord 存储原始块、其摘要以及与前一个和后一个块的链接。
这些邻居链接非常有用,因为块边界是人为设定的。如果检索到块 4,答案可能从块 3 开始,或继续到块 5。
索引同时保存可搜索的存储和查找映射:
@dataclass(frozen=True)
class HierarchicalIndex:
documents_by_id: dict[str, DocumentRecord]
chunks_by_id: dict[str, ChunkRecord]
chunks_by_doc_id: dict[str, list[ChunkRecord]]
document_summary_store: SimpleVectorStore
chunk_summary_stores_by_doc_id: dict[str, SimpleVectorStore]最重要的查找是:
chunk = index.chunks_by_id[chunk_hit.metadata["chunk_id"]]这一行将检索到的摘要匹配结果转换回用于最终答案的原始源文本。
如何将文档拆分为原始块
演示通过段落拆分 Markdown 文件,并将段落分组,直到达到目标字符大小:
CHUNK_SIZE = 420
def split_text(text: str) -> list[str]:
chunks = []
current_paragraphs = []
current_size = 0
for paragraph in re.split(r"\n\s*\n", text.strip()):
paragraph = paragraph.strip()
if not paragraph:
continue
if current_paragraphs and current_size + len(paragraph) > CHUNK_SIZE:
chunks.append("\n\n".join(current_paragraphs))
current_paragraphs = []
current_size = 0
current_paragraphs.append(paragraph)
current_size += len(paragraph)
if current_paragraphs:
chunks.append("\n\n".join(current_paragraphs))
return chunks一个重要事项:这不是适用于所有使用场景的完美拆分器。它特意设计得易于阅读。
在生产系统中,你可能会使用 tokenizer 意识的拆分器、Markdown 意识的段落、语义分块或父子分块。但无论你选择哪种方式,其核心思想都是一致的:保持原始分块作为最终的证据。
如何对分块和文档进行摘要
为了使演示易于运行,本文使用句子提取作为大型语言模型摘要的替代方法。它会对包含重要 RAG 术语的句子进行评分,并保留评分最高的句子。
def summarize_text(text: str, max_sentences: int = 2) -> str:
sentences = [
sentence.strip()
for sentence in re.split(r"(?<=[.!?])\s+", " ".join(text.split()))
if sentence.strip()
]
if len(sentences) <= max_sentences:
return " ".join(sentences)
scored_sentences = []
for position, sentence in enumerate(sentences):
sentence_words = words(sentence)
term_score = sum(3 for word in sentence_words if word in IMPORTANT_TERMS)
first_sentence_bonus = 1 if position == 0 else 0
scored_sentences.append((term_score + first_sentence_bonus, position, sentence))
selected = sorted(scored_sentences, key=lambda item: (-item[0],item[1]))[:max_sentences]
selected.sort(key=lambda item: item[1])
return " ".join(sentence for _score, _position, sentence in selected)在实际系统中,这个函数会调用一个小型本地模型或托管模型。提示指令可能如下:
- 为检索对这个分块进行摘要。
- 保留名称、约束、决策、错误、数字和领域特定术语。
- 不要回答用户的问题。
请注意,分块摘要并不是用来替代原始分块的。它的唯一目标是使检索更容易。
如何递归地减少摘要
一个常见的错误是通过将每个分块摘要放入一个提示中来创建文档摘要:
combined = "\n\n".join(chunk_summaries)
document_summary = summarize(combined)这种方法对少量分块有效,但对数百个分块无效。你只是将上下文窗口问题从回答时间转移到了索引时间。
更好的方法是按批次减少摘要:
分块摘要 → 预算批次 → 批次摘要 → 更高层次的摘要 → 最终文档摘要。
减少过程如下所示:
以下是预算打包函数:
def pack_summaries_by_token_budget(
summaries: list[str],
token_budget: int,
) -> list[list[str]]:
batches = []
current_batch = []
current_tokens = 0
for summary in summaries:
summary_tokens = approximate_tokens(summary)
if current_batch and current_tokens + summary_tokens > token_budget:
batches.append(current_batch)
current_batch = []
current_tokens = 0
current_batch.append(summary)
current_tokens += summary_tokens
if current_batch:
batches.append(current_batch)
return batches以下是递归减少循环:
def recursively_reduce_summaries(summaries: list[str]) -> str:
if not summaries:
return "No summary available."
current_summaries = summaries
level = 1
while len(current_summaries) > 1:
batches = pack_summaries_by_token_budget(
current_summaries,
SUMMARY_REDUCTION_INPUT_TOKEN_BUDGET,
)
if len(batches) == len(current_summaries):
batches = force_summary_reduction_progress(current_summaries)print( f"Reducing {len(current_summaries)} summaries into " f"{len(batches)} batch summaries at level {level}" )
current_summaries = [reduce_summary_batch(batch) for batch in batches] level += 1
return summarize_text(current_summaries[0], max_sentences=3)
回退机制很重要:
if len(batches) == len(current_summaries): batches = force_summary_reduction_progress(current_summaries)
如果每个摘要太大,无法与其他摘要合并,简单的预算打包无法取得进展,因此需要将摘要配对,以强制减少过程继续进行。
## 如何实现分层索引
一旦你有了文档记录和块记录,创建两种类型的存储:
- 一种存储文档摘要
- 一种存储块摘要,并按文档进行分组
这是文档摘要存储的示例:
document_summary_store = SimpleVectorStore( [ SearchDocument( page_content=record.summary, metadata={"doc_id": record.doc_id, "source": record.source}, ) for record in document_records ] )
然后按文档对块进行分组:
chunks_by_doc_id: dict[str, list[ChunkRecord]] = {}
for chunk in chunk_records: chunks_by_doc_id.setdefault(chunk.doc_id, []).append(chunk)
然后为每个文档创建一个块摘要存储:
chunk_summary_stores_by_doc_id = {}
for doc_id, doc_chunks in chunks_by_doc_id.items(): chunk_summary_stores_by_doc_id[doc_id] = SimpleVectorStore( [ SearchDocument( page_content=chunk.summary, metadata={ "chunk_id": chunk.chunk_id, "doc_id": chunk.doc_id, "source": chunk.source, "chunk_index": chunk.index, }, ) for chunk in doc_chunks ] )
这就是实现分层检索的关键:第一次搜索选择文档,而第二次搜索只在选定的文档内部进行。
## 如何通过摘要进行检索
在提问时,首先搜索文档摘要:
document_hits = index.document_summary_store.similarity_search( question, k=min(DOC_RETRIEVAL_K, len(index.documents_by_id)), )
在这些搜索中,k 控制存储应返回的前几名结果的数量。
然后在每个选定的文档中搜索块摘要:
chunk_hits = [] seen_chunk_ids = set()
for document_hit in document_hits: doc_id = str(document_hit.metadata["doc_id"]) chunk_store = index.chunk_summary_stores_by_doc_id[doc_id] doc_chunk_count = len(index.chunks_by_doc_id[doc_id]) per_doc_hits = chunk_store.similarity_search( question, k=min(CHUNK_RETRIEVAL_K_PER_DOC, doc_chunk_count), )
for chunk_hit in per_doc_hits: chunk_id = str(chunk_hit.metadata["chunk_id"])
if chunk_id in seen_chunk_ids: continue
chunk_hits.append(chunk_hit) seen_chunk_ids.add(chunk_id)
注意这里检索的是摘要。
摘要命中包含 chunk_id,但最终答案仍然使用与该 ID 关联的原始块文本,因为原始块保留了摘要可能删除的原始措辞和细节。
## 如何实现预算原始上下文
在块摘要检索之后,将命中结果转换回原始块。
演示还添加了相邻块:
def candidate_raw_chunks( chunk_hits: list[SearchDocument], index: HierarchicalIndex, ) -> list[ChunkRecord]: candidates = [] seen_chunk_ids = set()
for chunk_hit in chunk_hits: chunk = index.chunks_by_id[str(chunk_hit.metadata["chunk_id"])] related_chunk_ids = [chunk.chunk_id]
if EXPAND_NEIGHBOR_CHUNKS: related_chunk_ids.extend([chunk.next_chunk_id, chunk.previous_chunk_id])
for chunk_id in related_chunk_ids: if chunk_id is None or chunk_id in seen_chunk_ids: continue
candidates.append(index.chunks_by_id[chunk_id]) seen_chunk_ids.add(chunk_id)
return candidates
然后应用最终的上下文预算:
def build_raw_context( chunk_hits: list[SearchDocument], index: HierarchicalIndex, ) -> tuple[str, list[tuple[ChunkRecord, int]], list[tuple[ChunkRecord, int]]]: included_chunks = [] skipped_chunks = [] used_tokens = 0
for chunk in candidate_raw_chunks(chunk_hits, index): raw_context_part = format_raw_chunk(chunk) raw_context_tokens = approximate_tokens(raw_context_part)
if used_tokens + raw_context_tokens > RAW_CONTEXT_TOKEN_BUDGET: skipped_chunks.append((chunk, raw_context_tokens)) continue
included_chunks.append((chunk, raw_context_tokens)) used_tokens += raw_context_tokens
included_chunks.sort(key=lambda item: (item[0].source, item[0].index))
context = "\n\n---\n\n".join( format_raw_chunk(chunk) for chunk, _tokens in included_chunks )
return context, included_chunks, skipped_chunks
这一步是许多 RAG 错误变得明显的地方。
如果系统检索到一个有用的块,但由于提示信息已满而跳过它,问题不在于文档搜索,而在于上下文预算。
## 如何运行演示
配套仓库包含相同示例的两个版本。
从配套仓库的根目录运行 Python 版本:
cd python python3 -m small_context_rag_solution --question "Why can RAG fail when the context budget is too small?"
运行 TypeScript 版本:
cd typescript npm install npm run demo
你也可以通过省略问题标志来交互式运行任一示例。在交互模式中,输入 q、quit 或 exit 以退出。
Python:
python3 -m small_context_rag_solution
TypeScript:
npm run build npm start
默认的原始上下文预算故意设置得很小:RAW_CONTEXT_TOKEN_BUDGET=250。这使得跳过的块变得明显。
## 如何解释 250 与 1200 令牌测试
使用两种不同的预算运行相同的问题。
RAW_CONTEXT_TOKEN_BUDGET=250 python3 -m small_context_rag_solution --question "Why can RAG fail when the context budget is too small?" RAW_CONTEXT_TOKEN_BUDGET=1200 python3 -m small_context_rag_solution --question "Why can RAG fail when the context budget is too small?"
RAW_CONTEXT_TOKEN_BUDGET=250 npm run demo RAW_CONTEXT_TOKEN_BUDGET=1200 npm run demo
使用 250 令牌预算时,原始上下文构建器仅包含两个块:
- doc-003-large_rag_notes-chunk-004 (约 110 个令牌)
- doc-003-large_rag_notes-chunk-005 (约 121 个令牌)
它跳过了五个其他选中的块:
- doc-003-large_rag_notes-chunk-003 (约 117 个令牌)
- doc-003-large_rag_notes-chunk-001 (约 116 个令牌)
- doc-003-large_rag_notes-chunk-002 (约 120 个令牌)
- doc-001-context_window_notes-chunk-001 (约 131 个令牌)
- doc-001-context_window_notes-chunk-002 (73 个约略标记)
在 1200 个标记的预算下,每一个被选中的原始块都能容纳:
没有被选中的原始块被跳过。
这个图表展示了两种上下文预算之间的差异:
1200 个标记的限制对于一个实际系统来说仍然是一个非常小的上下文窗口,但它比 250 要大得多。在这个例子中,你可以清楚地看到,当提示构建器有更多空间时,相同的检索路径行为会有所不同。
这就是为什么我喜欢同时打印包含和跳过的块。它有助于回答一个实际的调试问题:
是检索遗漏了证据,还是提示组装将其丢弃了?
这个演示使用了一个简化的回答步骤,所以不要过于关注最终答案的确切措辞。在实际的 LLM 提示中,你会包括如下指令:
- 仅从下面的原始块中回答。
- 如果原始块中包含多个相关原因,请全部包括。
- 对于多部分答案,优先使用简洁的项目符号列表。
- 如果原始块中没有足够的证据,请说明。
更多的上下文并不会自动使答案更好。提示仍然需要告诉模型如何使用额外的证据。
## 这与现有 RAG 技术的关系
这种模式并不是全新的研究。它是对 RAG 生态系统中已存在的一些想法的实用结合。
LangChain 在其 ParentDocumentRetriever 中使用了类似的技术,该技术搜索较小的子块,然后返回它们较大的父文档。
它也与 LlamaIndex 的 Document Summary Index 相关,该方法使用文档摘要来选择相关文档,然后检索这些文档的节点。
它在概念上也与 RAPTOR 相邻,RAPTOR 是一种检索方法,通过递归聚类和摘要文本构建一棵树。
本文中的版本特意更简单:
- 没有聚类。
- 没有框架要求。
- 演示中不需要向量数据库。
- 没有声称摘要就足以回答最终问题。
目标是展示一种透明的模式,易于理解其内部机制,并根据自己的需求进行调整,而无需依赖重型框架。对于我的本地模型工作,有用的部分是以下的分离:
- 用于检索的摘要
- 用于定位的原始块
- 用于调试的预算追踪
## 何时使用此模式
当以下情况发生时,这种模式非常有用:
- 你使用本地模型且 VRAM 有限
- 你的上下文窗口较小或昂贵
- 你有很多文档,但每个问题只涉及其中少数几个相关文档
- 你希望查看可检查的检索追踪
- 你希望使用摘要进行搜索,但使用原始文本进行回答
- 你希望在索引和回答过程中避免无限制的提示
当以下情况发生时,这种模式不太有用:
- 你的源文档本身已经很小
- 你的整个语料库可以轻松地放入提示中
- 精确的关键词搜索就足够
- 你不需要多文档路由
- 你能够负担得起直接检索和重新排序许多原始块
还存在一个权衡。这种模式增加了索引工作:
- 块摘要
- 递归摘要缩减
- 文档摘要
- 额外的查找映射
这通常对于文档助手、研究工具、内部知识库和本地模型项目是可以接受的,因为在这些项目中,索引只需进行一次,而查询可以进行多次。
## 结论
不要将 RAG 仅仅视为“检索块并将它们粘贴到提示中”。
对于小上下文系统,检索需要路由和预算管理。即使在高端硬件上拥有非常大的上下文窗口,随着项目规模的扩大,良好的系统设计也变得至关重要。
这个模式可以归结为三个实用规则:
- 摘要有助于找到相关源材料。
- 原始块为答案提供依据。
- 上下文预算管理决定了哪些内容能够传递给模型。
这个解决方案帮助我在受限硬件上开发出更可靠的本地 RAG 系统。它还使故障更容易调试,因为我可以清楚地看到哪些摘要匹配了,哪些原始块被选中,哪些原始块被跳过了。
无论你是本地运行 RAG 还是使用托管模型,如果你正在使用小模型、有限的上下文窗口或严格的提示预算,这个模式在你花钱购买更大上下文窗口之前都值得一试。
我的核心专长是 React 生态系统中的前端架构,但我也经常使用 Node.js、Python、Cloudflare 和 AI 基础设施进行全栈开发。
如果这篇文章对你有帮助,请分享它。
免费学习编程。freeCodeCamp 的开源课程已经帮助超过 40,000 人成为开发人员。立即开始学习
ADVERTISEMENT