分类: graphrag

  • GraphRAG Local Search 的 Text Unit 选择策略:设计权衡与改进方向

    引言

    GraphRAG 的 Local Search 在查询时需要从知识图谱关联的原始文本片段(Text Units)中选择最相关的内容填入 LLM 上下文窗口。这个选择策略看似简单——按实体相似度排序、逐条填入——但在实际场景中会暴露出一个显著的局限性:热门实体可能霸占整个 Text Unit 预算,导致其他实体的关键文本被截断

    本文将深入分析这个问题的成因、它所解决的核心问题,以及可能的改进方向。

    当前策略是什么

    Local Search 的 Text Unit 选择分四步:

    1. 遍历选中实体(按向量相似度排名),收集每个实体关联的 text_unit_ids
    2. 去重:同一个 TU 只归属于最先遍历到的实体
    3. 排序:按 (entity_index, -num_relationships) —— 实体顺序优先,同一实体内按关系密度降序
    4. 逐条填入上下文,直到达到 token 上限(默认总预算的 50%,约 6000 tokens)

    核心代码:

    for index, entity in enumerate(selected_entities):
        entity_relationships = [rel for rel in relationships if rel.source == entity.title or rel.target == entity.title]
        for text_id in entity.text_unit_ids or []:
            if text_id not in text_unit_ids_set and text_id in self.text_units:
                num_relationships = count_relationships(entity_relationships, self.text_units[text_id])
                text_unit_ids_set.add(text_id)
                unit_info_list.append((self.text_units[text_id], index, num_relationships))
    
    unit_info_list.sort(key=lambda x: (x[1], -x[2]))
    

    问题场景:热门实体霸占预算

    具体例子

    假设用户问:”甘菊蓝的抗炎机制是什么?”

    向量搜索返回的实体排名:

    排名 实体 关联 TU 数量 说明
    0 洋甘菊 50 高频实体,几乎所有草药文档都提到它
    1 甘菊蓝 4 洋甘菊的活性成分,专业文献较少
    2 NF-κB 通路 2 具体的抗炎分子机制

    遍历去重后的 TU 归属:

    index 0 "洋甘菊": TU1, TU2, TU3, ..., TU50  (50条)
    index 1 "甘菊蓝": TU51, TU52              (TU1, TU5 已被洋甘菊占有)
    index 2 "NF-κB":  TU53                    (仅1条未被占有)
    

    排序结果:

    TU1(index=0, rel=5) → TU2(index=0, rel=4) → ... → TU50(index=0, rel=0)
    → TU51(index=1, rel=2) → TU52(index=1, rel=1)
    → TU53(index=2, rel=1)
    

    假设 token 预算为 6000 tokens,每个 TU 平均 300 tokens,那么只能容纳约 20 个 TU。

    结果:前 20 个位置全部被”洋甘菊”的 TU 占据。用户真正关心的”甘菊蓝的抗炎机制”相关文本(TU51、TU52、TU53)全部被截断。LLM 拿到的上下文中充斥着”洋甘菊”的泛泛介绍,却没有任何关于甘菊蓝具体分子机制的原文支撑。

    为什么要这样设计:它解决了什么问题

    这个策略并非随意设计,它解决的是一个更基础的问题:确保语义最相关的实体获得最充分的原文支撑

    它解决的场景

    假设用户问:”洋甘菊在欧洲传统医学中的地位如何?”

    向量搜索返回:

    排名 实体 关联 TU 数量
    0 洋甘菊 50
    1 欧洲草药学 8
    2 薰衣草 30

    在这个场景中,”洋甘菊”确实是最核心的实体,用户就是在问它。如果采用 round-robin 策略(每个实体轮流取 1 个 TU),那么”薰衣草”的 30 个 TU 会与”洋甘菊”平分预算——但用户根本没问薰衣草。

    当前策略的优势在于:
    尊重语义排名:向量相似度最高的实体获得最多原文支撑,这在大多数情况下是正确的
    关系密度排序保证质量:同一实体的多个 TU 中,信息最密集的排在前面
    去重避免冗余:同一个 TU 不会因为被多个实体关联而重复出现

    核心权衡

    这是一个经典的 相关性深度 vs. 覆盖广度 的权衡:

    • 当前策略选择了深度:确保最相关实体有充分的原文证据
    • 代价是广度:次要实体可能完全没有原文支撑

    在大多数”关于特定实体的问题”中(Local Search 的设计目标),深度优先是合理的。问题出在查询涉及多个实体的交叉关系时。

    问题的本质:单一排序维度无法表达多目标优化

    Text Unit 选择本质上是一个多目标优化问题:

    1. 相关性:TU 与查询的语义相关度(通过实体排名间接表达)
    2. 信息密度:TU 中包含的关系数量
    3. 覆盖性:确保每个选中实体都有原文支撑
    4. 多样性:避免上下文中充斥同质化内容

    当前策略用一个二元组 (entity_index, -num_relationships) 试图同时优化前两个目标,但完全忽略了后两个。

    改进方向

    方案 1:Per-Entity Cap(每实体上限)

    最简单的改进——为每个实体设置 TU 贡献上限:

    MAX_TU_PER_ENTITY = 5
    
    for index, entity in enumerate(selected_entities):
        count = 0
        for text_id in entity.text_unit_ids or []:
            if count >= MAX_TU_PER_ENTITY:
                break
            if text_id not in text_unit_ids_set and text_id in self.text_units:
                # ... 添加逻辑不变
                count += 1
    

    优点:实现简单,保证每个实体至少有机会贡献 TU
    缺点:cap 值难以确定;如果某个实体确实需要大量原文支撑,会被人为限制

    方案 2:Round-Robin(轮询)

    每轮从每个实体取 1 个 TU(按关系密度选最优的),循环直到预算用完:

    entity_queues = {i: sorted_tus_for_entity_i for i in range(len(selected_entities))}
    result = []
    while budget > 0 and any(entity_queues.values()):
        for i in range(len(selected_entities)):
            if entity_queues[i]:
                tu = entity_queues[i].pop(0)
                result.append(tu)
                budget -= token_count(tu)
    

    优点:保证覆盖性,每个实体都有原文支撑
    缺点:最相关实体的深度被稀释;排名靠后的不相关实体也获得了等量预算

    方案 3:加权配额分配

    按实体的向量相似度分数分配 TU 配额:

    # 假设相似度分数: [0.95, 0.82, 0.71]
    scores = [0.95, 0.82, 0.71]
    total = sum(scores)
    quotas = [int(max_tus * s / total) for s in scores]
    # quotas ≈ [15, 13, 11] (假设 max_tus=39)
    

    优点:兼顾深度和广度,相关性高的实体获得更多配额但不会独占
    缺点:实现复杂度增加;需要从向量搜索结果中保留相似度分数(当前代码未保留)

    方案 4:最小保证 + 剩余竞争

    每个实体保证至少 N 个 TU(如 2 个),剩余预算按当前策略竞争:

    # Phase 1: 每个实体保证 2 个最优 TU
    for entity in selected_entities:
        guaranteed_tus = top_2_by_relationship_density(entity)
        result.extend(guaranteed_tus)
    
    # Phase 2: 剩余预算按原策略排序填充
    remaining = all_tus - guaranteed_tus
    remaining.sort(key=lambda x: (x.entity_index, -x.num_relationships))
    fill_until_budget(remaining)
    

    优点:保证覆盖性的同时保留了原策略的深度优势
    缺点:如果选中实体很多,保证阶段可能消耗大量预算

    总结

    维度 当前策略 问题
    相关性深度 ✅ 优秀
    信息密度 ✅ 优秀
    覆盖广度 ❌ 缺失 热门实体霸占预算
    内容多样性 ❌ 缺失 同质化风险

    GraphRAG 当前的 Text Unit 选择策略是一个”深度优先”的设计,它在”关于单一实体的问题”场景下表现良好,但在涉及多实体交叉关系的查询中会暴露覆盖性不足的问题。

    最务实的改进是方案 4(最小保证 + 剩余竞争)——它以最小的代码改动保证了每个选中实体至少有原文支撑,同时不破坏原策略在主流场景下的优势。

  • GraphRAG 到底在干嘛?——微软这篇博客的深度拆解

    原文:GraphRAG: Unlocking LLM discovery on narrative private data – Microsoft Research

    微软 2024 年初发了一篇技术博客,核心就一句话:传统 RAG 在复杂数据面前不够用,GraphRAG 用知识图谱 + 图聚类补上了这块短板。

    这不是学术论文,更像是一篇”技术安利文”,目标读者是技术决策者和工程师。下面我把它拆开来聊。

    传统 RAG 到底差在哪?

    GraphRAG 要解决的问题,得先从传统 RAG 的痛点说起。文章指出了两个传统 RAG 搞不定的场景:

    串不起来的信息

    想象你问 AI:”Novorossiya 做了什么?”

    传统 RAG 拿着”Novorossiya”这个词去做向量搜索,结果检索回来的 10 个文本片段里,没有一个直接提到这个名字——答案散落在不同的文档里,靠的是实体之间的间接关联才能拼出来。向量搜索只会找”长得像”的文本,这种需要”跳着找”的推理它做不了。

    GraphRAG 就不一样了:它在知识图谱里找到 Novorossiya 这个节点,然后沿着关系边一路走下去——行动、目标、相关组织——最后把完整答案拼出来。

    说白了,向量检索是”局部匹配”,而真实世界的知识经常是通过实体关系链间接连起来的。

    回答不了”大问题”

    再比如你问:”这堆数据里排名前 5 的主题是什么?”

    传统 RAG 傻眼了——”主题”这个词太泛了,向量搜索不知道该往哪个方向找,碰巧匹配到一些包含”主题”这个词的无关文本,答案自然跑偏。

    这本质上是个粒度问题:向量 RAG 的检索单元是文本片段(chunk),但”整体主题”这种问题需要对整个数据集有宏观理解,任何单个 chunk 都撑不起这个回答。

    GraphRAG 靠预先建好的社区聚类和社区摘要,直接从宏观结构里提取主题,轻松搞定。

    GraphRAG 怎么干的?

    整个流程分两个阶段:先离线建索引,再在线回答问题。

    离线建索引:三步走

    原始文档
        │
        ▼
    ┌─────────────────────────────┐
    │ Step 1: 实体和关系抽取       │  LLM 逐块处理文档,提取所有
    │ (Entity & Relationship      │  实体(人、地、组织等)和
    │  Extraction)                │  它们之间的关系
    └─────────────────────────────┘
        │
        ▼
    ┌─────────────────────────────┐
    │ Step 2: 知识图谱构建         │  将抽取的实体和关系组装成
    │ (Knowledge Graph             │  一个完整的图结构
    │  Construction)               │
    └─────────────────────────────┘
        │
        ▼
    ┌─────────────────────────────┐
    │ Step 3: 社区检测与摘要       │  对图进行自底向上的层次聚类
    │ (Community Detection         │  (如 Leiden 算法),为每个
    │  & Summarization)            │  社区生成 LLM 摘要报告
    └─────────────────────────────┘
    

    简单说就是:先让 LLM 把文档里的人、事、物和它们的关系都挖出来,拼成一张大图,然后对这张图做分群,给每个群写一份摘要。

    在线回答:看问题类型选策略

    问题类型 怎么找答案
    具体问题(如”Novorossiya 做了什么”) 在图里定位实体 → 沿关系遍历 → 收集相关文本 → 生成回答
    宏观问题(如”前 5 个主题”) 直接用社区摘要 → 逐层聚合 → 生成全局回答

    几个值得深挖的技术点

    为什么用 LLM 建图,而不是传统 NLP?

    传统做法是用 NER(命名实体识别)+ 关系抽取模型,但这些模型有硬伤:得预先定义好实体类型和关系类型,换个领域就不灵了,隐含关系更是抓不住。

    LLM 的优势很明显:
    零样本就能干活,不用为每个领域单独训练
    能读懂言外之意,比如从”总检察长办公室报告了 Novorossiya 的创建”里抽出”政府关注”这层隐含关系
    不受 schema 限制,实体和关系类型让 LLM 自己发现

    当然代价也很直接:LLM 调用贵,索引阶段要处理整个数据集,计算开销不小。

    社区检测——GraphRAG 的杀手锏

    很多方法都会用知识图谱来增强 RAG,但 GraphRAG 真正独特的地方在于社区检测:

    • 用 Leiden 之类的算法把知识图谱切成多层次的社区(你可以理解为”话题群组”)
    • 给每个社区预先生成一份 LLM 摘要报告
    • 不同层次的社区对应不同的抽象级别,回答问题时按需选粒度

    这就是它能回答”大问题”的秘密——不用临时遍历整张图,直接查预先写好的摘要就行。

    生成社区报告时,LLM 拿到的输入是该社区内实体和关系的 CSV 表:Entities 表(实体 ID、名称、描述)、Relationships 表(源、目标、描述、combined_degree)、以及可选的 Claims 表。关系按 combined_degree 降序排列,优先塞最重要的,token 超了就截断。

    溯源——每句话都能查到出处

    GraphRAG 特别强调溯源能力,整条证据链是这样的:

    用户查询
        → GraphRAG 回答 + [数据:实体 (ID), 关系 (ID)]
            → 关系 ID 指向知识图谱中的具体边
                → 边关联到原始源文档的具体片段
    

    回答 → 图里的实体/关系 → 原始文档,一路可追溯。对企业级应用来说,这个能力非常关键——你能验证 AI 说的每一句话。

    实验是怎么做的?

    数据集

    用的是 VIINA 数据集(新闻文章暴力事件信息),选得很讲究:

    • 涉及多方冲突,信息碎片化,够复杂
    • 包含俄乌双方新闻源,观点对立,有矛盾信息
    • 2023 年 6 月的数据,确保不在 LLM 训练集里
    • 数千篇文章,远超上下文窗口,不用 RAG 没法处理

    评估结果

    用了四个指标来打分:

    指标 说的是啥 怎么评
    全面性 答案完不完整 LLM 评分器成对比较
    人类赋权 有没有给出处让你验证 LLM 评分器成对比较
    多样性 有没有多角度回答 LLM 评分器成对比较
    忠实度 有没有瞎编 SelfCheckGPT 绝对测量

    结果挺有意思:GraphRAG 在前三项上大幅领先传统 RAG,但忠实度上两者差不多。也就是说,GraphRAG 的提升主要在”找得更全”,而不是”编得更少”。

    别光看优点,局限也得知道

    这毕竟是篇安利文,自然报喜不报忧。几个需要注意的坑:

    索引成本高——每个文档块都要调 LLM 来抽实体和关系,大数据集可能要跑几小时甚至几天,用 GPT-4 级别的模型,API 费用相当可观。

    增量更新是个难题——文章压根没提数据变了怎么办。实际上新增文档要重新抽取、合并,社区结构可能因此改变,得重新聚类、重新生成摘要,这在工程上还没有很好的解法。

    抽取质量看 LLM 脸色——LLM 抽实体和关系不是百分百准的,可能漏掉隐含实体、搞错关系,不同模型的抽取质量差异也大,一致性难保证。

    查询会慢一些——图遍历 + LLM 生成比简单的向量检索 + LLM 生成链路更长,延迟自然更高。

    不是所有问题都需要它——文章自己也承认,简单的事实性查询(比如”什么是 Novorossiya”),传统 RAG 就够用了。GraphRAG 的优势集中在多跳推理和全局总结这两个场景。

    打个比方帮你建立直觉

    假设你是公司新人,想了解”最近三个月最重要的项目进展”。

    传统 RAG 就像翻文件柜:你走到档案室,用”项目进展”当关键词去翻。找到几十份文件,散落在不同抽屉里——会议纪要、邮件、报告都有。你得自己把碎片拼起来。

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

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

    最后划几个重点

    • GraphRAG 解决的不是”搜得更准”的问题,而是”搜的维度”的问题——从文本相似性扩展到了实体关系和全局结构。

    • 知识图谱是手段,社区聚类才是真正的创新——很多方法都用图增强 RAG,但社区检测 + 预摘要是 GraphRAG 解决全局查询的独门武器。

    • 溯源能力是信任的基础——每个断言都能追溯到原始文档,企业级应用离不开这个。

    • 代价是索引成本——用 LLM 处理全量数据建图谱,比简单向量化贵得多,落地时必须权衡。

    • 不是替代,是互补——复杂推理和全局分析用 GraphRAG,简单事实查询用传统 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 模板
  • DeepEval Faithfulness Metric 的已知缺陷:idk 不扣分问题

    背景

    在使用 DeepEval 对 GraphRAG 系统进行无标准答案(no-reference)评测时,我们发现 FaithfulnessMetric 在特定场景下会给出误导性的满分结果。

    问题现象

    我们向 GraphRAG 提出了一个关于 5GC PDU Session 建立流程的复杂问题。系统返回了详细的技术回答(涉及 AMF、SMF、UPF、PCF 等网元的具体职责),但检索到的 context 仅包含 3GPP 文档的目录结构,例如:

    The document contains a section '5.6 Session Management' with several sub-subsections.
    The document contains a section '5.2 Network Access Control' with several sub-subsections.
    

    Context 中没有任何实质性的技术内容,但 Faithfulness 评分为 1.00(满分)

    根因分析

    Faithfulness metric 的评估分为 4 步:

    Step 作用
    1. Truths 提取 从 retrieval_context 提取事实列表
    2. Claims 提取 从 actual_output 提取声明列表
    3. Verdicts 判定 将每条 claim 与 context 比对,判定 yes/no/idk
    4. Score 计算 根据 verdicts 计算最终分数

    关键在于 Step 3 的判定规则:

    • yes — claim 与 context 一致
    • no — claim 与 context 直接矛盾
    • idk — context 中找不到相关信息,无法判断

    以及 Step 4 的默认计分公式

    score = (总数 - no的数量) / 总数
    

    idk 不计入扣分。 只有明确矛盾(no)才会降低分数。

    实际案例

    我们的评测中,LLM judge(换用更严格的模型后)对 20 条 claims 全部判定为 idk

    {
      "verdicts": [
        {"verdict": "idk"},
        {"verdict": "idk"},
        ...  // 共 20 条,全部 idk
      ]
    }
    

    计分结果:score = (20 - 0) / 20 = 1.00

    最终 reason 输出:

    “The score is 1.00 because there are no contradictions; the actual output fully aligns with the retrieval context.”

    这显然是误导性的 — 回答中的所有声明都没有被 context 支撑,但因为也没有被”矛盾”,所以得了满分。

    本质问题

    Faithfulness 衡量的是“有没有与 context 矛盾”,而不是“有没有被 context 支撑”

    这两个是完全不同的维度:

    场景 Faithfulness Groundedness
    回答完全基于 context
    回答正确但 context 无关 高(无矛盾) 低(无支撑)
    回答与 context 矛盾

    当 retrieval context 只包含目录级、摘要级信息时,几乎不可能与任何具体声明产生”直接矛盾”,Faithfulness 就会永远满分。

    解决方案

    方案 1:开启 penalize_ambiguous_claims

    DeepEval 提供了内置参数:

    FaithfulnessMetric(model=model, threshold=0.5, penalize_ambiguous_claims=True)
    

    开启后计分公式变为:

    score = (总数 - no的数量 - idk的数量) / 总数
    

    此时 20 条全 idk 的分数为:(20 - 0 - 20) / 20 = 0.00,更真实地反映了 context 对回答的支撑程度。

    方案 2:补充 Groundedness 指标

    使用 GEval 自定义一个 Groundedness metric,直接评估回答是否被 context 支撑:

    GEval(
        name="Groundedness",
        criteria="Determine whether the actual output is fully supported and grounded by the retrieval context. "
                 "Penalize claims in the output that cannot be traced back to specific information in the retrieval context.",
        evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT, SingleTurnParams.RETRIEVAL_CONTEXT],
        model=model,
        threshold=0.5,
    )
    

    建议

    两个方案并用:
    – 保留 Faithfulness(开启 penalize_ambiguous_claims)检测矛盾和无支撑
    – 增加 Groundedness 从正面评估支撑程度
    – 在报告中注明 Faithfulness 的局限性,避免误读

    补充缺陷:总结性 claim 被误判为 idk

    即使 context 中包含了具体的细节信息,当 actual output 对这些细节做了归纳总结时,judge 仍然可能将其判为 idk

    实际案例

    Context 中包含了 PDU Session 建立的具体步骤细节(AMF 处理注册、SMF 选择 UPF、N4 会话建立等),而 actual output 中有一条总结性 claim:

    “从UE尝试访问特定DNN直到实现有效的用户面转发,整个过程涉及到了多个核心网元之间的紧密合作,每个组件都扮演着不可或缺的角色。”

    Judge 的判定:

    {
      "verdict": "idk",
      "reason": "The claim is a summary statement; the context provides specific procedural details but does not directly confirm this overall description."
    }
    

    原因

    Faithfulness 的 prompt 对 judge 有严格约束:

    “Only use ‘no’ if retrieval context DIRECTLY CONTRADICTS the claim — never use prior knowledge.”
    “Use ‘idk’ for claims not backed up by context — do not assume your knowledge.”

    Judge 被要求做字面级匹配,而不是语义级推理。即使 context 中的细节完全可以推导出这个总结,但因为 context 没有”直接确认”这句话,judge 就只能判 idk

    影响

    对于 RAG 系统来说,回答本来就应该基于 context 做归纳总结,这是正常且期望的行为。但 Faithfulness 的字面级判定会将这类合理总结视为”无支撑”,导致开启 penalize_ambiguous_claims 后分数偏低。

    可能的改进

    DeepEval 的 FaithfulnessMetric 支持 evaluation_template 参数,可以继承 FaithfulnessTemplate 并修改 verdict guidelines,将”可从 context 细节合理推导出的总结”纳入 yes 的判定范围。但这需要修改评测标准的语义,应谨慎使用。

    结论

    Faithfulness metric 的设计初衷是检测 hallucination(幻觉),即模型是否编造了与 context 矛盾的信息。但它存在两个层面的局限:

    1. idk 默认不扣分 — context 无关时永远满分(通过 penalize_ambiguous_claims=True 解决)
    2. 字面级匹配过于严格 — 合理的归纳总结也会被判为无支撑(需自定义 template 或依赖 Groundedness 指标补充)

    评测 RAG 系统时,需要同时关注 Faithfulness 和 Groundedness 两个维度,才能全面评估回答质量。

  • GraphRAG 中 Community Report 的”幽灵分身”:同一份报告为何被创建了两次

    现象

    在 FalkorDB 中查询 HAS_REPORT 边的 Top 10 节点时,发现有 4 个 community_report 节点各有 4 条 HAS_REPORT 边指向它们。按照设计,每个 community 应该唯一对应一个 report,为什么会出现一对多?

    Edge type: HAS_REPORT
    Rank  Title                                                          Count
    1     技术部核心团队:后端架构与系统设计                                    4
    2     产品部:用户增长与商业化策略                                         4
    3     运维部:服务稳定性与监控体系                                         4
    4     测试部:质量保障与自动化测试                                         4
    

    理论上每个 community 只有一个 report,每个 report 只属于一个 community,HAS_REPORT 应该是 1:1 的关系。

    用一个通俗的例子来理解

    想象你在管理一个公司的组织架构

    假设你的公司有这样的部门结构:

    技术部 (278人)
      └── 后端组 (253人)
    

    “后端组”是”技术部”的子部门。现在 HR 要给每个部门写一份部门简介

    HR 发现”后端组”的核心成员和”技术部”高度重叠(后端组的人就是技术部的主力),于是 AI 给两个部门生成了几乎一模一样的简介

    部门 简介标题 部门人数
    技术部 (community 1491) “核心技术团队:后端架构与系统设计” 278人
    后端组 (community 2790) “核心技术团队:后端架构与系统设计” 253人

    两份简介的标题和内容完全相同(因为描述的本质上是同一群人),只有”部门人数”(size)不同。

    由于内容相同,系统给它们算出了相同的 ID(基于内容的 hash)。

    对应到我们实际发现的 4 组问题数据:

    部门类比 实际 community 简介标题 人数(size)
    技术部 community 1491 “技术部核心团队:后端架构与系统设计” 278
    └── 后端组 community 2790 “技术部核心团队:后端架构与系统设计” 253
    产品部 community 200 “产品部:用户增长与商业化策略” 796
    └── 产品一组 community 1100 “产品部:用户增长与商业化策略” 631
    运维部 community 1909 “运维部:服务稳定性与监控体系” 180
    └── 运维一组 community 3073 “运维部:服务稳定性与监控体系” 178
    测试部 community 953 “测试部:质量保障与自动化测试” 21
    └── 测试一组 community 2343 “测试部:质量保障与自动化测试” 19

    问题出在哪里?

    当把这些数据导入图数据库时:

    第一步:创建 report 节点

    以”技术部”和”后端组”为例。系统看到 parquet 里有两行数据(同一个 ID,不同的 community),就无脑创建了两个节点

    Report 节点 A: {id: "abc123", community: 1491, size: 278}  -- 技术部的简介
    Report 节点 B: {id: "abc123", community: 2790, size: 253}  -- 后端组的简介
    

    第二步:创建 HAS_REPORT 边

    系统遍历每个 report 记录,用 id 去匹配 report 节点:

    -- 处理技术部 (community 1491)
    MATCH (c:communities {community: 1491})
    MATCH (r:community_reports {id: "abc123"})  -- 匹配到 2 个节点(A 和 B)!
    CREATE (c)-[:HAS_REPORT]->(r)
    -- 结果:技术部 → 节点A, 技术部 → 节点B(2 条边)
    
    -- 处理后端组 (community 2790)
    MATCH (c:communities {community: 2790})
    MATCH (r:community_reports {id: "abc123"})  -- 同样匹配到 2 个节点!
    CREATE (c)-[:HAS_REPORT]->(r)
    -- 结果:后端组 → 节点A, 后端组 → 节点B(2 条边)
    

    最终结果:这个 report 标题下有 4 条 HAS_REPORT 边(2 个部门 × 2 个同 ID 节点 = 4)。

    而正确的结果应该是:技术部 → 技术部的简介(1 条),后端组 → 后端组的简介(1 条),共 2 条。

    根因分析

    问题由两个因素叠加导致:

    1. Leiden 层级聚类产生了内容相同的 Report

    GraphRAG 使用 Leiden 算法做层级社区发现。当子社区的成员与父社区高度重叠时,LLM 为它们生成了内容几乎相同的 report。由于 report ID 是基于内容的 hash,内容相同 → ID 相同。

    实际数据验证:

    report id communities sizes 层级关系
    6516e2f4… 2790, 1491 253, 278 2790 是 1491 的子社区
    feda9fa0… 1100, 200 631, 796 1100 是 200 的子社区
    d8f25d09… 2343, 953 19, 21 2343 是 953 的子社区
    223c76c6… 3073, 1909 178, 180 3073 是 1909 的子社区

    2. 导入逻辑缺乏去重和精确匹配

    导入代码中:

    # 创建节点:无条件 CREATE,不去重
    "UNWIND $batch AS p CREATE (n:community_reports) SET n = p"
    
    # 创建边:只按 id 匹配,没有加 community 条件
    "MATCH (r:community_reports {id: p.rid})"  # 匹配到多个同 ID 节点 → 笛卡尔积
    

    解决方案

    HAS_REPORT 创建时精确匹配

    在创建 HAS_REPORT 边时,同时匹配 idcommunity,避免笛卡尔积:

    # Before (有 bug)
    "MATCH (r:community_reports {id: p.rid}) "
    
    # After (修复)
    "MATCH (r:community_reports {id: p.rid, community: p.cnum}) "
    

    这样每个 community 只会匹配到属于自己的那个 report 节点,创建 1 条边。

    教训:在图数据库中用 MATCH + CREATE 模式创建关系时,如果匹配条件不够精确(目标节点有重复),就会产生意料之外的笛卡尔积。始终确保 MATCH 条件能唯一定位到目标节点。

  • GraphRAG 层级聚类中的”孤儿社区”:为什么有些 Community 没有 PARENT_OF 边

    现象

    在使用 GraphRAG 构建知识图谱后,查询某个 community 节点时发现它没有任何 PARENT_OF 关系——既没有父级,也没有子级。但图中明明存在大量 PARENT_OF 边。为什么这个 community 被”遗忘”了?

    背景:GraphRAG 的层级 Community 结构

    GraphRAG 使用 Leiden 算法对实体图做层级聚类。为了让大家直观理解,我们用一个”世界地图”的类比来说明整个过程。

    想象你在给全世界的人分组

    假设你有一张巨大的社交关系图,图上每个节点是一个人,边代表”这两个人有联系”。现在你要把这些人分组:

    1. Level 0(最粗粒度):先按最大的圈子分——相当于把全世界的人分成几个”大洲”。同一个大洲内的人联系密切,不同大洲之间联系稀疏。
    2. Level 1:在每个大洲内部继续细分——相当于分成”国家”。
    3. Level 2:每个国家内部再分——相当于”省/州”。
    4. Level 3, 4, …:继续细分为”城市”、”社区”…

    Level 越大,粒度越细。

    每一层通过 PARENT_OF 边连接到下一层(粗 → 细):

    大洲 ──PARENT_OF──> 国家 ──PARENT_OF──> 省 ──PARENT_OF──> 城市
    (level 0)          (level 1)          (level 2)         (level 3)
    

    一个完整的例子

    假设我们对一个”全球美食知识图谱”做 GraphRAG 层级聚类。图中的实体是各种食材、菜品、烹饪技法,边代表它们之间的关联。

    第一轮聚类(Level 0):分出 5 个大组

    Community 代表实体 Size
    大洲 A “亚洲美食” 米饭、酱油、炒锅、豆腐、味噌… 800
    大洲 B “欧洲美食” 橄榄油、奶酪、面包、红酒、黄油… 600
    大洲 C “美洲美食” 玉米、辣椒、牛油果、烧烤… 400
    大洲 D “非洲美食” 木薯、花生酱、库斯库斯… 200
    大洲 E “南极科考站食堂” 罐头、压缩饼干、速溶咖啡 3

    第二轮聚类(Level 1):在大组内部细分

    大洲 A “亚洲美食”(800 个实体) 内部结构复杂,可以继续细分:

    大洲 A "亚洲美食" (level 0, size=800)
      ├── PARENT_OF → 国家 A1 "中华料理" (level 1, size=300)
      │     ├── PARENT_OF → 省 A1a "川菜" (level 2, size=80)
      │     ├── PARENT_OF → 省 A1b "粤菜" (level 2, size=70)
      │     └── PARENT_OF → 省 A1c "鲁菜" (level 2, size=50)
      ├── PARENT_OF → 国家 A2 "日本料理" (level 1, size=200)
      ├── PARENT_OF → 国家 A3 "东南亚料理" (level 1, size=150)
      └── PARENT_OF → 国家 A4 "韩国料理" (level 1, size=100)
    

    大洲 E “南极科考站食堂”(3 个实体) 呢?

    大洲 E "南极科考站食堂" (level 0, size=3)
      ├── 罐头
      ├── 压缩饼干
      └── 速溶咖啡
    
      (完了,没有 PARENT_OF 出边)
    

    3 个实体之间的关系:
    – 罐头 ↔ 压缩饼干(都是长保质期食品)
    – 罐头 ↔ 速溶咖啡(都是即食品)
    – 压缩饼干 ↔ 速溶咖啡(都是科考站标配)

    它们紧密关联,所以被聚成一组。但只有 3 个成员——你没法把 3 个人再分成”部门”和”小组”,太荒谬了。

    同时,大洲 E 和外部的连接也极其稀疏——只有”罐头”和大洲 B 的”橄榄油罐头”有一条弱关联。这条连接太弱,算法不会把大洲 E 合并到大洲 B 里去。

    结果:大洲 E 成为孤儿——既无法向下细分,也不会被合并到其他组。

    为什么会产生孤儿?两个条件同时满足

                        ┌─────────────────────────┐
                        │  Community 太小          │
                        │  (2~9 个实体)            │
                        │  内部无法继续细分         │
                        └───────────┬─────────────┘
                                    │
                                    ▼
                        ┌─────────────────────────┐
                        │  成为孤儿 Community      │
                        │  没有 PARENT_OF 边       │
                        └───────────┬─────────────┘
                                    │
                        ┌───────────┴─────────────┐
                        │  与外部连接极弱           │
                        │  (1~2 条跨组边)          │
                        │  不值得被合并到其他组     │
                        └─────────────────────────┘
    

    Leiden 算法的判断标准是模块度(modularity)

    • 向下细分:3 个人分成 2 组?每组 1-2 人,没有统计意义,模块度不会提升。放弃。
    • 合并到别人:和最近的大组只有 1 条弱连接,强行合并会降低那个大组的内聚性。放弃。

    用数据说话

    回到真实的 GraphRAG 数据,统计结果完全印证了这个规律:

    孤儿 community(无 PARENT_OF 边):

    Community Size(实体数)
    孤儿 1 9
    孤儿 2 7
    孤儿 3 5
    孤儿 4 3
    孤儿 5 2

    正常 community(有 PARENT_OF 边,参与层级细分):

    Community Size(实体数)
    正常 1 2,511
    正常 2 2,330
    正常 3 1,571
    正常 4 688
    正常 5 685

    规律一目了然:size 越大越容易参与层级,size 越小越容易成为孤儿。

    在一个实际的知识图谱中,level 0 共 41 个 community,其中 23 个正常参与层级细分,18 个成为孤儿。孤儿的 size 全部在 2-9 之间。

    对 GraphRAG 查询的影响

    Global Search 会遍历某一层的 community report 来回答问题。如果它选择遍历 level 1 的 report:

    • ✅ 正常 community 的信息会出现在 level 1 的子 community report 中
    • ❌ 孤儿 community 没有 level 1 子 community,它的信息不会出现在任何 level 1+ 的 report 中

    类比:如果你只看”国家级”的报告,南极科考站食堂的信息不会出现在任何国家的报告里——因为它不属于任何国家。

    Local Search 通过实体向量匹配直接找到相关实体,不依赖层级结构。所以孤儿 community 中的实体仍然可以被 Local Search 检索到。

    实际影响

    由于孤儿 community 的 size 很小(2-9 个实体),包含的信息量有限,对大多数查询的影响不大。但如果你的查询恰好涉及这些”边缘知识”,可能需要注意这个盲区。

    总结

    特征 正常 Community 孤儿 Community
    Size 几十~几千 2~9
    类比 大洲/国家/省(人口众多) 南极科考站(3 个人)
    内部结构 复杂,可层层细分 太简单,无法细分
    外部连接 与其他组有大量交互 与外界几乎隔绝
    PARENT_OF 边 有(指向更细的子 community)
    Global Search 可见性 信息逐层传递到各级 report 只在 level 0 report 中可见

    Leiden 层级聚类算法的行为, 就像现实世界中,南极科考站确实不属于任何国家的行政区划——它太小、太孤立,强行归入某个国家反而不合理。算法做了同样的判断:太小的 community 无法继续细分,与外界连接太弱的 community 不会被强行合并。

  • 为什么 Gold Answer 在 GraphRAG 系统中越来越不重要了

    传统 RAG 评估依赖人工标注的”标准答案”,但在 GraphRAG 时代,这套方法正在失去意义。

    什么是 Gold Answer?

    Gold Answer(黄金答案)是指人工标注的”标准正确答案”。在传统 NLP 和 RAG 系统评估中,我们通常这样做:

    1. 准备一批测试问题
    2. 人工写出每个问题的”正确答案”
    3. 让系统回答同样的问题
    4. 对比系统答案和 Gold Answer,计算 F1、BLEU、ROUGE 等分数

    这套方法在搜索引擎和简单问答系统中运行了很多年。但在 GraphRAG 这类复杂系统中,Gold Answer 的价值正在急剧下降。

    知识图谱持续演化,Gold Answer 跟不上

    问题本质

    GraphRAG 的核心是知识图谱。图谱不是静态的——每次文档更新、每次重新抽取实体关系,图谱都在变化。今天的”正确答案”,明天可能就过时了。

    举个例子

    假设你的公司有一份内部技术架构文档:

    • 1 月版本:文档写着”订单服务使用 MySQL 数据库”
    • 3 月版本:架构升级,改成了”订单服务使用 PostgreSQL + Redis 缓存”

    你在 1 月标注的 Gold Answer 是:

    Q: 订单服务用什么数据库?
    A: MySQL

    到了 3 月,GraphRAG 系统重新索引了新文档,正确回答了”PostgreSQL + Redis”。但如果你还拿 1 月的 Gold Answer 去评估,系统反而会被判”错误”。

    更现实的场景

    在企业内部,文档更新频率远比想象中高:

    • API 文档每周都在改
    • 组织架构每季度调整
    • 技术选型每半年可能翻新一次

    每次文档更新后,你都需要重新标注 Gold Answer。一个有 500 个测试问题的评估集,每次更新可能有 30% 的答案需要修改——这意味着每次要重新审核 150 个答案。

    人工标注 Gold Answer 成本极高且不可靠

    问题本质

    GraphRAG 处理的问题往往是多跳推理、跨文档关联的复杂问题。对于这类问题,连人类专家都很难给出一个”唯一正确”的答案。

    举个例子

    假设问题是:

    “张三负责的项目中,哪些使用了已经 EOL(End of Life)的技术栈?”

    要回答这个问题,标注人员需要:

    1. 找到张三负责哪些项目(可能分散在 5 份文档中)
    2. 找到每个项目的技术栈(又是另外几份文档)
    3. 判断哪些技术栈已经 EOL(需要查外部信息)
    4. 综合以上信息给出答案

    假设真实情况是张三负责 4 个项目,其中 3 个用了 EOL 技术栈。标注员经过 1 小时的文档翻阅,写出了 Gold Answer:

    项目 A(Spring Boot 2.5)、项目 B(Log4j 1.x)、项目 C(Python 2.7)

    但标注员漏掉了项目 D——因为张三对项目 D 的负责关系写在一份会议纪要里,而不是正式的项目分配表中。

    现在来看评估结果:

    系统 回答 对比 Gold Answer 的得分
    普通 RAG 找到了项目 A、B(漏了 C) 召回率 2/3 = 0.67
    GraphRAG 找到了项目 A、B、C、D(通过会议纪要中的关系推理找到了 D) 召回率 3/3 = 1.0,但精确率 3/4 = 0.75(D 被判为”多余”)

    讽刺的是:GraphRAG 因为比 Gold Answer 还要正确,反而被扣了分。它通过图谱中的关系链(张三 → 参会 → 会议决议 → 负责项目 D)找到了标注员都遗漏的信息,但在评估框架里,这个”多出来的正确答案”被当成了错误。

    最终 F1 分数:
    – 普通 RAG:F1 = 0.80
    – GraphRAG:F1 = 0.86

    GraphRAG 明明找得更全、更准,分数优势却微乎其微——甚至在某些评估设置下(比如严格精确匹配),分数可能比普通 RAG 还低。Gold Answer 的天花板限制了对更优系统的识别能力。

    成本账

    标注一个复杂的 GraphRAG 测试问题,一个领域专家可能需要 30-60 分钟(需要翻阅多份文档、交叉验证)。如果你需要 200 个测试问题,那就是 100-200 小时的专家时间。而且这些答案的保质期可能只有几个月(参见论点一)。

    GraphRAG 的答案形式天然多样

    问题本质

    传统 RAG 往往回答事实性问题(”X 是什么”),答案相对固定。但 GraphRAG 擅长的是关系推理和综合分析,这类问题的”正确答案”本身就有多种合理表达。

    举个例子

    问题:

    “我们的微服务架构中,哪些服务之间存在循环依赖?”

    GraphRAG 可能回答:

    答案 A:服务 A → 服务 B → 服务 C → 服务 A 形成循环;服务 D 和服务 E 互相调用。

    答案 B:存在两组循环依赖:(1) A-B-C 三角循环 (2) D-E 双向依赖。建议优先解耦 A-B-C 循环,因为涉及核心交易链路。

    答案 C:检测到循环依赖路径:A→B→C→A。另外 D↔E 存在双向调用,但由于是异步消息,实际影响较小。

    三个答案都是”正确”的,但侧重点不同。用任何一个作为 Gold Answer,都会不公平地惩罚其他同样正确的回答。

    传统指标的失效

    用 ROUGE 分数对比上面三个答案:

    • 答案 A vs 答案 B:ROUGE-L 可能只有 0.3(措辞完全不同)
    • 答案 A vs 答案 C:ROUGE-L 可能有 0.5(有部分重叠)

    但从信息正确性来看,三个答案都应该得满分。Gold Answer + 文本相似度指标的组合,在这里完全失效了。

    LLM-as-Judge 正在取代 Gold Answer

    问题本质

    既然 Gold Answer 有这么多问题,业界正在转向一种新的评估范式:用 LLM 作为评判者(LLM-as-Judge),直接评估答案的质量,而不是和”标准答案”做文本对比。

    举个例子

    传统方式:

    系统答案: "PostgreSQL + Redis"
    Gold Answer: "MySQL"
    ROUGE 分数: 0.0  → 判定为错误 ❌
    

    LLM-as-Judge 方式:

    问题: "订单服务用什么数据库?"
    系统答案: "PostgreSQL + Redis"
    参考文档: [最新架构文档,明确写了 PostgreSQL + Redis]
    
    LLM 评判: 答案与文档一致,信息准确,得分 5/5 ✅
    

    LLM-as-Judge 的优势:

    维度 Gold Answer LLM-as-Judge
    是否需要人工标注 需要大量人工 不需要
    能否适应文档更新 需要重新标注 自动适应(参考最新文档)
    能否处理多种正确表达 不能 能(理解语义等价)
    评估成本 高(人工) 低(API 调用)
    评估速度 慢(天/周) 快(分钟)

    GraphRAG 的评估维度远超”答案正确性”

    问题本质

    Gold Answer 只能评估一个维度:答案内容是否正确。但 GraphRAG 系统的质量取决于很多其他因素,这些因素 Gold Answer 完全无法衡量。

    举个例子

    同一个问题,两个 GraphRAG 系统都给出了正确答案,但质量天差地别:

    系统 A 的回答

    张三负责项目 X,使用了 Spring Boot 2.5(已 EOL)。

    系统 B 的回答

    张三负责项目 X,使用了 Spring Boot 2.5(已于 2023 年 11 月停止维护)。此外,该项目还依赖 Log4j 1.x(2015 年 EOL,存在已知安全漏洞 CVE-2019-17571)。建议参考内部迁移指南 [链接] 进行升级。

    两个答案对比 Gold Answer 可能得分相同,但系统 B 明显更有价值——它提供了更完整的信息、安全风险提示和行动建议。

    真正重要的评估维度

    对于 GraphRAG 系统,我们更应该关注:

    • 图谱覆盖率:实体和关系是否被完整抽取?
    • 推理路径可解释性:系统是通过哪些节点和边得出结论的?
    • 信息完整度:是否遗漏了重要的关联信息?
    • 时效性:引用的信息是否是最新的?
    • 可操作性:答案是否给出了可执行的建议?

    这些维度都不是 Gold Answer 能评估的。

    那我们该怎么评估 GraphRAG?

    既然 Gold Answer 不再是银弹,以下是更适合 GraphRAG 的评估策略:

    1. LLM-as-Judge + 评估维度拆分:让 LLM 从准确性、完整性、相关性等多个维度分别打分
    2. 基于源文档的事实核查:检查答案中的每个事实是否能在源文档中找到依据
    3. 图谱质量指标:直接评估知识图谱的实体覆盖率、关系准确率
    4. 端到端用户满意度:让真实用户评价答案是否解决了他们的问题
    5. 回归测试而非绝对评分:关注系统更新前后的质量变化,而非追求绝对分数

    最后

    Gold Answer 并非毫无价值——在简单事实问答、系统冷启动阶段,它仍然是有用的基线。但在 GraphRAG 这类复杂系统中,过度依赖 Gold Answer 会带来三个风险:

    1. 虚假的安全感:Gold Answer 评分高不代表系统真的好用
    2. 维护负担:持续更新 Gold Answer 的成本可能超过它带来的价值
    3. 评估盲区:Gold Answer 无法覆盖 GraphRAG 最重要的质量维度

    与其花大量时间维护一套注定会过时的”标准答案”,不如把精力投入到更现代、更全面的评估体系中。GraphRAG 的评估,应该像 GraphRAG 本身一样——动态、多维、基于理解而非死记硬背。

  • 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 像俯瞰全文的编辑。选择哪种,取决于你的文档规模和对切分质量的要求。

  • 多个独立问题,该合并成一个请求还是拆开发?——LLM 并发处理的原理分析

    当你有 5 个互不相关的问题,是打包成一条消息发给 LLM,还是同时发 5 个请求?哪种更快?

    先说结论

    拆成多个独立请求并行发送,几乎总是更快。

    这不是直觉判断,而是由 LLM 的底层推理机制决定的。下面从原理层面逐步论证。

    一、LLM 生成文本的基本机制:自回归

    要理解这个问题,必须先搞清楚 LLM 是怎么”写字”的。

    LLM(如 GPT-4、Claude)采用自回归生成(autoregressive generation):每次只生成一个 token,然后把这个 token 拼回输入,再生成下一个 token。循环往复,直到生成结束。

    关键点:生成 N 个 token,就需要 N 次前向推理(forward pass)。

    这意味着:
    – 输出 100 个 token 的回答,需要 100 次推理步骤
    – 输出 500 个 token 的回答,需要 500 次推理步骤
    总输出长度直接决定了总耗时

    二、合并请求:总输出量叠加,延迟线性增长

    假设你有 5 个独立问题,每个问题的回答大约 200 tokens。

    方案 A:合并成一个请求

    你把 5 个问题塞进一条消息:

    请分别回答以下问题:
    1. xxx
    2. xxx
    3. xxx
    4. xxx
    5. xxx
    

    LLM 需要生成的总输出 ≈ 5 × 200 = 1000 tokens。由于自回归机制,这 1000 个 token 是串行生成的——第 201 个 token 必须等前 200 个生成完才能开始。

    总耗时 ≈ 1000 × 单 token 生成时间

    而且还有额外开销:
    – LLM 需要在回答之间维护上下文切换(”现在回答第 3 题”)
    – 更长的 KV Cache 导致每一步 attention 计算量递增
    – 实际输出往往超过 1000 tokens(格式化、过渡语句等)

    三、拆分请求:并行推理,耗时取决于最慢的那个

    方案 B:5 个问题拆成 5 个独立请求,同时发送

    每个请求独立生成 ~200 tokens。如果服务端有足够的并发处理能力(现代 LLM 服务都有),这 5 个请求会被并行处理

    总耗时 ≈ max(各请求耗时) ≈ 200 × 单 token 生成时间

    对比一下:

    方案 总输出 tokens 实际耗时(相对值)
    合并一个请求 ~1000+ ~1000 步(串行)
    拆分 5 个请求 每个 ~200 ~200 步(并行)

    理论加速比 ≈ 5x(等于问题数量)。

    四、为什么并行能成立?——服务端的 Continuous Batching

    你可能会问:LLM 服务器不是也有容量限制吗?5 个请求同时来,不会排队吗?

    现代 LLM 推理引擎(vLLM、TensorRT-LLM、TGI 等)都实现了 Continuous Batching(连续批处理):

    1. 多个请求共享同一次 GPU 矩阵运算:GPU 擅长的就是并行计算。把 5 个请求的 token 拼成一个 batch,一次 forward pass 就能同时为 5 个请求各生成一个 token。
    2. 动态调度:不同请求的输出长度不同,短的先结束,空出的 slot 立刻给新请求用。
    3. 吞吐量 vs 延迟的解耦:batch 越大,GPU 利用率越高,单位时间处理的 token 总量越多。

    所以从服务端视角看:
    – 5 个短请求并行 → GPU 做 5 路 batch 推理,每步同时产出 5 个 token
    – 1 个长请求 → GPU 做 1 路推理,每步只产出 1 个 token

    GPU 的并行计算能力在合并请求时被浪费了。

    五、Prefill 阶段的差异

    LLM 推理分两个阶段:

    1. Prefill(预填充):处理输入 prompt,计算所有输入 token 的 KV Cache。这一步可以并行处理所有输入 token,耗时与输入长度近似线性。
    2. Decode(解码):逐 token 生成输出。这一步是串行的。

    合并请求时:
    – Prefill 阶段:输入更长(5 个问题的描述拼在一起),prefill 时间更长
    – Decode 阶段:输出更长,decode 时间更长

    拆分请求时:
    – 每个请求的 prefill 更短,且 5 个 prefill 可以并行或 pipeline 执行
    – 每个请求的 decode 更短,且并行进行

    两个阶段都是拆分更优。

    六、还有一个容易忽略的因素:质量

    除了速度,合并请求还有质量风险:

    • 注意力稀释:LLM 在一次生成中处理多个不相关任务时,对每个任务的”专注度”下降。研究表明,prompt 中无关信息越多,回答质量越低(Lost in the Middle 现象)。
    • 格式混乱:5 个问题的回答容易出现编号错乱、遗漏、答非所问。
    • 错误传播:如果第 2 个问题的回答出了问题,LLM 可能在后续回答中受到干扰(自回归的”惯性”)。

    拆分请求则完全隔离了上下文,每个问题都能获得 LLM 的”全部注意力”。

    七、什么时候合并反而更好?

    公平起见,有少数场景合并可能更合适:

    1. 问题之间有隐含关联:虽然你认为独立,但 LLM 如果看到全貌可能给出更一致的回答(比如同一份报告的不同章节)。
    2. API 调用有严格的 rate limit:如果你的 API 配额是每分钟 3 次请求,那 5 个问题只能合并。
    3. 网络延迟远大于生成时间:如果每次 API 调用的网络往返是 2 秒,而生成只要 0.5 秒,那拆分 5 次的网络开销(5 × 2s = 10s)可能超过合并的生成时间。但这种情况在实际中很少见——现代 API 的网络延迟通常在 100-300ms,远小于生成时间。
    4. 极短回答:如果每个问题只需要一两个词的回答,那 prefill 的开销占比更大,合并可以减少重复的 prefill 成本。

    八、实际验证思路

    如果你想自己验证,可以这样测:

    import asyncio
    import time
    import aiohttp
    
    async def ask_single(session, question):
        start = time.time()
        # 调用 LLM API
        resp = await session.post(API_URL, json={"prompt": question})
        result = await resp.json()
        return time.time() - start
    
    async def benchmark():
        questions = ["问题1", "问题2", "问题3", "问题4", "问题5"]
    
        async with aiohttp.ClientSession() as session:
            # 方案 A:合并
            start = time.time()
            combined = "请分别回答:\n" + "\n".join(questions)
            await ask_single(session, combined)
            time_combined = time.time() - start
    
            # 方案 B:并行
            start = time.time()
            await asyncio.gather(*[ask_single(session, q) for q in questions])
            time_parallel = time.time() - start
    
        print(f"合并: {time_combined:.2f}s")
        print(f"并行: {time_parallel:.2f}s")
        print(f"加速比: {time_combined / time_parallel:.1f}x")
    

    根据经验,5 个中等复杂度的独立问题,并行通常能获得 3-5x 的加速。

    总结

    维度 合并一个请求 拆分多个并行请求
    生成速度 慢(串行输出所有答案) 快(并行生成,取最慢值)
    GPU 利用率 低(单序列推理) 高(batch 并行推理)
    回答质量 可能下降(注意力稀释) 更好(独立上下文)
    API 调用数 1 次 N 次
    适用场景 有 rate limit / 问题极短 问题独立且需要详细回答

    核心原理一句话总结:LLM 的自回归机制决定了输出是串行的,合并请求 = 强制串行所有输出;拆分请求 = 利用服务端并行能力同时生成多个输出。独立问题拆开发,是用空间(并发 slot)换时间的经典策略。

  • 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 日。