211 lines
6.4 KiB
Python
211 lines
6.4 KiB
Python
"""ContextualChunker - 上下文增强分块
|
||
|
||
在嵌入前为每个文档块添加 LLM 生成的上下文前缀,
|
||
解决分块后上下文丢失问题(Anthropic Contextual Retrieval)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from agentkit.memory.embedder import EmbeddingCache
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class ContextualChunk:
|
||
"""带上下文前缀的文档块"""
|
||
|
||
original_content: str
|
||
context_prefix: str
|
||
enhanced_content: str
|
||
chunk_index: int
|
||
metadata: dict[str, Any]
|
||
|
||
@property
|
||
def content(self) -> str:
|
||
"""获取增强后的完整内容"""
|
||
return self.enhanced_content
|
||
|
||
|
||
CONTEXT_PROMPT_TEMPLATE = """\
|
||
Given the full document below and a specific chunk from it, write a brief context that helps someone understand what this chunk is about in the broader document. Output ONLY the context, no explanations.
|
||
|
||
<document>
|
||
{document}
|
||
</document>
|
||
|
||
<chunk>
|
||
{chunk}
|
||
</chunk>
|
||
|
||
Context:"""
|
||
|
||
|
||
class ContextualChunker:
|
||
"""上下文增强分块器
|
||
|
||
为每个文档块生成 LLM 上下文前缀,增强检索质量。
|
||
|
||
工作流程:
|
||
1. 接收文档和分块列表
|
||
2. 对每个块,调用 LLM 生成简洁上下文语句
|
||
3. 将上下文前缀添加到原始内容前
|
||
4. 缓存结果避免重复计算
|
||
|
||
成本优化:
|
||
- 文档级 Prompt Caching(同一文档的多个块共享文档前缀)
|
||
- EmbeddingCache 缓存上下文生成结果
|
||
- 批处理(batch_size)
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm_gateway: Any = None,
|
||
cache: EmbeddingCache | None = None,
|
||
batch_size: int = 8,
|
||
max_context_length: int = 200,
|
||
prompt_template: str = CONTEXT_PROMPT_TEMPLATE,
|
||
):
|
||
"""
|
||
Args:
|
||
llm_gateway: LLM Gateway 实例,用于生成上下文
|
||
cache: 嵌入缓存,用于缓存上下文生成结果
|
||
batch_size: 批处理大小
|
||
max_context_length: 上下文最大字符长度
|
||
prompt_template: 上下文生成 prompt 模板
|
||
"""
|
||
self._llm_gateway = llm_gateway
|
||
self._cache = cache
|
||
self._batch_size = batch_size
|
||
self._max_context_length = max_context_length
|
||
self._prompt_template = prompt_template
|
||
self._context_cache: dict[str, str] = {}
|
||
|
||
async def enhance_chunks(
|
||
self,
|
||
document: str,
|
||
chunks: list[str],
|
||
metadata: dict[str, Any] | None = None,
|
||
) -> list[ContextualChunk]:
|
||
"""为文档块添加上下文前缀
|
||
|
||
Args:
|
||
document: 完整文档内容
|
||
chunks: 文档分块列表
|
||
metadata: 附加元数据
|
||
|
||
Returns:
|
||
增强后的 ContextualChunk 列表
|
||
"""
|
||
if not chunks:
|
||
return []
|
||
|
||
if not self._llm_gateway:
|
||
# No LLM available — return chunks without context
|
||
logger.info("No LLM gateway configured, skipping contextual enhancement")
|
||
return [
|
||
ContextualChunk(
|
||
original_content=chunk,
|
||
context_prefix="",
|
||
enhanced_content=chunk,
|
||
chunk_index=i,
|
||
metadata=metadata or {},
|
||
)
|
||
for i, chunk in enumerate(chunks)
|
||
]
|
||
|
||
result: list[ContextualChunk] = []
|
||
|
||
# Process in batches
|
||
for batch_start in range(0, len(chunks), self._batch_size):
|
||
batch = chunks[batch_start : batch_start + self._batch_size]
|
||
batch_results = await self._process_batch(document, batch, batch_start, metadata)
|
||
result.extend(batch_results)
|
||
|
||
return result
|
||
|
||
async def _process_batch(
|
||
self,
|
||
document: str,
|
||
chunks: list[str],
|
||
start_index: int,
|
||
metadata: dict[str, Any] | None,
|
||
) -> list[ContextualChunk]:
|
||
"""处理一批文档块"""
|
||
results: list[ContextualChunk] = []
|
||
|
||
for i, chunk in enumerate(chunks):
|
||
chunk_index = start_index + i
|
||
chunk_meta = dict(metadata or {})
|
||
chunk_meta["chunk_index"] = chunk_index
|
||
|
||
# Check cache
|
||
cache_key = self._make_cache_key(document, chunk)
|
||
if cache_key in self._context_cache:
|
||
context = self._context_cache[cache_key]
|
||
else:
|
||
context = await self._generate_context(document, chunk)
|
||
self._context_cache[cache_key] = context
|
||
|
||
# Truncate context if too long
|
||
if len(context) > self._max_context_length:
|
||
context = context[: self._max_context_length]
|
||
|
||
# Build enhanced content
|
||
if context:
|
||
enhanced = f"{context}\n{chunk}"
|
||
else:
|
||
enhanced = chunk
|
||
|
||
chunk_meta["context_prefix"] = context
|
||
chunk_meta["has_context"] = bool(context)
|
||
|
||
results.append(
|
||
ContextualChunk(
|
||
original_content=chunk,
|
||
context_prefix=context,
|
||
enhanced_content=enhanced,
|
||
chunk_index=chunk_index,
|
||
metadata=chunk_meta,
|
||
)
|
||
)
|
||
|
||
return results
|
||
|
||
async def _generate_context(self, document: str, chunk: str) -> str:
|
||
"""使用 LLM 为单个块生成上下文"""
|
||
# Truncate document for prompt efficiency
|
||
doc_preview = document[:3000] if len(document) > 3000 else document
|
||
chunk_preview = chunk[:1000] if len(chunk) > 1000 else chunk
|
||
|
||
prompt = self._prompt_template.format(
|
||
document=doc_preview,
|
||
chunk=chunk_preview,
|
||
)
|
||
|
||
try:
|
||
response = await self._llm_gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model="default",
|
||
)
|
||
context = response.content.strip()
|
||
return context
|
||
except Exception as e:
|
||
logger.warning(f"Context generation failed for chunk: {e}")
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _make_cache_key(document: str, chunk: str) -> str:
|
||
"""生成缓存键"""
|
||
content = f"{document[:500]}:{chunk[:500]}"
|
||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||
|
||
def clear_cache(self) -> None:
|
||
"""清除上下文缓存"""
|
||
self._context_cache.clear()
|