标签: NER

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