深入 ‘Context Pruning’:如何利用语义相似度自动剔除记忆中那些不相关的‘废话’以节省 Token

各位同仁,各位对大语言模型(LLM)充满热情的开发者们:

欢迎来到今天的讲座。我们今天将深入探讨一个在LLM应用开发中日益关键且充满挑战的话题——Context Pruning(上下文剪枝),特别是如何巧妙地利用语义相似度来自动剔除记忆中那些不相关的“废话”,从而显著节省Token,优化模型表现。

在LLM的世界里,上下文(Context)是模型的生命线。它决定了模型能够理解多少历史信息、掌握多少背景知识,进而生成多高质量、多相关的回复。然而,上下文并非多多益善,它受限于模型本身的Token限制(Context Window Size),并直接影响成本延迟以及信息过载带来的“幻觉”风险。想象一下,你正在与一个LLM进行深度交流,而它却不得不携带之前数百轮对话的全部信息,以及可能从庞大知识库中检索出的所有细节,其中大部分可能已经与当前话题无关。这就像一个超重行李的旅行者,每一步都沉重而缓慢。

我们的目标,就是成为那位高效的行李整理师,精准识别并丢弃那些不必要的负担,让模型轻装上阵,专注于核心任务。

1. Token困境:为什么上下文剪枝至关重要?

在深入技术细节之前,我们首先要理解为什么上下文管理是LLM应用的核心挑战之一。

  1. Token限制 (Context Window Size): 几乎所有LLM都有一个固定的最大输入Token限制。无论是GPT-3.5的4K、16K,还是GPT-4的8K、32K、128K,亦或是Claude-2的100K,尽管这些窗口在不断扩大,但面对真实世界的复杂应用(如长篇文档分析、多轮对话历史、庞大知识库检索),它们依然捉襟见肘。一旦超出限制,模型就无法处理,或者只能截断输入,导致信息丢失。
  2. 成本 (Cost): 大多数商业LLM(如OpenAI API)是按Token计费的。输入Token越多,费用越高。一个未经优化的上下文管理策略,可能导致你的应用成本飙升。
  3. 延迟 (Latency): 处理更多的Token需要更多的时间。这对于需要实时响应的应用(如聊天机器人、交互式助手)来说是致命的。
  4. 信息过载与“幻觉” (Information Overload & Hallucination): 即使模型能处理大量Token,过多的无关信息反而会稀释真正重要的内容,增加模型混淆甚至产生“幻觉”的风险。模型可能会错误地关联不相关的细节,或者在海量信息中迷失方向,导致输出质量下降。
  5. 模型注意力分散 (Attention Dilution): LLM的注意力机制在处理长上下文时,其有效性会随着上下文长度的增加而下降。越长的上下文,模型越难精确地聚焦到关键信息上。

上下文剪枝,正是为了解决这些问题而生。它旨在智能地选择最相关的上下文,将其以最小的Token量呈现给LLM,从而实现成本、延迟和质量的优化。

2. 定义“无关”:从关键词到语义相似度

传统的信息检索和过滤,往往依赖于关键词匹配。例如,如果用户问“告诉我关于AI的最新进展”,我们可能会在知识库中搜索包含“AI”和“进展”的文档。这种方法简单直接,但存在明显缺陷:

  • 同义词/近义词问题: “人工智能”与“AI”意思相同,但关键词匹配可能无法识别。
  • 多义词问题: “Apple”可能指公司,也可能指水果,关键词匹配无法区分语境。
  • 语义鸿沟: 用户可能用完全不同的词汇表达相同的意图,例如“推荐一些能改善睡眠的APP”和“我想找个助眠软件”。

在LLM时代,我们拥有更强大的工具来理解语言的深层含义——语义相似度。它超越了字面匹配,能够捕捉词语、句子乃至段落之间的意义关联。这正是我们自动剔除“废话”的核心武器。

3. 基础工具与概念:构建语义剪枝的基石

要利用语义相似度进行上下文剪枝,我们需要掌握以下几个关键概念和工具。

3.1. 词向量与嵌入 (Word Embeddings & Sentence Embeddings)

概念: 嵌入(Embeddings)是将文本(词、句子、段落甚至整个文档)转换成固定维度数值向量的技术。这些向量在数学空间中捕捉了文本的语义信息。语义相似的文本在向量空间中距离较近,而语义不相关的文本则距离较远。

工作原理: 预训练的语言模型(如BERT、GPT系列、或专门用于嵌入的Sentence-BERT等)通过大量的文本数据学习,将每个词或句子映射到一个高维向量空间。

常用模型/库:

  • Sentence-BERT (SBERT): 专门设计用于生成高质量句子嵌入,在语义相似度任务上表现出色,且速度快。
  • OpenAI Embeddings API: 易用、效果好,但需要API调用。
  • Hugging Face Transformers: 提供了大量预训练的嵌入模型,可根据需求选择。

代码示例:生成文本嵌入

我们将使用sentence-transformers库来生成嵌入。

from sentence_transformers import SentenceTransformer
import numpy as np

# 1. 加载预训练的嵌入模型
# 'all-MiniLM-L6-v2' 是一个轻量级但效果不错的英文模型
# 对于中文,可以使用 'paraphrase-multilingual-MiniLM-L12-v2' 或更大型号
print("正在加载嵌入模型...")
embedding_model = SentenceTransformer('all-MiniLM-L6-v2') 
print("模型加载完成。")

def get_embedding(text: str) -> np.ndarray:
    """
    生成给定文本的嵌入向量。
    """
    if not text:
        return np.array([])
    # model.encode() 方法会自动处理批处理,但为了演示,这里单次编码
    return embedding_model.encode(text)

# 示例文本
text1 = "The cat sat on the mat."
text2 = "A feline rested on the rug."
text3 = "The dog barked loudly."
text4 = "How to make a delicious pizza at home?"
text5 = "Learn to cook Italian food, especially pizza recipes."

# 生成嵌入
embedding1 = get_embedding(text1)
embedding2 = get_embedding(text2)
embedding3 = get_embedding(text3)
embedding4 = get_embedding(text4)
embedding5 = get_embedding(text5)

print("n嵌入向量维度:", embedding1.shape)
# print("Text 1 Embedding:", embedding1[:5]) # 打印前5个维度作为示例
# print("Text 2 Embedding:", embedding2[:5])

3.2. 相似度度量 (Similarity Metrics)

有了嵌入向量,我们如何量化它们之间的“距离”或“相似度”呢?

  • 余弦相似度 (Cosine Similarity): 这是最常用的语义相似度度量。它计算两个向量之间夹角的余弦值。值越接近1,表示向量方向越一致,语义越相似;值越接近-1,表示语义越相反;值接近0表示不相关。它不关心向量的长度(即文本的绝对强度),只关心它们的方向。

    公式: cos(θ) = (A · B) / (||A|| * ||B||)
    其中,A · B 是向量A和B的点积,||A||||B|| 是它们的欧几里得范数(长度)。

  • 欧几里得距离 (Euclidean Distance): 计算两个向量在多维空间中的直线距离。距离越小,表示越相似。虽然也可以用于相似度,但余弦相似度在文本嵌入领域更为流行,因为它对文本长度不敏感,更侧重于语义方向。

代码示例:计算余弦相似度

from sklearn.metrics.pairwise import cosine_similarity

def calculate_cosine_similarity(embedding_a: np.ndarray, embedding_b: np.ndarray) -> float:
    """
    计算两个嵌入向量的余弦相似度。
    """
    if embedding_a.size == 0 or embedding_b.size == 0:
        return 0.0 # 或者抛出错误,取决于业务逻辑
    # cosine_similarity 函数期望二维数组,所以我们用 .reshape(1, -1)
    return cosine_similarity(embedding_a.reshape(1, -1), embedding_b.reshape(1, -1))[0][0]

# 计算相似度
similarity_cat_feline = calculate_cosine_similarity(embedding1, embedding2)
similarity_cat_dog = calculate_cosine_similarity(embedding1, embedding3)
similarity_pizza_cook = calculate_cosine_similarity(embedding4, embedding5)
similarity_cat_pizza = calculate_cosine_similarity(embedding1, embedding4)

print(f"n'cat sat' vs 'feline rested' 相似度: {similarity_cat_feline:.4f}") # 应该很高
print(f"'cat sat' vs 'dog barked' 相似度: {similarity_cat_dog:.4f}")     # 应该很低
print(f"'make pizza' vs 'cook pizza' 相似度: {similarity_pizza_cook:.4f}") # 应该很高
print(f"'cat sat' vs 'make pizza' 相似度: {similarity_cat_pizza:.4f}")   # 应该很低

3.3. 文本分块策略 (Text Chunking Strategies)

在处理长文本时,我们不能直接对整个文档生成一个嵌入。原因有二:

  1. 嵌入模型的输入限制: 许多嵌入模型也有自己的最大输入长度限制。
  2. 粒度控制: 对整个文档生成一个嵌入,会丢失文档内部的精细语义结构。我们希望能够对文档的各个部分进行独立评估,从而实现更精细的剪枝。

因此,我们需要将长文本分解成更小的、可管理的块(chunks)

常见分块策略:

  • 固定大小分块 (Fixed-size Chunking): 将文本按固定Token数或字符数切分。简单,但可能在句子中间切断。
  • 句子级别分块 (Sentence-based Chunking): 将文本按句子边界切分。保留了语义完整性,但句子长度不一。
  • 递归字符文本分割器 (Recursive Character Text Splitter): 尝试按一系列分隔符(如nn, n, )递归分割,直到块大小满足要求。这是LangChain等框架中常用的高级策略,它尽量避免在语义中间切断。
  • 带重叠分块 (Chunking with Overlap): 在每个块的末尾包含前一个块的一部分内容。这有助于保留跨块的上下文信息,在检索和总结任务中尤其有用。

代码示例:基本文本分块

import re

def split_text_into_chunks(text: str, chunk_size: int = 200, chunk_overlap: int = 50) -> list[str]:
    """
    将文本分割成固定大小(带重叠)的块。
    这是一个简化的实现,实际应用中可以使用更高级的文本分割器。
    """
    # 简单的按标点符号分割,然后尝试合并到指定大小
    sentences = re.split(r'(?<=[.!?])s+', text) # 按句号、问号、叹号分割

    chunks = []
    current_chunk = ""

    for sentence in sentences:
        if len(current_chunk) + len(sentence) + 1 <= chunk_size: # +1 for space
            current_chunk += (" " if current_chunk else "") + sentence
        else:
            if current_chunk:
                chunks.append(current_chunk)
            current_chunk = sentence

    if current_chunk:
        chunks.append(current_chunk)

    # 如果需要,可以进一步处理重叠
    # 这里我们演示一个更直接的字符分割加重叠
    final_chunks = []
    i = 0
    while i < len(text):
        chunk = text[i : i + chunk_size]
        final_chunks.append(chunk)
        if i + chunk_size >= len(text):
            break
        i += chunk_size - chunk_overlap

    return final_chunks

# 示例长文本
long_text = """
人工智能(Artificial Intelligence,简称AI)是研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统的一门新的技术科学。
AI的核心目标是使机器能够像人一样思考、学习、理解、推理、感知和决策。
近年来,深度学习的兴起极大地推动了AI的发展,尤其在图像识别、自然语言处理和语音识别等领域取得了突破性进展。
例如,大型语言模型(LLM)如GPT系列,展现了惊人的文本生成和理解能力。
然而,AI的发展也伴随着诸多挑战,包括数据隐私、算法偏见、伦理道德以及其对社会就业结构的影响。
未来的AI将更加注重可解释性、鲁棒性和通用性,以期更好地服务人类社会。
"""

chunks = split_text_into_chunks(long_text, chunk_size=100, chunk_overlap=20)
print(f"n原始文本分块数量: {len(chunks)}")
# for i, chunk in enumerate(chunks):
#     print(f"Chunk {i+1}: {chunk[:50]}...") # 打印每个块的前50个字符

3.4. “查询”:相关性的北极星

在进行上下文剪枝时,我们总是围绕一个核心点来判断“相关性”——那就是当前的用户查询(User Query)意图(Intent)。这个查询就是我们的“北极星”,指引我们判断哪些信息是当下最需要的。

例如,如果用户的查询是“AI在医疗领域的应用”,那么即使上下文中有一段关于“AI在金融领域的应用”,它相对于查询来说也是“废话”,应该被剪枝。

4. 基于语义相似度的上下文剪枝策略

现在,我们有了基础工具,可以开始构建具体的剪枝策略了。

4.1. 策略一:查询导向的直接剪枝 (Query-Based Pruning)

这是最直观也是最常用的策略。其核心思想是:将上下文中的每个块与当前的查询进行语义相似度比较,只保留相似度高于某个预设阈值的块。

算法步骤:

  1. 嵌入查询: 将用户当前的查询(或对话中的最新消息)转换为嵌入向量。
  2. 分块上下文: 将完整的上下文(例如,对话历史、检索到的文档内容)分割成一系列小的文本块。
  3. 嵌入块: 为每个文本块生成其嵌入向量。
  4. 计算相似度: 计算查询嵌入与每个文本块嵌入之间的余弦相似度。
  5. 过滤: 设定一个相似度阈值(similarity_threshold)。只保留那些相似度超过此阈值的文本块。
  6. 重构上下文: 将过滤后的文本块按照原始顺序(或按相似度排序,取决于需求)拼接起来,形成新的、剪枝后的上下文。

优点:

  • 简单直观,易于实现。
  • 能有效去除与当前查询明显不相关的“噪声”。

缺点:

  • 阈值敏感: 阈值的选择至关重要。过高可能剪掉有用的信息,过低则保留过多无关信息。
  • 可能忽略间接相关信息:如果某个块与查询直接相似度不高,但它是另一个高度相关块的背景或前提,它也可能被剪掉。

代码示例:查询导向的直接剪枝

import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import re

# 假设模型已经加载
# embedding_model = SentenceTransformer('all-MiniLM-L6-v2') 

def get_embedding(text: str) -> np.ndarray:
    """生成给定文本的嵌入向量。"""
    if not text:
        return np.array([])
    return embedding_model.encode(text)

def split_text_into_sentences(text: str) -> list[str]:
    """
    将文本分割成句子。
    """
    # 尽可能保留语义完整性
    sentences = re.split(r'(?<=[.!?。!?])s*', text)
    return [s.strip() for s in sentences if s.strip()]

def query_based_pruning(
    full_context: str, 
    query: str, 
    similarity_threshold: float = 0.5,
    embedding_model: SentenceTransformer = None
) -> str:
    """
    根据查询语义相似度对上下文进行剪枝。
    """
    if not embedding_model:
        raise ValueError("嵌入模型未提供。请确保已加载SentenceTransformer模型。")

    if not full_context:
        return ""

    print(f"n--- 执行查询导向的直接剪枝 ---")
    print(f"原始上下文长度: {len(full_context)} 字符")
    print(f"查询: '{query}'")

    # 1. 嵌入查询
    query_embedding = get_embedding(query)
    if query_embedding.size == 0:
        print("警告: 查询为空或无法嵌入。返回空上下文。")
        return ""

    # 2. 分块上下文 (这里我们选择按句子分块,更细粒度)
    context_chunks = split_text_into_sentences(full_context)
    if not context_chunks:
        print("警告: 上下文为空或无法分块。返回空上下文。")
        return ""
    print(f"上下文被分割成 {len(context_chunks)} 个块 (句子)。")

    # 3. 嵌入块 & 4. 计算相似度 & 5. 过滤
    relevant_chunks_with_scores = []
    for i, chunk in enumerate(context_chunks):
        if not chunk.strip(): # 忽略空块
            continue
        chunk_embedding = get_embedding(chunk)
        if chunk_embedding.size == 0:
            continue

        similarity = calculate_cosine_similarity(query_embedding, chunk_embedding)

        # 调试信息,可以按需开启
        # print(f"Chunk {i+1} ('{chunk[:30]}...'): Similarity={similarity:.4f}")

        if similarity >= similarity_threshold:
            relevant_chunks_with_scores.append((chunk, similarity))

    print(f"通过阈值 {similarity_threshold:.2f} 筛选后,保留了 {len(relevant_chunks_with_scores)} 个相关块。")

    # 6. 重构上下文 (保持原始顺序)
    # 为了保持原始顺序,我们只保留那些被标记为相关的块。
    # 如果需要按相似度排序,可以在这里对 relevant_chunks_with_scores 进行排序。
    pruned_context_chunks = [item[0] for item in relevant_chunks_with_scores]
    pruned_context = " ".join(pruned_context_chunks)

    print(f"剪枝后的上下文长度: {len(pruned_context)} 字符")
    return pruned_context

# 示例使用
if 'embedding_model' not in locals(): # 确保模型已加载
    embedding_model = SentenceTransformer('all-MiniLM-L6-v2') 

full_context_example = """
人工智能(AI)是一门多学科交叉的科学,旨在创建能够模拟人类智能的机器。
它的历史可以追溯到上世纪50年代,图灵测试是其早期的一个重要概念。
近年来,深度学习的兴起极大地推动了AI的发展,特别是在图像识别、自然语言处理和语音识别等领域取得了突破性进展。
例如,大型语言模型(LLM)如GPT系列,展现了惊人的文本生成和理解能力。
AI在医疗健康领域的应用包括辅助诊断、药物研发和个性化治疗。
在金融领域,AI被用于欺诈检测、风险评估和高频交易。
然而,AI的发展也伴随着诸多挑战,包括数据隐私、算法偏见、伦理道德以及其对社会就业结构的影响。
最新的研究表明,AI在环境监测和气候变化预测方面也展现出巨大潜力。
此外,量子计算是另一个前沿科技领域,与AI有潜在的结合点,但目前仍处于早期阶段。
"""

user_query = "AI在健康医疗领域有哪些应用?"
pruned_context_medical = query_based_pruning(full_context_example, user_query, similarity_threshold=0.5, embedding_model=embedding_model)
print("n--- 剪枝后的医疗AI上下文 ---")
print(pruned_context_medical)

user_query_quantum = "量子计算和AI有什么关系?"
pruned_context_quantum = query_based_pruning(full_context_example, user_query_quantum, similarity_threshold=0.5, embedding_model=embedding_model)
print("n--- 剪枝后的量子AI上下文 ---")
print(pruned_context_quantum)

user_query_history = "AI的历史和早期概念是什么?"
pruned_context_history = query_based_pruning(full_context_example, user_query_history, similarity_threshold=0.5, embedding_model=embedding_model)
print("n--- 剪枝后的AI历史上下文 ---")
print(pruned_context_history)

user_query_general = "什么是AI?"
pruned_context_general = query_based_pruning(full_context_example, user_query_general, similarity_threshold=0.5, embedding_model=embedding_model)
print("n--- 剪枝后的通用AI定义上下文 ---")
print(pruned_context_general)

4.2. 策略二:查询重排序剪枝 (Query-Reranking Pruning)

与直接剪枝不同,重排序剪枝不依赖于硬性阈值。它将所有上下文块按与查询的相似度进行排序,然后选择最相关的N个块。

算法步骤:

  1. 嵌入查询: 同策略一。
  2. 分块上下文: 同策略一。
  3. 嵌入块: 同策略一。
  4. 计算相似度: 同策略一,但保留每个块及其对应的相似度分数。
  5. 排序: 根据相似度分数将所有块从高到低进行排序。
  6. 选择Top-N: 选择排序结果中相似度最高的N个块。
  7. 重构上下文: 将选定的N个块拼接起来。通常,我们会按照它们在原始上下文中的顺序进行拼接,以保持逻辑连贯性,除非我们明确希望按相关性顺序呈现。

优点:

  • 固定上下文大小: 保证剪枝后的上下文Token数在一个可控范围内(假设N个块的总Token数不超过限制)。
  • 无需精确阈值: 避免了阈值调优的困难,只需要确定N的大小。

缺点:

  • N的选择: N的大小仍然需要调优。过小可能丢失重要信息,过大则引入不必要的噪音。
  • 可能切断相关链条:如果第N+1个块是理解Top-N中某个块的关键,它也会被丢弃。

代码示例:查询重排序剪枝

def query_reranking_pruning(
    full_context: str, 
    query: str, 
    top_n_chunks: int = 5, # 选择最相关的N个块
    embedding_model: SentenceTransformer = None
) -> str:
    """
    根据查询语义相似度对上下文进行重排序剪枝,选择Top-N个块。
    """
    if not embedding_model:
        raise ValueError("嵌入模型未提供。请确保已加载SentenceTransformer模型。")

    if not full_context:
        return ""

    print(f"n--- 执行查询重排序剪枝 ---")
    print(f"原始上下文长度: {len(full_context)} 字符")
    print(f"查询: '{query}'")

    query_embedding = get_embedding(query)
    if query_embedding.size == 0:
        print("警告: 查询为空或无法嵌入。返回空上下文。")
        return ""

    context_chunks = split_text_into_sentences(full_context)
    if not context_chunks:
        print("警告: 上下文为空或无法分块。返回空上下文。")
        return ""
    print(f"上下文被分割成 {len(context_chunks)} 个块 (句子)。")

    # 存储每个块及其相似度
    chunk_scores = []
    for i, chunk in enumerate(context_chunks):
        if not chunk.strip():
            continue
        chunk_embedding = get_embedding(chunk)
        if chunk_embedding.size == 0:
            continue

        similarity = calculate_cosine_similarity(query_embedding, chunk_embedding)
        chunk_scores.append({"chunk": chunk, "similarity": similarity, "original_index": i}) # 记录原始索引

    # 5. 排序:按相似度从高到低排序
    chunk_scores.sort(key=lambda x: x["similarity"], reverse=True)

    print(f"按相似度排序后,将选择最高的 {top_n_chunks} 个块。")

    # 6. 选择Top-N个块
    selected_chunks_info = chunk_scores[:top_n_chunks]

    # 7. 重构上下文:通常我们希望保持原始的逻辑顺序
    # 所以,将选中的块按照它们在原始上下文中的顺序重新排序
    selected_chunks_info.sort(key=lambda x: x["original_index"])

    pruned_context_chunks = [item["chunk"] for item in selected_chunks_info]
    pruned_context = " ".join(pruned_context_chunks)

    print(f"剪枝后的上下文长度: {len(pruned_context)} 字符")
    return pruned_context

# 示例使用
user_query_rerank = "AI的挑战和未来方向是什么?"
pruned_context_rerank = query_reranking_pruning(full_context_example, user_query_rerank, top_n_chunks=3, embedding_model=embedding_model)
print("n--- 剪枝后的重排序上下文 (AI挑战) ---")
print(pruned_context_rerank)

user_query_rerank_2 = "AI的历史和早期概念是什么?"
pruned_context_rerank_2 = query_reranking_pruning(full_context_example, user_query_rerank_2, top_n_chunks=2, embedding_model=embedding_model)
print("n--- 剪枝后的重排序上下文 (AI历史) ---")
print(pruned_context_rerank_2)

4.3. 策略三:冗余剔除剪枝 (Redundancy-Based Pruning)

在经过查询导向的剪枝后,我们可能会发现保留的块中仍然存在高度重复或语义上几乎相同的“废话”。例如,知识库中可能有多句话以不同措辞表达了同一个事实。冗余剔除旨在识别并移除这些内部重复。

算法步骤:

  1. 初步筛选: 首先,使用查询导向的剪枝(策略一或二)获得一个初步的相关上下文集合。
  2. 块内相似度计算: 对于这个初步筛选出的上下文集合中的每一个块,计算它与其他所有块的语义相似度。
  3. 识别并移除冗余: 设定一个较高的冗余阈值redundancy_threshold,通常高于查询相似度阈值)。如果两个块的相似度超过此阈值,则认为它们是冗余的,可以保留其中一个并移除另一个。通常,我们会保留较早出现的那个块,或者根据某种评分(例如,与查询的相似度更高)来决定。

优点:

  • 提高信息密度: 确保每个保留的块都提供了独特的信息。
  • 进一步节省Token:在初步剪枝的基础上,进一步压缩上下文。

缺点:

  • 计算成本高: 需要进行N*(N-1)/2次相似度计算(其中N是初步筛选后的块数),对于大量块来说计算量较大。
  • 阈值调优复杂: 冗余阈值的设置需要非常谨慎,过高可能保留重复,过低可能误删相似但非冗余的重要信息。

代码示例:冗余剔除剪枝

def redundancy_based_pruning(
    initial_chunks: list[str], 
    redundancy_threshold: float = 0.9, # 较高的阈值来识别近似重复
    embedding_model: SentenceTransformer = None
) -> list[str]:
    """
    从一组初始块中剔除语义冗余的块。
    """
    if not embedding_model:
        raise ValueError("嵌入模型未提供。请确保已加载SentenceTransformer模型。")

    if not initial_chunks:
        return []

    print(f"n--- 执行冗余剔除剪枝 ---")
    print(f"初始块数量: {len(initial_chunks)}")

    # 1. 嵌入所有初始块
    chunk_embeddings = [get_embedding(chunk) for chunk in initial_chunks]

    # 记录哪些块应该被保留
    to_keep_indices = list(range(len(initial_chunks)))

    # 2. 块内相似度计算 & 3. 识别并移除冗余
    for i in range(len(initial_chunks)):
        if i not in to_keep_indices: # 如果当前块已经被标记为移除,则跳过
            continue

        for j in range(i + 1, len(initial_chunks)):
            if j not in to_keep_indices: # 如果后续块已经被标记为移除,则跳过
                continue

            if chunk_embeddings[i].size == 0 or chunk_embeddings[j].size == 0:
                continue

            similarity = calculate_cosine_similarity(chunk_embeddings[i], chunk_embeddings[j])

            # 调试信息
            # print(f"Comparing Chunk {i} ('{initial_chunks[i][:20]}...') vs Chunk {j} ('{initial_chunks[j][:20]}...'): Similarity={similarity:.4f}")

            if similarity >= redundancy_threshold:
                # 标记后续的冗余块为移除
                if j in to_keep_indices:
                    to_keep_indices.remove(j)
                    # print(f"  -> 标记 Chunk {j} 为冗余,将其移除。")

    pruned_chunks = [initial_chunks[i] for i in sorted(to_keep_indices)]

    print(f"冗余剔除后,保留了 {len(pruned_chunks)} 个块。")
    return pruned_chunks

# 组合使用示例
# 假设我们有一个包含一些重复信息的上下文
redundant_full_context = """
人工智能(AI)正在改变世界。AI是一项革命性技术。
AI在医疗健康领域的应用包括辅助诊断、药物研发和个性化治疗。
医生可以使用AI进行诊断。AI也可以帮助医生研发新药。
在金融领域,AI被用于欺诈检测、风险评估和高频交易。
金融机构通过AI检测欺诈。AI还能评估金融风险。
AI的发展也伴随着诸多挑战,例如数据隐私和算法偏见。
"""

user_query_redundant = "AI在医疗和金融领域的应用有哪些?"

# 1. 首先进行查询导向的剪枝 (使用直接剪枝策略)
initial_relevant_context = query_based_pruning(
    redundant_full_context, 
    user_query_redundant, 
    similarity_threshold=0.4, # 适当降低阈值以保留更多潜在相关块
    embedding_model=embedding_model
)
initial_chunks_for_redundancy = split_text_into_sentences(initial_relevant_context)

print("n--- 初步筛选后的块 (准备冗余剔除) ---")
for i, chunk in enumerate(initial_chunks_for_redundancy):
    print(f"Chunk {i+1}: {chunk}")

# 2. 然后进行冗余剔除剪枝
final_pruned_chunks = redundancy_based_pruning(
    initial_chunks_for_redundancy, 
    redundancy_threshold=0.9, 
    embedding_model=embedding_model
)

final_pruned_context = " ".join(final_pruned_chunks)
print("n--- 最终剪枝后的上下文 (经过冗余剔除) ---")
print(final_pruned_context)

4.4. 策略四:图结构剪枝 (Graph-Based Pruning)

这是一种更高级的策略,适用于处理非常复杂、相互关联的上下文,例如大型文档集或复杂的对话图。其核心思想是将上下文块视为图中的节点,块之间的语义相似度作为边的权重,然后利用图算法来识别最重要的信息。

算法步骤(简化):

  1. 分块与嵌入: 将所有上下文分解为块并生成嵌入。
  2. 构建相似度图: 构建一个无向图。每个块是一个节点。如果两个块的相似度超过某个阈值,则在它们之间添加一条边,边的权重可以是相似度分数。
  3. 引入查询: 可以将查询也作为一个节点,并计算其与所有上下文块的相似度,作为查询到这些块的边。
  4. 图算法分析:
    • PageRank-like 算法: 计算每个块在图中的“重要性”分数。与更多重要块相连的块得分更高。
    • 社区检测 (Community Detection): 识别语义上紧密相关的块群。
    • 最短路径/中心性: 找到连接查询和关键信息的最短路径上的块,或者具有高中心性的块。
  5. 选择关键块: 根据图算法得出的分数或结构分析结果,选择最重要的块。
  6. 重构上下文。

优点:

  • 捕捉复杂关系: 能够发现块之间间接的语义关联,而不仅仅是直接的查询相关性。
  • 鲁棒性: 对单个块的噪声或误判具有一定的鲁棒性。

缺点:

  • 实现复杂: 需要专业的图库(如networkx)和图算法知识。
  • 计算成本高: 图的构建和遍历对于大量块来说计算量巨大。
  • 调优困难: 阈值、图算法参数的选择更具挑战性。

代码示例(概念性):图结构剪枝的构建思路

import networkx as nx

def graph_based_pruning_conceptual(
    full_context: str, 
    query: str, 
    similarity_edge_threshold: float = 0.6,
    top_k_nodes: int = 5,
    embedding_model: SentenceTransformer = None
) -> str:
    """
    概念性地展示图结构剪枝的思路。
    此实现是一个简化版本,仅用于说明图的构建和基本PageRank应用。
    """
    if not embedding_model:
        raise ValueError("嵌入模型未提供。请确保已加载SentenceTransformer模型。")

    if not full_context:
        return ""

    print(f"n--- 执行图结构剪枝 (概念性) ---")
    print(f"原始上下文长度: {len(full_context)} 字符")
    print(f"查询: '{query}'")

    # 1. 分块与嵌入
    context_chunks = split_text_into_sentences(full_context)
    if not context_chunks:
        return ""

    chunk_embeddings = [get_embedding(chunk) for chunk in context_chunks]
    query_embedding = get_embedding(query)

    # 2. 构建相似度图
    G = nx.Graph()

    # 添加上下文块作为节点
    for i, chunk in enumerate(context_chunks):
        G.add_node(f"chunk_{i}", text=chunk, type="context")

    # 添加查询作为节点
    G.add_node("query_node", text=query, type="query")

    # 添加上下文块之间的边
    for i in range(len(context_chunks)):
        for j in range(i + 1, len(context_chunks)):
            if chunk_embeddings[i].size == 0 or chunk_embeddings[j].size == 0:
                continue
            similarity = calculate_cosine_similarity(chunk_embeddings[i], chunk_embeddings[j])
            if similarity >= similarity_edge_threshold:
                G.add_edge(f"chunk_{i}", f"chunk_{j}", weight=similarity)

    # 添加查询与上下文块之间的边
    if query_embedding.size > 0:
        for i in range(len(context_chunks)):
            if chunk_embeddings[i].size == 0:
                continue
            similarity = calculate_cosine_similarity(query_embedding, chunk_embeddings[i])
            # 通常查询与块的相似度阈值可以不同,这里简化为与内部块相同
            if similarity >= similarity_edge_threshold: 
                G.add_edge("query_node", f"chunk_{i}", weight=similarity)

    print(f"图构建完成: {G.number_of_nodes()} 节点, {G.number_of_edges()} 边。")

    # 3. 图算法分析 (PageRank)
    # PageRank 计算每个节点的“重要性”,与更多重要节点相连的节点得分更高
    # 我们将查询节点视为一个重要的初始点,或者直接计算所有节点的PageRank

    # 为了简化,我们直接在所有节点上运行PageRank,并关注上下文块的得分
    # 也可以定制PageRank的personalization参数,使其更偏向于查询节点
    try:
        pagerank_scores = nx.pagerank(G, weight='weight')
    except nx.PowerIterationFailedConvergence:
        print("警告: PageRank收敛失败,可能图结构复杂或不连通。尝试选择节点。")
        pagerank_scores = {node: 1.0 for node in G.nodes} # 失败时给默认值

    # 4. 选择关键块
    # 过滤出上下文块的PageRank分数
    context_node_scores = {
        node_id: score 
        for node_id, score in pagerank_scores.items() 
        if node_id.startswith("chunk_")
    }

    # 按分数排序,选择Top-K
    sorted_nodes = sorted(context_node_scores.items(), key=lambda item: item[1], reverse=True)

    selected_chunk_indices = [int(node_id.split('_')[1]) for node_id, _ in sorted_nodes[:top_k_nodes]]

    # 5. 重构上下文 (保持原始顺序)
    selected_chunks_final = [context_chunks[idx] for idx in sorted(selected_chunk_indices)]

    pruned_context = " ".join(selected_chunks_final)

    print(f"通过图分析选择 {len(selected_chunks_final)} 个块。剪枝后的上下文长度: {len(pruned_context)} 字符")
    return pruned_context

# 示例使用
complex_context_example = """
深度学习是机器学习的一个分支,它使用多层神经网络来从数据中学习复杂的模式。
它在图像识别、自然语言处理和语音识别等领域取得了显著成功。
卷积神经网络(CNN)是深度学习中用于图像处理的关键架构。
循环神经网络(RNN)及其变体(如LSTM和GRU)则主要用于序列数据,如文本和时间序列。
Transformer模型,特别是其自注意力机制,彻底改变了自然语言处理领域,是BERT和GPT等大型语言模型的基础。
除了深度学习,强化学习是另一种机器学习范式,通过与环境的交互来学习最优策略。
AlphaGo就是强化学习的一个著名案例。
未来的AI发展趋势包括多模态学习、可解释AI和联邦学习。
"""

user_query_graph = "Transformer模型在深度学习中的作用是什么?"
pruned_context_graph = graph_based_pruning_conceptual(
    complex_context_example, 
    user_query_graph, 
    similarity_edge_threshold=0.5, 
    top_k_nodes=3,
    embedding_model=embedding_model
)
print("n--- 剪枝后的图结构上下文 (Transformer) ---")
print(pruned_context_graph)

5. 实践考量与优化

将这些策略应用于实际场景时,还需要考虑一些重要的实践因素。

5.1. 阈值调优与评估

策略类型 关键参数 调优挑战 影响
直接剪枝 similarity_threshold 过高丢失信息,过低引入噪音 Token节省量、信息完整性
重排序剪枝 top_n_chunks 过小截断相关链条,过大引入无关信息 固定Token预算、信息完整性
冗余剔除 redundancy_threshold 过高保留重复,过低误删相似但非冗余的重要信息 信息密度、Token节省量
图结构剪枝 similarity_edge_threshold, top_k_nodes 复杂度高,参数间相互影响 捕捉复杂关系能力、计算资源消耗

如何调优?

  • A/B测试: 在实际用户流量中测试不同阈值或N值对LLM输出质量、成本和延迟的影响。
  • 人工评估: 随机抽取剪枝前后的上下文,由人工判断剪枝的合理性,是否有关键信息被误删,是否有冗余信息被保留。
  • 代理指标: 对于Q&A系统,可以评估LLM回答的准确性和完整性。对于对话系统,可以评估用户满意度或任务完成率。
  • 领域知识: 结合特定领域的知识,对某些特定类型的上下文块设置不同的优先级或阈值。

5.2. 块大小的重要性

  • 太小: 损失上下文信息。一个单独的句子可能不足以表达完整语义,其嵌入可能不准确。
  • 太大:
    • 可能超出嵌入模型的最大输入长度。
    • 降低剪枝粒度。一个大的块即使包含少量无关信息,但只要整体与查询相关,就会被保留。
    • 嵌入质量可能下降。长文本的嵌入可能会平均化其语义,导致对细微差别的捕捉能力下降。
  • 带重叠的块: 是一种平衡策略,既能保证块内部上下文,又能通过重叠部分维持块之间的连续性。

建议: 通常,一个块包含2-5个句子,或者100-500个Token,并带有10-20%的重叠,是比较平衡的选择。

5.3. 嵌入模型的选择

  • 性能 vs. 成本: OpenAI的text-embedding-ada-002模型效果很好,但需要API调用且有成本。Sentence-BERT系列模型(如all-MiniLM-L6-v2paraphrase-multilingual-MiniLM-L12-v2)可以在本地运行,免费且速度快,效果也相当不错。
  • 语言支持: 确保选择的模型支持你的目标语言(中文、英文或其他)。
  • 领域适应性: 对于特定领域的任务(如法律、医学),可以考虑使用在该领域数据上进行过微调(fine-tuning)的嵌入模型,以获得更精准的语义表示。

5.4. 保护关键信息 (Guardrails)

有些信息是无论如何都不能被剪枝的,例如:

  • 系统提示 (System Prompt): 定义LLM角色、行为的指令。
  • 用户身份/偏好: 长期会话中用户的关键信息。
  • 特定指令: 用户在对话中明确要求遵循的规则。

可以实现一个白名单机制:将这些绝对不能剪枝的块标记出来,在任何剪枝操作中都予以保留。

5.5. 迭代剪枝与混合策略

  • 迭代剪枝: 在长对话中,可以先对整个对话历史进行一次粗粒度剪枝(例如,按轮次),然后对当前轮次相关的上下文进行细粒度剪枝。
  • 混合策略: 将关键词匹配(用于强制保留某些特定词汇的块)与语义相似度剪枝相结合,形成一个更健壮的系统。例如,先用关键词过滤掉明显无关的噪音,再用语义相似度进行精细剪枝。

6. 实现最佳实践

  • 缓存嵌入: 嵌入生成是计算密集型操作。对于频繁访问的静态知识库内容,应将其嵌入预先计算并存储(例如,在向量数据库中),而不是每次都重新计算。对于对话历史,也可以缓存已处理消息的嵌入。
  • 异步处理: 对于大量上下文块,可以异步生成嵌入和计算相似度,以减少延迟。
  • 监控与日志: 记录剪枝前后Token数量、剪枝耗时、以及LLM的响应质量,以便持续优化。
  • 模块化设计: 将分块、嵌入、相似度计算、不同剪枝策略等模块化,便于测试、替换和扩展。

7. 挑战与未来方向

  • 模糊性与细微差别: 语义相似度有时难以捕捉文本中的微妙之处,例如讽刺、双关语或需要大量背景知识才能理解的隐喻。
  • 实时剪枝: 对于流式输入或超长上下文,如何在极低延迟下进行高效剪枝仍然是一个挑战。
  • 多模态上下文: 如何在包含文本、图像、音频等多种模态的上下文中进行剪枝?需要多模态嵌入和相似度度量。
  • 超越语义相似度: 结合更多高级信息,例如:
    • 意图识别: 用户的深层意图可能比表面查询更能指导相关性判断。
    • 话语标记: 识别上下文中的过渡词、总结句等,这些可能指示着重要的信息边界。
    • 因果关系: 理解上下文块之间的因果关系,即使直接相似度不高,也可能因果关联而高度相关。
  • 自适应剪枝: 根据LLM的实时表现、Token预算、用户反馈等动态调整剪枝策略和参数。

8. 更智能、更精简、更快速的LLM

上下文剪枝是构建高效、经济且高质量LLM应用的关键一环。通过利用语义相似度,我们能够摆脱简单的关键词匹配限制,深入理解信息的本质关联,从而精准地识别并剔除那些冗余的“废话”。这不仅能有效应对Token限制和成本压力,更能提升模型的专注度,减少“幻觉”,最终为用户带来更流畅、更智能的交互体验。随着LLM技术的不断演进,上下文管理将继续是研究和创新的前沿领域,而语义剪枝,无疑将是其中一颗闪耀的明星。

发表回复

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