第4节:切片语义割裂怎么办?

RAG与Agent性能调优:4.切片语义割裂怎么办?

Gitee地址:https://gitee.com/agiforgagaplus/OptiRAGAgent

文章详情目录:RAG与Agent性能调优

上一节:第3节:领域术语种混淆?构建精准数语库,提升检索一致性

下一节:第5节:动态切片策略与重叠机制提升RAG召回率

什么是滑动窗口?

我们可以把文档想象成一条连绵不断的河流,而滑动窗口就像是一个移动观察框。它每次只关注一小段,然后逐步向前滑动,依次扫描整个文档

  • 避免切片过短,确保每个片段都有一定的信息量
  • 同时,合理设置窗口大小和布长,实现对全文覆盖式处理

关键词如何发挥作用?

  • 关键词通常是文档中的核心概念、专业术语或高频语义单元,它们承载着文本的主要信息特点
  • 如果某个窗口的边界恰好切断了一个关键词,或者打断了一个完整的语义单元
  • 我们就可以根据关键词位置动态调整切片的起始或结束位置,从而保证语语义的完整性

滑动窗口+关键词=智能切片

将两者结合,我们可以构建一套更加智能的切片流程

  • 初步切片:使用滑动窗口,对文档进行基础分段
  • 关键词检测:分析每个切片的边界,是否存在关键词或语义单元中断
  • 动态调整:根据关键词的位置和上下文语义,微调切片边缘,确保语义完整且不冗余

实战演练(均为伪代码,详细代码进仓库查看)

Token切片

优点:控制输出长度,适用于小模型,实现简单

缺点:容易造成语义割裂;依赖overlap补偿效果有限,不适合复杂语义任务

复制代码
import os
from llama_index.core import VectorStoreIndex, Settings, Document
from llama_index.core.node_parser import SentenceWindowNodeParser, SemanticSplitterNodeParser, TokenTextSplitter
from llama_index.core.postprocessor import MetadataReplacementPostProcessor

# 导入 OpenAILike LLM (用于 DashScope 兼容模式,Qwen 模型)
from llama_index.llms.openai_like import OpenAILike 
# 导入 DashScopeEmbedding (用于阿里云 DashScope 嵌入模型)
from llama_index.embeddings.dashscope import DashScopeEmbedding 

# 1. 配置 LLM (使用 DashScope 的 qwen-plus 模型,通过 OpenAILike 调用)
# 2. 配置嵌入模型 (使用 DashScopeEmbedding 类)

def evaluate_splitter(splitter, documents, question, splitter_name):
    """
    评测不同文档切片方法的效果
    手动打印召回结果,方便直接对比切分效果。
    """
    print(f"\n{'='*50}")
    print(f"正在使用 {splitter_name} 方法进行测试...")
    print(f"{'='*50}\n")

    # 显示 raw chunks generated by the splitter
    print(f"【{splitter_name}】生成的原始文档切片 (Nodes):")
    raw_nodes = splitter.get_nodes_from_documents(documents)
    for i, node in enumerate(raw_nodes, 1):
        print(f"\n   切片 {i}:")
        if isinstance(splitter, SentenceWindowNodeParser):
            original_text = node.metadata.get("original_text", node.get_content())
            window_context = node.metadata.get("window", "N/A - 窗口内容未生成")
            print(f"   核心内容: \"{original_text}\"")
            print(f"   完整窗口上下文(供LLM用): \"{window_context}\"")
        else:
            print(f"   内容: \"{node.get_content()}\"")
        # Add metadata for debugging if needed
        # print(f"   元数据: {node.metadata}")
        print("   " + "-" * 40)
    print("\n" + "="*50)

# --- 开始测试不同的切片策略 ---
# Token 切片 (Character/Token-based)
token_splitter = TokenTextSplitter(
    chunk_size=30, # Small chunk size to demonstrate forced breaks
    chunk_overlap=0 # No overlap for clear distinct chunks
)
evaluate_splitter(token_splitter, documents, question, "Token 切片 (chunk_size=30)")

token_splitter = TokenTextSplitter(
    chunk_size=30, # Small chunk size to demonstrate forced breaks
    chunk_overlap=10 # No overlap for clear distinct chunks
)
evaluate_splitter(token_splitter, documents, question, "Token 切片 (chunk_size=30,chunk_overlap=10 )")

句子切片

|---------------|-----------------------|----------------------|
| 特性 | TokenTextSplitter | SentenceSplitter |
| 切分单位 | Token | 句子 |
| chunk_size | 硬性上限,强制截断 | 软性目标,优先保持句子完整 |
| chunk_overlap | Token 级别重叠,可能包含不完整句子 | 句子级别重叠,确保上下文自然衔接 |

复制代码
sentence_splitter = SentenceSplitter(
    chunk_size=512,
    chunk_overlap=50
)
evaluate_splitter(sentence_splitter, documents, question, "Sentence")

句子窗口切片

  • 切分单元:句子

  • 核心机制:为每个句子附加上下文窗口

  • 检索方式:基于句子的精准召回

  • 生成方式:使用上下文窗口提升语义完整性

  • 适用场景:精准问答,摘要生成,解释型问答等对上下文敏感的任务

    句子窗口切片 (Sentence Window)

    sentence_window_splitter = SentenceWindowNodeParser.from_defaults(
    window_size=3,
    window_metadata_key="window",
    original_text_metadata_key="original_text"
    )
    evaluate_splitter(sentence_window_splitter, documents, question, "Sentence Window")

句子的滑动窗口切片

|---------|----------|------------|---------------|
| 特性 | 句子切片 | 句子窗口切片 | 基于句子的滑动窗口 |
| 保持句子完整性 | ✅ | ✅ | ✅ |
| 切块之间重叠 | ❌ | ❌ | ✅ |
| 上下文丰富程度 | 一般 | 强(需后处理) | 强(天然包含) |
| 检索准确性 | 一般 | 一般 | 强 |
| 适用场景 | 基础文本处理 | 精准问答、解释型任务 | 复杂语义匹配、长文本检索 |

复制代码
def demonstrate_sliding_window_splitter(documents, chunk_size, chunk_overlap):
    """
    演示 LlamaIndex 中保持句子完整性的滑动窗口切片。
    
    Args:
        documents (list[Document]): 待切分的文档列表。
        chunk_size (int): 每个切块的目标 Token 数量。
        chunk_overlap (int): 相邻切块之间重叠的 Token 数量。
    """
    print(f"\n{'='*50}")
    print(f"正在演示【滑动窗口切片】...")
    print(f"切块大小 (chunk_size): {chunk_size}")
    print(f"重叠大小 (chunk_overlap): {chunk_overlap}")
    print(f"{'='*50}\n")

    # --- 第一步:创建切分器 ---
    # SentenceSplitter 优先保持句子完整性,再考虑大小
    splitter = SentenceSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap
    )

    # --- 第二步:执行切分 ---
    # 获取切分后的节点(切块)
    nodes = splitter.get_nodes_from_documents(documents)

    # --- 第三步:打印切分结果,展示重叠效果 ---
    print("\n--- 切分后生成的原始切块:---")
    print(f"文档被切分为 {len(nodes)} 个切块。")
    for i, node in enumerate(nodes, 1):
        content = node.get_content().strip()
        print(f"\n【切块 {i}】 (长度: {len(content)} 字符):")
        print("-" * 50)
        print(f"内容:\n\"{content}\"")
        print("-" * 50)

    # --- 简单的切分效果分析:观察相邻切块的重叠部分 ---
    print("\n--- 关键点:观察相邻切块的重叠部分 ---")
    if len(nodes) > 1:
        # 为了更好地展示重叠,我们只截取重叠部分的内容
        # 由于是句子级别的切分,重叠部分是完整的句子
        overlap_content_end_of_chunk1 = nodes[0].get_content()[-chunk_overlap:].strip()
        overlap_content_start_of_chunk2 = nodes[1].get_content()[:chunk_overlap].strip()
        print(f"切块 1 的末尾 ({chunk_overlap} 字符): \"...{overlap_content_end_of_chunk1}\"")
        print(f"切块 2 的开头 ({chunk_overlap} 字符): \"{overlap_content_start_of_chunk2}...\"")
        print(f"\n你可以看到,切块 1 的末尾与切块 2 的开头存在重叠,这就是 chunk_overlap 的作用。")
    else:
        print("文档太短,未能生成多个切块。请使用更长的文档以观察效果。")

    print(f"\n滑动窗口切片测试完成。")
    print(f"{'='*50}\n")

# --- 调用滑动窗口切片演示函数 ---
# 调整 chunk_size 和 chunk_overlap 观察不同效果
demonstrate_sliding_window_splitter(documents, chunk_size=150, chunk_overlap=50)

语义切片

复制代码
# --- 3. 定义一个能处理中文的自定义分句函数 ---
# 这个函数本身就是 SemanticSplitterNodeParser 所需要的"句子切分器"
def chinese_sentence_tokenizer(text: str) -> list[str]:
    sentences = re.findall(r'[^。!?...\n]+[。!?...\n]?', text)
    return [s.strip() for s in sentences if s.strip()]


def plot_similarity_and_chunks(splitter: SemanticSplitterNodeParser, title: str):
    # 使用我们自己的函数进行可视化部分的句子切分,这部分逻辑是正确的
    sentences = chinese_sentence_tokenizer(document.get_content())
    
    if len(sentences) < 2:
        print(f"错误:只找到了 {len(sentences)} 个句子,无法计算句子间的相似度。")
        return

    print(f"正在为 {len(sentences)} 个句子生成嵌入向量...")
    embeddings = Settings.embed_model.get_text_embedding_batch(sentences, show_progress=True)
    
    def cosine_similarity(v1, v2):
        return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

    similarities = [cosine_similarity(embeddings[i], embeddings[i+1]) for i in range(len(embeddings) - 1)]
    breakpoint_threshold_val = np.percentile(similarities, splitter.breakpoint_percentile_threshold)
    print(f"计算出的相似度阈值为: {breakpoint_threshold_val:.4f}")

    # --- 可视化 ---
    plt.figure(figsize=(12, 6))
    plt.plot(similarities, marker='o', linestyle='-', label='相邻句子相似度')
    plt.axhline(y=breakpoint_threshold_val, color='r', linestyle='--', label=f'切分阈值 ({splitter.breakpoint_percentile_threshold}百分位)')
    plt.title(title, fontsize=16)
    plt.xlabel("句子连接处索引")
    plt.ylabel("余弦相似度")
    plt.legend()
    try:
        plt.rcParams['font.sans-serif'] = ['SimHei']
        plt.rcParams['axes.unicode_minus'] = False
    except Exception:
        print("无法设置中文字体 'SimHei',图表中的中文可能显示为乱码。")
    plt.grid(True)
    plt.show()

    # --- 实际切分 ---
    # 这部分现在应该可以正常工作了
    nodes = splitter.get_nodes_from_documents([document])
    print("\n--- 切分结果 ---")
    for i, node in enumerate(nodes):
        print(f"====== 节点 {i+1} (长度: {len(llama_tokenizer(node.get_content()))} tokens) ======")
        print(node.get_content().strip())
        print("-" * 20)

# --- 5. 直接将我们编写的函数传递给 SemanticSplitterNodeParser

# 实验一:使用保守阈值 (95)
print("="*20 + " 实验一:使用保守阈值 (95) " + "="*20)
conservative_splitter = SemanticSplitterNodeParser(
    buffer_size=1, 
    breakpoint_percentile_threshold=95,
    embed_model=Settings.embed_model,
    sentence_splitter=chinese_sentence_tokenizer 
)
plot_similarity_and_chunks(conservative_splitter, "相似度与切分点 (阈值=95%)")


# 实验二:使用激进阈值 (5)
print("\n" + "="*20 + " 实验二:使用激进阈值 (5) " + "="*20)
aggressive_splitter = SemanticSplitterNodeParser(
    buffer_size=1, 
    breakpoint_percentile_threshold=5,
    embed_model=Settings.embed_model,
    sentence_splitter=chinese_sentence_tokenizer 
)
plot_similarity_and_chunks(aggressive_splitter, "相似度与切分点 (阈值=5%)")

滑动窗口+关键词语义切片

|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| 策略类型 | 优点 | 缺点 |
| 滑动窗口 (SentenceSplitter) | 1.上下文保留:通过chunk_overlap确保切块边界的信息不会丢失,有效缓解了"答案在切块边缘"的问题。 2.大小可控:可以大致控制切块的chunk_size,对LLM的上下文窗口友好。 3.速度快:不依赖昂贵的Embedding模型调用,计算成本低。 | 1.语义盲目:完全不理解文本内容,可能在逻辑最紧密的地方强行切分,导致"语义割裂"。 2.冗余度高:产生大量重叠内容,增加了索引的存储成本和检索时的计算量。 |
| 语义切分 (SemanticSplitter) | 1.语义完整性:切块的边界就是语义的边界,每个块都是一个高度内聚的、完整的逻辑单元。 2.信噪比高:提供给LLM的上下文非常"干净",几乎没有无关信息。 3.动态大小:切块大小自适应文本的逻辑结构。 | 1.边界上下文丢失:如果用户的答案恰好需要结合两个语义块边界的信息,可能会因为没有重叠而检索不全。 2.大小不可控:可能产生非常大的语义块,超出LLM能处理的上下文长度限制。 3.成本高:需要为每个句子计算嵌入向量,速度慢且有API调用成本。 |

复制代码
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY")
# --- 自定义混合解析器类 (已更新为带详细打印的版本) ---
class HybridNodeParser(NodeParser):
    primary_parser: NodeParser
    secondary_parser: NodeParser
    max_chunk_size: int = 1024
    tokenizer: Callable = Field(default_factory=get_tokenizer, exclude=True)

    def _parse_nodes(self, documents: List[Document], **kwargs) -> List[Document]:
        print("--- 开始执行【混合切分】... ---")
        
        primary_nodes = self.primary_parser.get_nodes_from_documents(documents)
        print(f"\n{'='*25} 第一步(语义切分)结果 {'='*25}")
        print(f"初步切分出 {len(primary_nodes)} 个语义段落。")
        for i, p_node in enumerate(primary_nodes, 1):
            print(f"\n【原始语义段落 {i}】 (大小: {len(self.tokenizer(p_node.get_content()))} tokens)")
            print("-" * 60)
            print(textwrap.indent(p_node.get_content().strip(), '  '))
            print("-" * 60)

        print(f"\n{'='*25} 第二步(检查与二次切分)过程 {'='*25}")
        final_nodes = []
        for i, node in enumerate(primary_nodes, 1):
            node_size = len(self.tokenizer(node.get_content()))
            print(f"\n>>> 正在检查【原始语义段落 {i}】 (大小: {node_size} tokens)...")
            
            if node_size <= self.max_chunk_size:
                print(f"  └── 结果: 大小合适 (<= {self.max_chunk_size} tokens),直接采纳。")
                final_nodes.append(node)
            else:
                print(f"  └── 结果: 段落过大 (> {self.max_chunk_size} tokens),将使用滑动窗口进行二次切分。")
                print("\n      【即将被切分的原始内容】")
                print("      " + "-"*50)
                print(textwrap.indent(node.get_content().strip(), '      | '))
                print("      " + "-"*50)
                
                sub_nodes = self.secondary_parser.get_nodes_from_documents([Document(text=node.get_content())])
                print(f"\n      【二次切分结果】: 被切分成了 {len(sub_nodes)} 个重叠的子切块。")
                for j, s_node in enumerate(sub_nodes, 1):
                    print(f"\n        【子切块 {i}.{j}】 (大小: {len(self.tokenizer(s_node.get_content()))} tokens)")
                    print("        " + "-"*40)
                    print(textwrap.indent(s_node.get_content().strip(), '        | '))
                    print("        " + "-"*40)

                final_nodes.extend(sub_nodes)
                
        print("\n--- 【混合切分】完成!---")
        return final_nodes

    @classmethod
    def from_defaults(cls, **kwargs):
        raise NotImplementedError("请直接实例化此类,不要使用 from_defaults")

# --- 实例化两个基础的解析器 ---
semantic_parser = SemanticSplitterNodeParser(
    buffer_size=1, 
    breakpoint_percentile_threshold=95,
    sentence_splitter=chinese_sentence_tokenizer,
    embed_model=Settings.embed_model
)
window_parser = SentenceSplitter(
    chunk_size=256,
    chunk_overlap=50
)

# --- 实例化并使用我们的混合解析器  ---
hybrid_parser = HybridNodeParser(
    primary_parser=semantic_parser,
    secondary_parser=window_parser,
    max_chunk_size=300,
    tokenizer=get_tokenizer()
)

# 执行混合切分
final_nodes = hybrid_parser.get_nodes_from_documents([long_document])
相关推荐
weixin_156241575761 天前
基于YOLO深度学习的动物检测与识别系统
人工智能·深度学习·yolo
水如烟1 天前
孤能子视角:“人+AI“孤能子,跨物种自指闭环?以及医学人机接口BMI
人工智能
IT_陈寒1 天前
Python的asyncio把我整不会了,原来问题出在这儿
前端·人工智能·后端
Database_Cool_1 天前
Tair 短期记忆架构实践:淘宝闪购 AI Agent 的秒级响应记忆系统
人工智能·架构
叶舟1 天前
LYT-NET:一个超级轻量的低光照图像增强Transformer网络
人工智能·深度学习·transformer·llie·低光照图像增强
乾元1 天前
《硅基之盾》番外篇二:算力底座的暗战——智算中心 VXLAN/EVPN 架构下的多租户隔离与防御
网络·人工智能·网络安全·架构
ALL_IN_AI1 天前
本地部署 Ollama 大模型:零成本开启 AI 开发之旅
人工智能
木心术11 天前
设备管理网管系统:详细下一步行动指南
前端·人工智能·opencv
小白狮ww1 天前
Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled 蒸馏模型,27B 参数也能做强推理
人工智能·自然语言处理·claude·通义千问·opus·推理·qwen3.5