LLM长文档处理工程实践:从RAG到Full-Context的技术选型
引言
GPT-4的128K context window,Gemini 1.5的1M context window,Claude 3系列的200K……context window的军备竞赛正在重塑LLM应用的工程格局。但问题随之而来:更大的context window,真的意味着更好的处理效果吗?工程师们很快发现,长文档处理远不是"把内容塞进去"那么简单。本文将深度解析长文档处理的核心工程策略,帮你在不同场景下做出正确的技术选型。—## 一、长文档处理的三大技术路线### 1.1 技术路线全景长文档处理技术路线├── 检索增强生成(RAG)│ ├── 适用:超大文档集(GB级)│ ├── 优点:可扩展性强,成本可控│ └── 缺点:可能丢失跨段落关联│├── 全文上下文(Full-Context)│ ├── 适用:单文档深度分析│ ├── 优点:不丢失任何信息│ └── 缺点:成本高,"Lost in the Middle"问题│└── 混合策略(Hybrid) ├── 适用:企业级文档处理系统 ├── 优点:平衡精度与成本 └── 缺点:架构复杂度高### 1.2 如何选择技术路线?| 场景 | 推荐方案 | 原因 ||------|---------|------|| 问答(单文档,<100K tokens) | Full-Context | 精度最高,成本可接受 || 问答(多文档,>100K tokens) | RAG | 成本控制 || 文档摘要(单文档) | Map-Reduce + Full-Context | 分段摘要再合并 || 合同审查 | Full-Context | 需要全局视角 || 法律文档检索 | RAG + 重排序 | 规模化需求 || 代码仓库分析 | RAG + 依赖关系图 | 代码语义复杂 |—## 二、RAG的工程深度优化### 2.1 分块策略进阶简单的固定大小分块往往效果不佳。需要语义感知分块:pythonfrom anthropic import Anthropicfrom typing import List, Dictimport reclass SemanticChunker: """语义感知的文档分块器""" def __init__( self, target_chunk_size: int = 512, overlap_size: int = 50, model: str = "claude-3-5-haiku-20241022" ): self.target_size = target_chunk_size self.overlap = overlap_size self.client = Anthropic() self.model = model def chunk_by_structure(self, text: str) -> List[Dict]: """按文档结构分块(保留标题层级)""" chunks = [] # 识别标题 header_pattern = r'^(#{1,6})\s+(.+)$' sections = re.split(r'\n(?=#{1,6}\s)', text) for section in sections: if not section.strip(): continue # 提取标题信息 header_match = re.match(header_pattern, section, re.MULTILINE) header_level = len(header_match.group(1)) if header_match else 0 header_text = header_match.group(2) if header_match else "" # 如果section太长,进一步拆分 if len(section.split()) > self.target_size: sub_chunks = self._split_by_paragraphs(section) for sub in sub_chunks: chunks.append({ "content": sub, "header": header_text, "header_level": header_level, "metadata": {} }) else: chunks.append({ "content": section, "header": header_text, "header_level": header_level, "metadata": {} }) return chunks def _split_by_paragraphs(self, text: str) -> List[str]: """按段落拆分,保持语义完整性""" paragraphs = text.split('\n\n') chunks = [] current_chunk = [] current_size = 0 for para in paragraphs: para_size = len(para.split()) if current_size + para_size > self.target_size and current_chunk: chunks.append('\n\n'.join(current_chunk)) # 保留重叠 overlap_paras = current_chunk[-1:] if current_chunk else [] current_chunk = overlap_paras + [para] current_size = sum(len(p.split()) for p in current_chunk) else: current_chunk.append(para) current_size += para_size if current_chunk: chunks.append('\n\n'.join(current_chunk)) return chunks async def enrich_chunks(self, chunks: List[Dict]) -> List[Dict]: """使用LLM为每个chunk生成摘要和关键词""" enriched = [] for chunk in chunks: response = self.client.messages.create( model=self.model, max_tokens=200, messages=[{ "role": "user", "content": f"""为以下文本生成JSON格式的元数据:{chunk['content'][:500]}...返回JSON:{{"summary": "20字摘要", "keywords": ["关键词1", "关键词2"], "topic": "主题"}}""" }] ) try: metadata = json.loads(response.content[0].text) except: metadata = {"summary": "", "keywords": [], "topic": ""} enriched.append({**chunk, "metadata": metadata}) return enriched### 2.2 混合检索策略仅靠向量相似度往往不够,需要结合关键词检索:pythonfrom typing import List, Dict, Optionalimport numpy as npclass HybridRetriever: """混合检索器:向量检索 + BM25关键词检索""" def __init__( self, vector_store, # 向量数据库客户端 bm25_index, # BM25索引 alpha: float = 0.6 # 向量检索权重 ): self.vector_store = vector_store self.bm25 = bm25_index self.alpha = alpha def retrieve( self, query: str, top_k: int = 10, filters: Optional[dict] = None ) -> List[Dict]: """混合检索""" # 向量检索 vector_results = self.vector_store.similarity_search( query, k=top_k * 2, filter=filters ) # BM25检索 bm25_results = self.bm25.get_top_n( query.split(), [r["content"] for r in self._all_docs], n=top_k * 2 ) # 倒数排名融合(RRF) return self._reciprocal_rank_fusion( vector_results, bm25_results, top_k=top_k ) def _reciprocal_rank_fusion( self, list1: List, list2: List, k: int = 60, top_k: int = 10 ) -> List: """倒数排名融合算法""" scores = {} for rank, doc in enumerate(list1): doc_id = doc.get("id") or hash(doc.get("content", "")) scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + k) for rank, doc in enumerate(list2): doc_id = doc.get("id") or hash(doc.get("content", "")) scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + k) # 按融合分数排序 sorted_ids = sorted(scores.keys(), key=lambda x: -scores[x]) # 构建结果 all_docs = { doc.get("id") or hash(doc.get("content", "")): doc for doc in list1 + list2 } return [all_docs[id] for id in sorted_ids[:top_k] if id in all_docs]—## 三、Full-Context的工程挑战### 3.1 “Lost in the Middle"问题研究表明,LLM对长文档的注意力分布是U形的——开头和结尾的内容被更多关注,中间部分容易被忽略。解决方案——重要内容前置策略:pythonclass ContextOptimizer: """优化长上下文的信息布局""" def __init__(self, client): self.client = client def reorder_for_attention( self, chunks: List[Dict], query: str ) -> List[Dict]: """根据查询重排块,将最相关内容放在首尾""" if len(chunks) <= 3: return chunks # 按相关度排序 sorted_chunks = sorted( chunks, key=lambda x: x.get("relevance_score", 0), reverse=True ) # 重排:最相关放首尾,次相关放中间 n = len(sorted_chunks) reordered = [] # 前1/3放最相关的一半内容 top_half = sorted_chunks[:n//2] bottom_half = sorted_chunks[n//2:] # 交错放置 for i, chunk in enumerate(top_half): if i % 2 == 0: reordered.insert(0, chunk) # 放开头 else: reordered.append(chunk) # 放结尾 # 次相关内容放中间 mid = len(reordered) // 2 for chunk in bottom_half: reordered.insert(mid, chunk) return reordered def add_position_anchors(self, chunks: List[Dict]) -> str: """添加位置锚点,帮助模型定位信息""" result = [] total = len(chunks) for i, chunk in enumerate(chunks): position = f"[段落 {i+1}/{total}]" if chunk.get("header"): position += f" [{chunk['header']}]" result.append(f"{position}\n{chunk['content']}") return "\n\n---\n\n".join(result)### 3.2 Map-Reduce模式对于超长文档(超过context window),使用Map-Reduce模式:pythonclass MapReduceSummarizer: """基于Map-Reduce的长文档摘要器""" def __init__(self, client, model: str = "claude-3-5-sonnet-20241022"): self.client = client self.model = model async def summarize( self, chunks: List[str], final_length: int = 500, detail_level: str = "medium" # low, medium, high ) -> str: """ Map-Reduce摘要 1. Map阶段:并行摘要每个chunk 2. Reduce阶段:合并中间摘要 """ # Map阶段(并行) import asyncio async def summarize_chunk(chunk: str, idx: int) -> str: response = self.client.messages.create( model=self.model, max_tokens=300, messages=[{ "role": "user", "content": f"""请用3-5句话摘要以下内容的关键信息。保留所有重要数字、名称和结论。内容:{chunk}""" }] ) return response.content[0].text # 并发执行(控制并发数防止限速) semaphore = asyncio.Semaphore(5) async def bounded_summarize(chunk, idx): async with semaphore: return await summarize_chunk(chunk, idx) map_results = await asyncio.gather(*[ bounded_summarize(chunk, i) for i, chunk in enumerate(chunks) ]) # Reduce阶段 if len(map_results) <= 5: # 直接合并 return await self._reduce_summaries(map_results, final_length) else: # 分批reduce,再次reduce batch_size = 5 intermediate = [] for i in range(0, len(map_results), batch_size): batch = map_results[i:i+batch_size] reduced = await self._reduce_summaries(batch, 300) intermediate.append(reduced) return await self._reduce_summaries(intermediate, final_length) async def _reduce_summaries( self, summaries: List[str], target_length: int ) -> str: combined = "\n\n---\n\n".join(summaries) response = self.client.messages.create( model=self.model, max_tokens=target_length + 100, messages=[{ "role": "user", "content": f"""请将以下多个段落摘要合并成一篇连贯的摘要,约{target_length}字,保留所有关键信息:{combined}""" }] ) return response.content[0].text—## 四、混合策略的实现### 4.1 智能路由器pythonclass DocumentQueryRouter: """根据文档大小和查询类型智能路由""" TOKENS_PER_WORD = 1.3 # 粗略估算 # Context window限制(保守估计,留余量) MODEL_LIMITS = { "claude-3-5-sonnet-20241022": 150_000, "gpt-4o": 100_000, "gpt-4o-mini": 100_000, } def __init__(self, model: str): self.model = model self.limit = self.MODEL_LIMITS.get(model, 100_000) def estimate_tokens(self, text: str) -> int: return int(len(text.split()) * self.TOKENS_PER_WORD) def route( self, document: str, query: str, query_type: str = "qa" # qa, summary, extraction ) -> str: """ 路由决策 Returns: "full_context", "rag", "map_reduce" """ doc_tokens = self.estimate_tokens(document) query_tokens = self.estimate_tokens(query) total = doc_tokens + query_tokens + 500 # 留500 tokens给输出 # 摘要任务:必须用Map-Reduce if query_type == "summary" and doc_tokens > 20_000: return "map_reduce" # 文档很小:直接全文 if total < self.limit * 0.7: return "full_context" # 文档很大:必须用RAG if total > self.limit: return "rag" # 边界区域:根据查询类型决定 if query_type == "extraction": return "full_context" # 提取任务需要全文 return "rag" # 默认用RAGclass SmartDocumentProcessor: """智能文档处理器,自动选择最优策略""" def __init__(self, model: str = "claude-3-5-sonnet-20241022"): self.model = model self.client = Anthropic() self.router = DocumentQueryRouter(model) self.chunker = SemanticChunker() async def process( self, document: str, query: str, query_type: str = "qa" ) -> str: strategy = self.router.route(document, query, query_type) print(f"[路由决策] 策略: {strategy}") if strategy == "full_context": return self._full_context_qa(document, query) elif strategy == "rag": chunks = self.chunker.chunk_by_structure(document) retriever = await self._build_retriever(chunks) relevant = retriever.retrieve(query, top_k=5) context = "\n\n".join(r["content"] for r in relevant) return self._qa_with_context(context, query) elif strategy == "map_reduce": chunks = [ c["content"] for c in self.chunker.chunk_by_structure(document) ] summarizer = MapReduceSummarizer(self.client, self.model) return await summarizer.summarize(chunks) def _full_context_qa(self, document: str, query: str) -> str: response = self.client.messages.create( model=self.model, max_tokens=2000, messages=[ { "role": "user", "content": f"文档内容:\n\n{document}\n\n问题:{query}" } ] ) return response.content[0].text def _qa_with_context(self, context: str, query: str) -> str: response = self.client.messages.create( model=self.model, max_tokens=2000, messages=[ { "role": "user", "content": f"参考资料:\n\n{context}\n\n请基于参考资料回答:{query}" } ] ) return response.content[0].text—## 五、实战基准测试不同策略的性能对比(基于实测数据):| 文档大小 | 策略 | 响应延迟 | 成本/次 | 精度 ||---------|------|---------|---------|------|| <20K tokens | Full-Context | 3-8s | $0.03 | ★★★★★ || 50-100K tokens | Full-Context | 15-30s | $0.15 | ★★★★☆ || 50-100K tokens | RAG (top5) | 5-12s | $0.02 | ★★★★☆ || >200K tokens | RAG (top5) | 8-15s | $0.025 | ★★★☆☆ || >200K tokens | Map-Reduce | 30-60s | $0.05 | ★★★★☆ |—## 六、总结长文档处理没有"银弹”,工程化的关键是:1.理解场景:问答/摘要/提取对信息完整性要求不同2.智能路由:根据文档大小和查询类型自动选策略3.优化分块:语义感知分块 > 固定大小分块4.混合检索:向量 + BM25的RRF融合 > 单一向量检索5.应对失真:重要内容前置,避免"Lost in the Middle"随着context window的扩大(Gemini 2.0已支持2M tokens),Full-Context策略的适用范围会持续扩大,但成本和延迟问题会使RAG在大规模生产场景中长期保持价值。
