标签: RAG

  • GraphRAG 实体提取的别名局限性分析

    1. 问题概述

    GraphRAG 在实体提取阶段,将同一实体的不同别名视为独立实体,导致知识图谱中出现实体碎片化。以”孙悟空”为例:

    文本A: "孙悟空大闹天宫"        → 实体: 孙悟空
    文本B: "孙行者三打白骨精"      → 实体: 孙行者
    文本C: "齐天大圣被压五行山下"  → 实体: 齐天大圣
    

    最终图谱中出现三个独立节点,它们之间没有任何关联。查询”孙悟空做了什么”时,只能找到”大闹天宫”,而”三打白骨精”和”被压五行山下”的关系链完全断裂。

    2. 当前 GraphRAG 的处理方式

    2.1 实体提取阶段

    源码: packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py

    LLM 从每个 text unit 中提取实体,核心逻辑:

    # graph_extractor.py → _process_result()
    if record_type == '"entity"' and len(record_attributes) >= 4:
        entity_name = clean_str(record_attributes[1].upper())  # 名称统一大写
        entity_type = clean_str(record_attributes[2].upper())
        entity_description = clean_str(record_attributes[3])
        entities.append({
            "title": entity_name,
            "type": entity_type,
            "description": entity_description,
            "source_id": source_id,
        })
    

    关键点:
    – 实体名称仅做 clean_str() + upper() 处理(去除 HTML 转义和控制字符,转大写)
    没有任何别名识别或归一化逻辑
    – LLM 提取什么名字,就原样记录什么名字

    2.2 实体合并阶段

    源码: packages/graphrag/graphrag/index/operations/extract_graph/extract_graph.py

    多个 text unit 提取的实体通过 _merge_entities() 合并:

    def _merge_entities(entity_dfs) -> pd.DataFrame:
        all_entities = pd.concat(entity_dfs, ignore_index=True)
        return (
            all_entities
            .groupby(["title", "type"], sort=False)  # ← 仅按 title + type 分组
            .agg(
                description=("description", list),
                text_unit_ids=("source_id", list),
                frequency=("source_id", "count"),
            )
            .reset_index()
        )
    

    合并策略是 精确字符串匹配:只有 title(名称)和 type(类型)完全相同的实体才会被合并。

    这意味着:
    孙悟空孙行者不合并(title 不同)
    SUN WUKONGMONKEY KING不合并
    TechGlobalTG不合并(缩写 vs 全称)

    2.3 描述摘要阶段

    源码: packages/graphrag/graphrag/index/operations/summarize_descriptions/

    合并后,同一实体(title 相同)的多条 description 会通过 LLM 汇总为一条:

    # description_summary_extractor.py
    async def __call__(self, id, descriptions):
        if len(descriptions) == 0:
            result = ""
        elif len(descriptions) == 1:
            result = descriptions[0]  # 只有一条描述,直接使用
        else:
            result = await self._summarize_descriptions(id, descriptions)  # 多条描述,LLM 汇总
    

    摘要 prompt 的设计:

    Given one or more entities, and a list of descriptions, all related to the same entity
    or group of entities. Please concatenate all of these into a single, comprehensive
    description. If the provided descriptions are contradictory, please resolve the
    contradictions and provide a single, coherent summary.
    

    问题:这个摘要步骤只处理已经被 _merge_entities() 合并到一起的描述。由于别名实体根本没有被合并,它们的描述永远不会被放在一起摘要。

    2.4 关系的连带断裂

    源码: extract_graph.py → _merge_relationships()

    def _merge_relationships(relationship_dfs) -> pd.DataFrame:
        all_relationships = pd.concat(relationship_dfs, ignore_index=False)
        return (
            all_relationships
            .groupby(["source", "target"], sort=False)  # ← 按 source + target 精确匹配
            .agg(
                description=("description", list),
                text_unit_ids=("source_id", list),
                weight=("weight", "sum"),
            )
            .reset_index()
        )
    

    关系合并同样依赖精确字符串匹配。假设:

    文本A 提取: (孙悟空) --师徒--> (唐僧)
    文本B 提取: (孙行者) --师徒--> (唐僧)
    

    这两条关系 不会合并,因为 source 不同。最终图谱中:
    孙悟空 → 唐僧 (weight=1)
    孙行者 → 唐僧 (weight=1)

    而正确的结果应该是一条 weight=2 的关系。更严重的是,如果某些关系只出现在别名上下文中,查询主名称时完全找不到。

    2.5 查询阶段的影响

    源码: packages/graphrag/graphrag/query/context_builder/entity_extraction.py

    查询时通过 embedding 向量相似度匹配实体:

    def map_query_to_entities(query, text_embedding_vectorstore, text_embedder, ...):
        search_results = text_embedding_vectorstore.similarity_search_by_text(
            text=query,
            text_embedder=lambda t: text_embedder.embedding(input=[t]).first_embedding,
            k=k * oversample_scaler,
        )
    

    查询”孙悟空”时,embedding 相似度可能匹配到”孙悟空”节点,但”孙行者”和”齐天大圣”节点的 embedding 距离较远,可能不在 top-k 结果中。即使匹配到了,它们作为独立节点,各自的关系子图也是割裂的。

    3. 根本原因总结

    环节 当前行为 问题
    LLM 提取 按文本中出现的名称原样提取 不同别名产生不同 entity title
    实体合并 groupby(["title", "type"]) 精确匹配 别名实体无法合并
    描述摘要 只摘要已合并实体的描述 别名实体的描述永远分离
    关系合并 groupby(["source", "target"]) 精确匹配 别名导致关系碎片化
    查询匹配 embedding 相似度搜索 别名节点可能不在 top-k 中
    Prompt 无别名识别指令 LLM 没有被引导去统一别名

    4. 解决方案:LLM 别名发现 + 外部知识库确定性合并

    整体思路:两层保障。LLM 在提取时发现别名关系,覆盖大部分情况;外部知识库对特别关心的实体提供确定性兜底,确保关键实体不会因 LLM 不一致而遗漏。

    4.1 整体流程

                        ┌─────────────────────┐
                        │  外部别名知识库       │
                        │  (JSON/DB, 人工维护)  │
                        └──────────┬──────────┘
                                   │ 加载
                                   ▼
    text units ──→ LLM 提取(含aliases) ──→ 别名归一化 ──→ _merge_entities ──→ 后续流程
                                             ▲
                                             │
                                  ┌──────────┴──────────┐
                                  │ 1. 外部知识库优先匹配  │
                                  │ 2. LLM aliases 补充   │
                                  └─────────────────────┘
    

    4.2 外部别名知识库

    用户维护一份别名映射文件,定义特别关心的实体的 canonical name 和所有已知别名:

    // alias_kb.json
    [
      {
        "canonical": "孙悟空",
        "aliases": ["孙行者", "齐天大圣", "美猴王", "斗战胜佛"]
      },
      {
        "canonical": "猪八戒",
        "aliases": ["天蓬元帅", "猪悟能", "猪刚鬣", "呆子", "二师兄"]
      }
    ]
    

    特点:
    确定性:知识库中的映射是硬规则,不依赖 LLM 判断,100% 保证合并
    可控:只需覆盖业务上特别关心的实体,不需要穷举所有实体
    可增量维护:发现新的漏合并时,加一条记录即可

    4.3 LLM 提取阶段增加 aliases 字段

    修改提取 prompt(prompts/index/extract_graph.py),让 LLM 在提取实体时同时输出别名:

    1. Identify all entities. For each identified entity, extract the following information:
    - entity_name: Name of the entity, capitalized
    - entity_type: One of the following types: [{entity_types}]
    - entity_description: Comprehensive description of the entity's attributes and activities
    - aliases: Other names, abbreviations, or titles for this entity found in the text.
      If none, leave empty.
    Format each entity as ("entity"<|><entity_name><|><entity_type><|><entity_description><|><aliases>)
    

    graph_extractor.py_process_result() 中解析 aliases:

    if record_type == '"entity"' and len(record_attributes) >= 4:
        entity_name = clean_str(record_attributes[1].upper())
        entity_type = clean_str(record_attributes[2].upper())
        entity_description = clean_str(record_attributes[3])
        aliases = []
        if len(record_attributes) >= 5:
            aliases = [clean_str(a.upper()) for a in record_attributes[4].split(",") if a.strip()]
        entities.append({
            "title": entity_name,
            "type": entity_type,
            "description": entity_description,
            "source_id": source_id,
            "aliases": aliases,
        })
    

    LLM 发现的别名覆盖外部知识库未收录的长尾情况。例如文本中出现”猴哥”指代孙悟空,知识库没收录,但 LLM 能识别并输出 aliases: 猴哥

    4.4 别名归一化:两层合并

    _merge_entities() 之前,先构建统一的 alias → canonical 映射,外部知识库优先:

    def _build_alias_map(entity_dfs, alias_kb_path=None):
        """构建 alias → canonical 映射。外部知识库优先,LLM aliases 补充。"""
        alias_to_canonical = {}
    
        # 第一层:外部知识库(确定性,优先级最高)
        if alias_kb_path:
            import json
            with open(alias_kb_path) as f:
                kb_entries = json.load(f)
            for entry in kb_entries:
                canonical = entry["canonical"].upper()
                for alias in entry["aliases"]:
                    alias_to_canonical[alias.upper()] = canonical
    
        # 第二层:LLM 提取的 aliases(补充知识库未覆盖的)
        all_entities = pd.concat(entity_dfs, ignore_index=True)
        name_freq = all_entities["title"].value_counts()
    
        for _, row in all_entities.iterrows():
            title = row["title"]
            for alias in row.get("aliases", []):
                if not alias or alias == title:
                    continue
                # 外部知识库已有映射的,不覆盖
                if alias in alias_to_canonical or title in alias_to_canonical:
                    continue
                # LLM aliases:频率高的作为 canonical
                if name_freq.get(alias, 0) > name_freq.get(title, 0):
                    alias_to_canonical[title] = alias
                else:
                    alias_to_canonical[alias] = title
    
        # 传递闭包:A→B, B→C 则 A→C
        def resolve(name):
            visited = set()
            while name in alias_to_canonical and name not in visited:
                visited.add(name)
                name = alias_to_canonical[name]
            return name
    
        return resolve
    

    extract_graph() 中,合并前统一重写实体和关系中的名称:

    async def extract_graph(...) -> tuple[pd.DataFrame, pd.DataFrame]:
        # ... LLM 提取 ...
        results = await derive_from_rows(...)
    
        entity_dfs = [r[0] for r in results if r]
        relationship_dfs = [r[1] for r in results if r]
    
        # 别名归一化(新增)
        resolve = _build_alias_map(entity_dfs, alias_kb_path=config.alias_kb_path)
        for df in entity_dfs:
            df["title"] = df["title"].map(resolve)
        for df in relationship_dfs:
            df["source"] = df["source"].map(resolve)
            df["target"] = df["target"].map(resolve)
    
        # 原有合并逻辑(现在能正确合并别名实体)
        entities = _merge_entities(entity_dfs)
        relationships = _merge_relationships(relationship_dfs)
        relationships = filter_orphan_relationships(relationships, entities)
    
        return (entities, relationships)
    

    4.5 效果对比

    以”孙悟空”为例:

    场景 当前行为 改进后
    文本A提到”孙悟空”,文本B提到”孙行者” 两个独立节点,关系断裂 外部知识库命中,统一为”孙悟空”
    文本C提到”猴哥”(知识库未收录) 独立节点 LLM aliases 发现,归一化到”孙悟空”
    文本D提到”天蓬元帅”(知识库有) 独立节点 外部知识库命中,统一为”猪八戒”
    文本E提到某个冷门缩写(两层都没覆盖) 独立节点 仍为独立节点,发现后加入知识库即可

    6. 源码文件索引

    文件 作用
    prompts/index/extract_graph.py 实体提取 prompt 定义
    index/operations/extract_graph/graph_extractor.py LLM 提取结果解析,实体/关系构建
    index/operations/extract_graph/extract_graph.py 实体/关系合并逻辑(_merge_entities, _merge_relationships
    index/operations/extract_graph/utils.py 孤儿关系过滤
    index/operations/summarize_descriptions/ 描述摘要(仅处理已合并实体)
    index/workflows/extract_graph.py 提取 workflow 编排
    index/workflows/finalize_graph.py 图谱最终化(degree 计算、去重)
    index/operations/finalize_entities.py 实体最终化(按 title 去重)
    query/context_builder/entity_extraction.py 查询时实体匹配
    index/utils/string.py clean_str() 字符串清洗
    prompt_tune/template/extract_graph.py 可调优的提取 prompt 模板
  • Semantic Chunk 为什么需要 Embedding API

    固定长度分块不需要任何外部服务,语义分块却必须调用 Embedding API——这背后的原因是什么?

    先说结论

    Semantic chunking 的核心思想是在语义边界处切分文本。判断”两段文字是否属于同一个话题”需要将文本转换为向量后计算相似度——这就是 Embedding API 不可或缺的原因。

    传统分块 vs 语义分块

    维度 固定长度 / 递归分块 语义分块
    切分依据 字符数、token 数、分隔符 相邻句子的语义相似度
    是否需要 Embedding ❌ 不需要 ✅ 必须
    切分质量 可能在话题中间断开 在话题转换处切分,保持语义完整

    固定长度分块就像用尺子量纸——不管内容写了什么,到了 500 字就剪一刀。语义分块则像一个读者,读完一段后判断”下一段是不是在说同一件事”,如果不是,就在这里切开。

    两种主流语义分块策略

    策略一:相邻相似度法(Kamradt 方法)

    核心思路:计算相邻句子之间的语义距离,在距离突变处切分。

    流程:
    1. 将文本切成小句子
    2. 为每个句子拼接前后 buffer_size 个句子作为上下文
    3. 调用 Embedding API 获取每个组合句子的向量
    4. 计算相邻句子向量的余弦距离
    5. 通过二分搜索找到阈值,在距离超过阈值的位置切分
    

    伪代码:

    # Step 1: 拼接上下文窗口
    for i, sentence in enumerate(sentences):
        combined = ""
        for j in range(max(0, i - buffer_size), i):
            combined += sentences[j] + " "
        combined += sentence
        for j in range(i + 1, min(n, i + 1 + buffer_size)):
            combined += " " + sentences[j]
        combined_texts.append(combined)
    
    # Step 2: 获取所有组合句子的 embedding(一次批量调用)
    embeddings = embedding_client.embed_texts(combined_texts)
    embedding_matrix = np.array(embeddings)
    
    # Step 3: 只计算相邻句子间的余弦距离
    distances = []
    for i in range(len(sentences) - 1):
        similarity = dot(embedding_matrix[i], embedding_matrix[i + 1])
        distances.append(1 - similarity)  # 距离越大 = 话题差异越大
    
    # Step 4: 二分搜索找阈值,使切分数量接近 total_size / avg_chunk_size
    threshold = binary_search_threshold(distances, target_cuts)
    
    # Step 5: 在距离超过阈值的位置切分
    breakpoints = [i for i, d in enumerate(distances) if d > threshold]
    

    直觉理解:想象你在读一篇文章,每读完一句就问自己”这句和下一句是不是在说同一件事?”。如果突然觉得话题跳了,就在这里切一刀。

    关键特征:只看相邻关系。 它只计算 sentence[i] 和 sentence[i+1] 之间的距离,是一种局部贪心策略。

    策略二:聚类最优分割法(动态规划方法)

    核心思路:构建所有句子对之间的相似度矩阵,用动态规划找到使簇内相似度总和最大的最优分割。

    流程:
    1. 将文本切成小句子
    2. 调用 Embedding API 获取所有句子的向量
    3. 构建 N×N 相似度矩阵
    4. 对矩阵做均值归一化(防止退化为单一大簇)
    5. 用动态规划找到最优分割方案
    

    伪代码:

    # Step 1: 获取所有句子的 embedding(注意:没有 buffer 拼接)
    embeddings = embedding_client.embed_texts(sentences)
    embedding_matrix = np.array(embeddings)
    
    # Step 2: 构建 N×N 相似度矩阵
    similarity_matrix = dot(embedding_matrix, embedding_matrix.T)
    
    # Step 3: 均值归一化,防止 DP 退化为"全部放一个簇"
    mean_sim = mean(upper_triangle(similarity_matrix))
    similarity_matrix -= mean_sim
    fill_diagonal(similarity_matrix, 0)
    
    # Step 4: 动态规划寻找最优切分
    # dp[i] = 前 i+1 个句子的最大簇内相似度总和
    for i in range(n):
        for size in range(1, i + 2):
            start = i - size + 1
            if cluster_size(start, i) > max_chunk_size and size > 1:
                break
            reward = sum(similarity_matrix[start:i+1, start:i+1])
            if start > 0:
                reward += dp[start - 1]
            dp[i] = max(dp[i], reward)
    
    # Step 5: 回溯得到最优分割
    clusters = backtrack(segmentation)
    

    关键特征:全局最优。 它考虑所有句子对之间的关系,通过 DP 找到整体最优的分割方案。

    两种策略的深度对比

    算法本质差异

    维度 Kamradt(相邻相似度) Cluster(动态规划)
    视野 局部——只看相邻句子 全局——看所有句子对
    决策方式 贪心:距离超阈值就切 最优化:最大化簇内总相似度
    阈值确定 二分搜索目标切分数 无需阈值,DP 自动决定
    上下文增强 ✅ 有 buffer_size 拼接 ❌ 直接用原始句子
    大小约束 avg_chunk_size + max_chunk_size 双重约束 max_chunk_size 硬约束

    核心区别用一句话概括:

    • Kamradt 问的是:”这两个相邻句子之间是否存在话题跳转?”
    • Cluster 问的是:”哪种分组方式能让每组内部的句子最相似?”

    一个直观的例子

    假设有 6 个句子,话题分布如下:

    句子1: 讨论苹果公司的财报
    句子2: 讨论苹果公司的新产品
    句子3: 讨论天气预报
    句子4: 讨论明天的气温
    句子5: 讨论苹果公司的股价
    句子6: 讨论苹果公司的竞争对手
    

    Kamradt 的切法: 逐对比较相邻距离
    – 句子2→3:话题跳转(苹果→天气),切!
    – 句子4→5:话题跳转(天气→苹果),切!
    – 结果:[1,2] [3,4] [5,6]

    Cluster 的切法: 全局相似度矩阵显示 1,2,5,6 彼此高度相似
    – 但由于 DP 要求连续分割(不能跳着分组),它仍然只能切连续片段
    – 结果可能也是 [1,2] [3,4] [5,6],但决策依据不同

    关键差异出现在边界模糊的情况:

    考虑一篇从”电动车技术”渐变到”能源政策”的文章:

    句子1: 特斯拉发布了新一代电池技术
    句子2: 新电池的能量密度提升了 50%
    句子3: 更高的能量密度意味着更长的续航里程
    句子4: 续航焦虑一直是消费者购买电动车的障碍
    句子5: 政府为缓解这一问题推出了充电桩补贴政策
    句子6: 补贴政策同时覆盖了家用和商用充电设施
    句子7: 商用充电设施的电价采用峰谷分时定价
    句子8: 分时电价机制是电力市场化改革的重要组成部分
    

    Kamradt 看到的(相邻距离):

    1→2: 0.08  (都在说电池)
    2→3: 0.10  (电池→续航,很近)
    3→4: 0.12  (续航→续航焦虑,很近)
    4→5: 0.15  (消费者→政府政策,稍远但不突出)
    5→6: 0.09  (都在说补贴)
    6→7: 0.13  (补贴→电价,有点远)
    7→8: 0.11  (都在说电价)
    

    没有任何一个距离明显”跳起来”——话题是一步步滑过去的。Kamradt 的二分搜索很难找到一个合理的阈值,可能切出 [1-4][5-8] 或 [1-3][4-6][7-8] 这样不太理想的结果。

    Cluster 看到的(全局相似度矩阵摘要):

            句1   句2   句3   句4   句5   句6   句7   句8
    句1     --   0.9   0.7   0.4   0.2   0.1   0.1   0.05
    句2          --    0.8   0.5   0.2   0.15  0.1   0.05
    句3                --    0.6   0.3   0.2   0.15  0.1
    句4                      --    0.5   0.4   0.3   0.2
    句5                            --    0.8   0.6   0.4
    句6                                  --    0.7   0.5
    句7                                        --    0.8
    句8                                              --
    

    全局视角清晰地显示:句子 1-3 彼此高度相似(电池/续航技术),句子 5-8 彼此高度相似(政策/电价),句子 4 是过渡句。DP 优化会发现 [1-3][4-8] 或 [1-4][5-8] 的簇内总相似度最大,从而做出更合理的切分。

    本质区别: Kamradt 只看”相邻两句之间的落差”,渐变过渡中每一步落差都很小,就像温水煮青蛙;Cluster 看”整组句子之间的整体相似度”,即使过渡平滑,它也能发现句子 1 和句子 8 之间其实已经毫无关系了。

    Embedding 开销对比

    这是两种策略最重要的实际差异之一:

    维度 Kamradt Cluster
    Embedding 输入 combined_sentence(含 buffer 上下文) 原始句子(无 buffer)
    Embedding 调用次数 N 个文本,1 次批量调用 N 个文本,1 次批量调用
    每个文本的平均长度 较长(~7 句,buffer_size=3) 较短(1 句)
    总 token 消耗 较高(buffer 导致输入膨胀) 较低(无冗余)
    后续计算开销 O(N)——只算相邻距离 O(N²)——构建完整相似度矩阵
    DP 计算开销 O(N × max_cluster_size)

    具体数字对比(假设 1000 个句子,平均每句 30 tokens)

    Kamradt:
    – Embedding 输入:1000 个 combined_sentence,每个约 7×30 = 210 tokens
    – 总 token 消耗:1000 × 210 = 210,000 tokens
    – 距离计算:999 次点积 → 可忽略
    – 内存:1000 × embedding_dim 的矩阵

    Cluster:
    – Embedding 输入:1000 个原始句子,每个约 30 tokens
    – 总 token 消耗:1000 × 30 = 30,000 tokens
    – 相似度矩阵:1000 × 1000 = 100 万个浮点数(约 8MB)
    – DP 计算:O(1000 × max_cluster_size) 次循环

    结论:
    Embedding API 费用:Kamradt 消耗约 7 倍 token(因为 buffer 拼接),API 成本更高
    计算资源:Cluster 的 O(N²) 矩阵和 DP 在本地 CPU/内存上开销更大
    网络延迟:两者相同(都是 1 次批量调用,或按 batch_size 分多次)

    大规模场景(10 万句子)

    指标 Kamradt Cluster
    Embedding token 总量 ~2100 万 tokens ~300 万 tokens
    API 调用次数(batch_size=500) 200 次 200 次
    相似度计算 99,999 次点积 100 亿次点积(N²矩阵)
    内存占用 ~400MB(embedding 矩阵) ~40GB(N²相似度矩阵)⚠️

    10 万句子时 Cluster 策略的 N² 矩阵会爆内存,这是它的硬伤。实际使用中,Cluster 策略更适合中等长度文档(几百到几千句子),而 Kamradt 可以处理任意长度。

    切分质量对比

    场景 Kamradt 表现 Cluster 表现
    话题边界清晰 ✅ 优秀,距离突变明显 ✅ 优秀
    话题渐变过渡 ⚠️ 可能找不到切点 ✅ 全局优化仍能找到最佳分割
    短文档(<50 句) ✅ 快速 ✅ 质量更高
    长文档(>1 万句) ✅ 线性扩展 ❌ 内存爆炸
    句子很短 ⚠️ 需要 buffer 补充上下文 ⚠️ 短句 embedding 质量差

    如何选择?

    你的场景 推荐策略 原因
    文档长度不确定,需要通用方案 Kamradt 线性复杂度,不会爆内存
    文档较短(<2000 句),追求最优切分 Cluster 全局最优,质量更高
    Embedding API 按 token 计费 Cluster 无 buffer 膨胀,token 消耗低 7 倍
    本地计算资源有限 Kamradt O(N) 计算,内存友好
    话题边界模糊,需要精确切分 Cluster DP 全局优化更鲁棒

    为什么不能用其他方法替代 Embedding?

    替代方案 问题
    关键词重叠 / TF-IDF 无法捕捉同义词和上下文语义(”汽车”和”车辆”会被认为不相关)
    规则分隔符(段落、句号) 同一段落可能包含多个话题,不同段落可能讨论同一话题
    LLM 直接判断 成本过高,延迟大,不适合批量处理数万句子

    Embedding 将文本映射到高维语义空间,语义相近的文本向量距离小,语义不同的文本向量距离大。这是目前在成本、速度、质量之间最优的语义相似度度量方式。

    buffer_size:上下文窗口的作用

    语义分块中有一个关键参数 buffer_size(默认值 3),它决定了为每个句子生成 embedding 时拼接多少上下文。

    # 拼接逻辑示意
    for each sentence[i]:
        combined = sentence[i-3] + sentence[i-2] + sentence[i-1]  # 前 3 句
                  + sentence[i]                                     # 当前句
                  + sentence[i+1] + sentence[i+2] + sentence[i+3]  # 后 3 句
    

    关键点:buffer_size 不影响 Embedding 调用次数,只影响每次输入的文本长度。

    以 10 个句子为例,无论 buffer_size 是 1 还是 10,都是对 10 个 combined_sentence 做 embedding。区别在于每个文本包含的上下文多少:

    buffer_size 每个文本平均包含句子数 效果
    1 ~3 句 上下文少,可能误判
    3(默认) ~7 句 平衡点
    10 ~21 句 上下文丰富,但可能超出模型 token 限制

    注意:Embedding 模型有输入长度上限(如 BGE-M3 最大 8192 tokens)。buffer_size 太大会导致文本被截断,反而丢失当前句子的信息。

    大规模场景下的性能考量

    假设一篇长文档被切成 10 万个句子:

    • 需要 embed 的文本数 = 100,000 个
    • 如果 batch_size 配置为 500,实际 API 调用次数 = 100,000 ÷ 500 = 200 次 HTTP 请求

    性能瓶颈在 API 调用次数(由句子总数和 batch_size 决定),与 buffer_size 无关。

    降级策略:Embedding 不可用时怎么办?

    好的系统设计应该考虑 Embedding 服务不可用的情况。常见做法是:当 Embedding 调用失败时,自动降级为递归分块策略(纯规则分块,不需要 Embedding)。

    这意味着语义分块是一种增强而非依赖——没有 Embedding 服务时系统仍然可以工作,只是切分质量会下降。

    总结

    问题 答案
    为什么需要 Embedding? 判断语义相似度需要向量表示
    能否用规则替代? 不能,规则无法捕捉语义
    能否用 LLM 替代? 理论上可以,但成本和延迟不可接受
    Kamradt vs Cluster 核心区别? 局部相邻比较 vs 全局最优分割
    哪个 Embedding 开销更大? Kamradt token 消耗高(buffer 膨胀),Cluster 计算开销高(N²矩阵)
    大规模文档选哪个? Kamradt——线性复杂度,不会爆内存
    追求最优切分选哪个? Cluster——全局 DP 优化,但限中等长度文档
    服务不可用怎么办? 两者都降级为规则分块

    Embedding API 是语义分块的”眼睛”——没有它,分块算法就是一个盲人在切蛋糕。两种策略用不同的方式”看”文本:Kamradt 像逐行扫描的阅读器,Cluster 像俯瞰全文的编辑。选择哪种,取决于你的文档规模和对切分质量的要求。

  • GraphRAG 实战最大的坑:一个实体,七种身份

    当你以为 GraphRAG 最难的是”建图”,实际上最难的是”给实体定类型”——哪怕你已经预定义了严格的类型 schema。

    一、先看一组真实数据

    我们拿 3GPP TS 23.502(5G 核心网信令流程规范)跑了一次 GraphRAG 的实体抽取。这份文档大约 700 多页,是电信领域最核心的标准之一。

    结果让人头疼:

    • 总共抽出 8873 个不同的实体(按 title 去重后)
    • 其中 1123 个实体被分配了 2 种以上的 type——占比 12.7%
    • 最夸张的实体 PMIC,被分成了 7 种不同的 typeARCHITECTURE_CONCEPTDATA_TYPEINFORMATION_ELEMENTMANAGEMENT_ENTITYNETWORK_ELEMENTPROCEDUREPROTOCOL

    注意,这次实验已经预定义了严格的实体类型 schema,在 prompt 中明确约束 LLM 只能使用指定的类型集合。也就是说,这不是”没给约束导致的混乱”,而是给了约束之后依然控制不住的混乱

    更要命的是,这些”类型冲突”不是发生在不同文档之间,而是在同一个文档里甚至在同一个片段里。LLM 在读最小切分的一段文字时,即便有明确的类型约束,对同一个实体仍然给出了不同的类型判断。

    我们发现了 63 条 text_unit 级别的重叠冲突——同一个实体在同一个文本块中被标注为两种不同的 type。比如:

    实体 被标为 也被标为
    AF ORGANIZATION NETWORK_FUNCTION
    NRF INTERFACE NETWORK_FUNCTION
    5G SECURITY CONTEXT SECURITY_ELEMENT ARCHITECTURE_CONCEPT
    HPLMN NETWORK_FUNCTION ORGANIZATION
    SERVICE REQUEST INFORMATION_ELEMENT PROCEDURE

    这不是 LLM 犯了低级错误,也不是 schema 设计得不好。你仔细想想,AF(Application Function)确实既是一个”网络功能”,也是一个”组织角色”;NRF 既是一个”网络功能”,也暴露了”接口”。这些类型都在我们预定义的 schema 里,LLM 每次选的都是”合法”的类型——只是对同一个实体选了不同的合法类型。问题不在于 LLM 判断错了,也不在于 schema 不够严格,而在于现实世界的实体本来就不是单一类型的。

    二、为什么这个问题这么难?

    2.1 实体天然具有多面性

    在 3GPP 规范里,AMF(Access and Mobility Management Function)这个词:

    • 在架构图里,它是一个 NETWORK_FUNCTION
    • 在信令流程里,它是一个 PROCEDURE 的参与者
    • 在部署描述里,它是一个 NETWORK_ELEMENT
    • 在接口定义里,它是一个 INTERFACE 的端点

    同一个实体在不同上下文中扮演不同角色,这不是 bug,这是现实。

    2.2 LLM 的类型判断依赖上下文窗口

    GraphRAG 的实体抽取是逐 chunk 进行的。每个 text_unit 大约几百个 token,LLM 只能看到这一小段文字。

    同一个实体 PDU SESSION ESTABLISHMENT
    – 在描述信令流程的 chunk 里,LLM 判断它是 PROCEDURE
    – 在描述消息格式的 chunk 里,LLM 判断它是 INFORMATION_ELEMENT

    两个判断都对,但合并到知识图谱时就冲突了。

    2.3 哪怕 schema 设计得再好,类型边界本身就是模糊的

    我们已经预定义了类型 schema,但谁来定义 ARCHITECTURE_CONCEPTNETWORK_FUNCTION 的边界?在 3GPP 的语境里,很多概念天然横跨多个类别。POLICY CONTROL 既是一个”过程”(PROCEDURE),也是一个”架构概念”(ARCHITECTURE_CONCEPT)——这两个类型都在我们的 schema 里,LLM 选哪个都不算错。

    这不是 prompt 写得不好的问题,也不是 schema 定义不够精确的问题,而是类型体系的粒度和现实世界的复杂度之间存在根本性的张力。你可以把 schema 定义得更细,但更细的 schema 只会让边界问题更多,不会更少。

    2.4 规模放大了问题

    我们的数据显示,拥有多个 type 的实体中,Top 20 的实体平均有 4-7 种 type,平均关联 10-200 条 description。像 AF 这样的核心实体,有 209 条 description、192 个 text_unit 引用、4 种 type。

    当知识图谱里有上千个这样的”多面实体”时,下游的社区检测、关系推理、摘要生成都会受到影响——因为图的结构被类型噪声污染了。

    三、业界目前怎么解决?

    方案一:预定义严格的类型体系(Schema-First)⚠️ 我们已经试过了

    做法:在抽取之前,人工定义一套严格的实体类型 schema,并在 prompt 中明确约束 LLM 只能使用这些类型。

    代表:微软 GraphRAG 的默认配置、大多数企业级知识图谱项目。

    我们的实际结果:本文开头的所有数据,就是在 Schema-First 模式下跑出来的。我们已经预定义了类型集合,prompt 里也明确约束了——但 1123 个实体仍然出现了多类型冲突,63 条 text_unit 级别的重叠冲突照样存在。

    为什么不够
    – Schema 能约束 LLM “只能从这些类型里选”,但没法约束它”对同一个实体只选一个”
    – 领域概念天然具有多面性,AF 在 3GPP 的语境里确实既是 NETWORK_FUNCTION 又是 ORGANIZATION,你定义再严格的 schema 也改变不了这个事实
    – 需要领域专家参与设计 schema,成本高,换一个领域就要重新设计
    – 过于严格会丢失信息——强制把 AF 归为 NETWORK_FUNCTION,就丢掉了它作为 ORGANIZATION 的语义

    结论:Schema-First 是必要条件,但不是充分条件。它能减少”乱起名”的问题,但解决不了”一个实体多种身份”的根本矛盾。

    方案二:允许多类型,后处理合并(Multi-Label + Post-Processing)

    做法:抽取时不限制类型数量,允许一个实体有多个 type,然后在后处理阶段通过规则或模型来合并、去重、选主类型。

    代表:LlamaIndex 的 PropertyGraphIndex、一些学术研究。

    优点
    – 保留了实体的多面性信息
    – 抽取阶段不丢信息

    缺点
    – 后处理逻辑复杂,规则难以穷举
    – “选主类型”本身就是一个需要领域知识的判断
    – 图的复杂度增加,查询性能下降

    适用场景:探索性分析、不确定领域边界的早期阶段。

    方案三:类型层次化(Hierarchical Typing)

    做法:建立层次化的类型体系,比如 NETWORK_FUNCTIONARCHITECTURE_CONCEPT 的子类型。抽取时标注最细粒度的类型,查询时可以按层次聚合。

    代表:Wikidata 的类型体系、YAGO 知识库。

    优点
    – 兼顾了精确性和灵活性
    – 支持不同粒度的查询

    缺点
    – 层次体系的设计本身就是一个大工程
    – LLM 很难在抽取时准确判断层次关系
    – 跨领域的层次体系很难统一

    适用场景:大规模、长期维护的知识图谱项目。

    方案四:放弃显式类型,用 Embedding 表示(Type-Free + Embedding)

    做法:不给实体分配离散的类型标签,而是用向量 embedding 来表示实体的语义特征。相似的实体在向量空间中自然聚在一起。

    代表:一些最新的研究工作,如基于 GNN 的实体表示学习。

    优点
    – 彻底避免了类型冲突问题
    – 能捕捉实体之间的细微语义差异

    缺点
    – 失去了可解释性——你没法告诉用户”这是一个网络功能”
    – 下游的社区检测和摘要生成需要重新设计
    – 调试困难

    适用场景:研究性项目、对可解释性要求不高的场景。

    方案五:上下文感知的动态类型(Context-Aware Dynamic Typing)

    做法:不在抽取阶段固定类型,而是在查询阶段根据问题的上下文动态决定实体的类型。比如用户问架构问题时,AF 被视为 NETWORK_FUNCTION;问组织问题时,被视为 ORGANIZATION

    代表:目前主要停留在学术探索阶段。

    优点
    – 最符合现实——实体的”身份”确实取决于上下文
    – 不需要在抽取阶段做艰难的类型决策

    缺点
    – 工程复杂度极高
    – 离线建图阶段无法确定图结构,社区检测等算法难以应用
    – 查询延迟增加

    适用场景:下一代 GraphRAG 系统的研究方向。

    四、我的建议:Schema-First 打底 + 分层类型 + 主类型投票 + 上下文保留

    我们的实验已经证明,Schema-First 是必要的起点——没有它,类型会更加混乱。但光靠它不够。基于我们在 3GPP 文档上的实战经验,我建议在 Schema-First 的基础上,叠加一套务实的后处理方案

    第零层:保持 Schema-First(已有)

    继续使用预定义的类型 schema 约束 LLM。这一步已经在做了,它的价值在于把类型控制在一个有限集合内,避免 LLM 自由发挥出 THINGYSTUFF 这种无意义类型。

    第一层:抽取时保留所有类型

    在 Schema-First 的基础上,不要在抽取阶段强制单一类型。LLM 从预定义类型集合中选了多个?都保留。保留每个 (entity, type, text_unit) 三元组。这是原始信号,丢了就回不来了。

    第二层:统计投票选主类型

    对每个实体,统计它在所有 text_unit 中被标注为各类型的频次,选频次最高的作为主类型(primary type)。

    AF 为例:
    – NETWORK_FUNCTION: 出现 150 次 → 主类型
    – ORGANIZATION: 出现 30 次
    – ARCHITECTURE_CONCEPT: 出现 20 次
    – NETWORK_ELEMENT: 出现 9 次

    主类型用于知识图谱的主结构、社区检测和默认查询。

    第三层:保留副类型作为属性

    其他类型不丢弃,作为实体的 alternative_types 属性存储。查询时可以按需使用。

    {
      "title": "AF",
      "primary_type": "NETWORK_FUNCTION",
      "alternative_types": ["ORGANIZATION", "ARCHITECTURE_CONCEPT", "NETWORK_ELEMENT"],
      "type_distribution": {
        "NETWORK_FUNCTION": 150,
        "ORGANIZATION": 30,
        "ARCHITECTURE_CONCEPT": 20,
        "NETWORK_ELEMENT": 9
      }
    }
    

    第四层:类型冲突检测与人工审核

    对于 text_unit 级别的重叠冲突(同一个 chunk 里同一个实体被标为不同类型),标记为需要审核的候选项。这 63 条冲突就是最值得人工检查的——它们往往揭示了类型体系设计的盲区。

    代价是什么?

    1. 存储成本增加:每个实体要存多个类型和分布信息,图的数据量大约增加 20-30%。
    2. 抽取阶段不变:不需要修改 prompt 或抽取流程,成本不增加。
    3. 后处理需要开发:投票、合并、冲突检测的 pipeline 需要额外开发,大约 2-3 天的工程量。
    4. 查询逻辑稍复杂:需要在查询层面决定是用主类型还是全部类型,但这个逻辑可以封装。
    5. 不能完全自动化:text_unit 级别的冲突仍然需要人工判断,但数量可控(我们的案例中只有 63 条)。

    五、写在最后

    GraphRAG 的论文和博客总是把重点放在”社区检测”和”全局查询”这些亮眼的能力上,但真正落地时,实体类型的混乱才是第一个拦路虎

    一份 23.502 文档,8873 个实体,1123 个有多类型冲突——而且这还是已经用了 Schema-First 约束之后的结果。这不是个例,这是所有复杂领域文档的常态。预定义类型 schema 是必要的,但远远不够。

    解决这个问题没有银弹。但至少我们可以做到:在 Schema-First 的基础上,不在后处理阶段丢信息,用统计方法选主类型,保留多面性供下游使用,把真正需要人工判断的冲突控制在可管理的范围内。

    这才是 GraphRAG 从”能跑通 demo”到”能上生产”之间,最需要填的一个坑。

    本文基于对 3GPP TS 23.502 文档的 GraphRAG 实体抽取实验数据。写于 2026 年 4 月 24 日。

  • 为什么我们需要 GraphRAG?——从”搜索”到”理解”的进化

    当 AI 不再只是”查资料”,而是真正”读懂”你的问题。

    一、先从一个生活场景说起

    假设你是一家公司的新员工,第一天上班,你想了解”公司最近三个月最重要的项目进展”。

    你有两种方式:

    方式一:翻文件柜
    你走到档案室,打开文件柜,用关键词”项目进展”去翻。你找到了几十份文件,但它们散落在不同的抽屉里,有的是会议纪要,有的是邮件,有的是报告。你需要自己把这些碎片拼起来,才能得到一个完整的答案。

    方式二:问一个”什么都知道”的老同事
    这位老同事不仅读过所有文件,还记得”张三负责的A项目和李四负责的B项目其实有关联”,知道”上个月的预算调整影响了三个部门的计划”。他能直接给你一个有条理的、完整的回答。

    方式一,就是传统的 RAG(检索增强生成)。
    方式二,就是 GraphRAG 想要做到的事情。

    二、什么是 RAG?它已经很厉害了,为什么还不够?

    RAG 是什么

    RAG 的全称是 Retrieval-Augmented Generation,翻译过来就是”检索增强生成”。简单说,就是让 AI 在回答问题之前,先去一堆文档里搜索相关内容,然后基于搜到的内容来回答你。

    这就像开卷考试——AI 可以翻书找答案,而不是纯靠记忆。

    RAG 的局限

    RAG 确实很有用,但它有一个根本性的短板:它只会”找”,不会”连”。

    举个例子,假设你问:

    “公司在亚太区的业务扩张对供应链产生了什么影响?”

    传统 RAG 会这样做:
    1. 搜索包含”亚太区””业务扩张””供应链”这些关键词的文档
    2. 找到几段相关的文字
    3. 把这些文字交给 AI,让它生成回答

    问题在哪里?

    • 关于”亚太区业务扩张”的信息可能在一份战略报告里
    • 关于”供应链调整”的信息可能在一份运营报告里
    • 这两份报告之间的关联——比如”因为亚太区扩张,所以新增了越南供应商,导致物流成本变化”——可能没有任何一份文档明确写出来

    传统 RAG 找到的是一个个孤立的”碎片”,它不擅长把碎片之间的隐含关系串起来。

    三、GraphRAG 是怎么解决这个问题的?

    核心思路:先建一张”关系网”

    GraphRAG 的关键创新在于,它在回答问题之前,先做了一件额外的事情:把所有文档里的信息整理成一张”关系网”(知识图谱)。

    这张关系网长什么样?你可以想象成一张人物关系图:

    • 节点(圆圈):代表一个个”东西”,比如人、公司、项目、地点、概念
    • 连线(箭头):代表它们之间的关系,比如”负责””属于””影响””合作”

    举个简单的例子:

    [张三] --负责--> [A项目]
    [A项目] --依赖--> [B项目]
    [B项目] --负责人--> [李四]
    [A项目] --预算来源--> [亚太区部门]
    [亚太区部门] --合作--> [越南供应商]
    

    有了这张网,当你问”张三的项目和越南供应商有什么关系”时,AI 可以沿着这张网”走”一遍,发现:

    张三 → A项目 → 亚太区部门 → 越南供应商

    即使没有任何一份文档直接写过”张三和越南供应商的关系”,AI 也能通过这条路径推理出答案。

    用大白话总结

    传统 RAG GraphRAG
    工作方式 搜索关键词,找到相关段落 先建关系网,再沿着关系回答
    擅长的问题 “X 是什么?””X 怎么做?” “X 和 Y 有什么关系?””整体情况是什么?”
    类比 图书馆管理员帮你找书 侦探帮你把线索串成完整故事
    短板 碎片化,缺乏全局视角 建关系网需要时间和计算资源

    四、GraphRAG 能帮我们做什么?

    场景一:企业知识管理

    一家大公司有成千上万份内部文档:政策、流程、会议纪要、技术文档……

    • 传统方式:员工搜索关键词,翻阅大量文档,自己总结
    • GraphRAG 方式:AI 已经”读懂”了所有文档之间的关系,员工可以直接问”上季度客户投诉增加的根本原因是什么?”,AI 能从产品变更、客服记录、供应商问题等多个维度给出关联分析

    场景二:医疗健康

    一个患者的病历、检查报告、用药记录分散在不同系统里。

    • 传统方式:医生需要逐一查看,靠经验判断
    • GraphRAG 方式:AI 把患者信息、药物、疾病、检查结果之间的关系建成网络,能提示”这个患者正在服用的 A 药和新开的 B 药可能有相互作用,因为它们都作用于同一个代谢通路”

    场景三:金融风控

    银行需要判断一笔贷款的风险。

    • 传统方式:查看借款人的信用报告和财务数据
    • GraphRAG 方式:AI 发现借款人的公司和另一家已经违约的公司有共同的实际控制人,而这个关联隐藏在多层股权结构中——这种”隐藏关系”正是 GraphRAG 的强项

    场景四:日常问答助手

    你在使用一个 AI 助手来了解某个复杂话题,比如”气候变化”。

    • 传统方式:AI 给你一段关于气候变化的概述
    • GraphRAG 方式:AI 能告诉你”气候变化通过影响农业产量,进而影响粮食价格,最终影响发展中国家的社会稳定”——这种多跳推理(从 A 到 B 到 C 到 D)是 GraphRAG 的核心优势

    五、GraphRAG 不是万能的

    说了这么多好处,也要诚实地说说它的局限:

    1. 建关系网需要成本:把大量文档转化成知识图谱,需要时间和计算资源。对于小规模、简单的问答场景,传统 RAG 可能就够用了。

    2. 关系网的质量很关键:如果 AI 在建图的时候理解错了某个关系,后续的推理也会出错。就像侦探如果把线索连错了,结论也会跑偏。

    3. 不是所有问题都需要它:如果你只是想查”公司的报销流程是什么”,传统搜索就能很好地回答,不需要动用 GraphRAG。

    六、总结

    GraphRAG 的本质,是让 AI 从”关键词搜索”进化到”关系推理”。

    它不是要取代传统的 RAG,而是在传统 RAG 的基础上,增加了一层”理解关系”的能力。就像从”查字典”升级到”读百科全书”——字典告诉你每个词的意思,百科全书还告诉你这些词之间的联系。

    对于需要处理大量复杂信息、需要发现隐藏关联、需要全局视角的场景,GraphRAG 是一个值得关注的方向。

    本文写于 2026 年 4 月 23 日。GraphRAG 技术仍在快速发展中,未来可能会有更多改进和新的应用场景。