知识图谱与 LLM 的实战应用——结合开放式大模型与领域本体的命名实体消歧

7 阅读20分钟

本章涵盖

  • 理解传统 NED 工具的局限性
  • 将通用型大语言模型与领域本体结合用于 NED
  • 通过最短路径检测、路径到文本的转换以及文本路径摘要,执行多步骤消歧

第 7 章聚焦于命名实体消歧(NED),重点介绍了 scispaCy 的作用。scispaCy 是一个构建在 spaCy 框架之上的专业自然语言处理(NLP)工具,专门用于处理文档和出版物,并提供了生物医学领域的预训练模型。

8.1 理解传统 NED 系统的局限性

scispaCy 集成了词表和本体,例如统一医学语言系统(UMLS),这些资源提供了规范化实体,可用于对文本中的提及进行消歧。然而,这种方法存在一些局限:

  • 它是为特定应用领域设计的:生物医学领域。
  • 在扩展和更新参考知识库以纳入新实体和新术语时,会面临挑战。
  • 它没有充分利用知识库中可获得的大量信息。
  • 它没有在消歧任务中使用实体之间已有的关系和路径。

为了理解最后这一点的影响,我们先回顾一下第 7 章开头讨论过的例子,当时使用的是欧洲疾病预防与控制中心(ECDC)的这段引文:

In the week of 13 April, Belize reported for the first time mosquito-borne Zika virus transmission. Update on the observed increase of congenital Zika syndrome and other neurological complications Microcephaly and other fetal malformations potentially associated with Zika virus infection.

scispaCy 利用术语 “Zika” 周围的上下文词语,识别出了三个正确的消歧实体。结果如下所示。

代码清单 8.1 使用 scispaCy 进行候选选择与排序

Recognized entity: Zika virus 75 85
Ranked target candidates:
- C0318793 Zika Virus  #1
- C0276289 Zika Virus Infection
- C4687930 Zika Virus Antibody Measurement

Recognized entity: congenital Zika syndrome 135 159
Ranked target candidates:
- C4546023 Congenital Zika Syndrome  #2

Recognized entity: Zika virus infection 268 288
Ranked target candidates:
- C0276289 Zika Virus Infection  #3
- C0318793 Zika Virus
- C4687930 Zika Virus Antibody Measurement

#1 在字符 75 到 85 之间检测到的目标实体 “Zika Virus”
#2 在字符 135 到 159 之间检测到的目标实体 “Congenital Zika Syndrome”
#3 在字符 268 到 288 之间检测到的目标实体 “Zika Virus Infection”

但现在,让我们测试一个略有不同的例子。这个例子中不再包含像 “congenital” 和 “syndrome” 这样的上下文支撑词:

Zika belongs to the Flaviviridae virus family, and it is spread by Aedes mosquitoes. Individuals affected by Zika disease and other syndromes like chikungunya fever often experience symptoms like viral myalgia, infectious edema, and infective conjunctivitis. Severe outcomes of Zika are due to its capacity to cross the placental barrier during pregnancy, causing microcephaly and congenital malformations.

与前一个例子相比,这里已经没有那些能够支持消歧阶段的词语了。我们来看一下 scispaCy 的输出。

代码清单 8.2 使用 scispaCy 模型进行候选选择与排序

Recognized entity: Zika 0 4
Ranked target candidates:
- C0276289 Zika Virus Infection
- C0318793 Zika Virus
- C4687930 Zika Virus Antibody Measurement

Recognized entity: Zika disease 109 121
Ranked target candidates:

Recognized entity: Zika 278 282
Ranked target candidates:
- C0276289 Zika Virus Infection
- C0318793 Zika Virus
- C4687930 Zika Virus Antibody Measurement

这个模型在第一句和第三句中,都将 “Zika” 消歧为实体 “C0276289 Zika Virus Infection” ,但对于第二句中的该提及,它却没有检测到目标实体。

本章将用一种新的方法来解决这些局限,该方法结合了开放式大语言模型(LLMs)领域本体。与 scispaCy 这类基于特定领域的工具不同,这种方法也可以用于其他应用领域,只要这些领域中存在丰富的本体资源即可。

8.2 导入领域本体

为了驱动消歧过程,我们将使用第 7 章中介绍过的 SNOMED(Systematized Nomenclature of Medicine) 本体。作为回顾,SNOMED 是一个多语言临床术语库,包含超过 45 万个概念以及一套丰富的关系类型。在我们的示例场景中,仍将使用以下两个文件:

  • sct2_Description_Full-en_US1000124_20220901.txt(实体名称与别名,以及实体之间的关系)
  • sct2_Relationship_Full_US1000124_20220901.txt(用于标识实体和关系的数值编码)

注意 这些文件的示例见 7.5.2 节。

图 8.1 展示了 SNOMED 的层次结构以及信息如何沿节点传播。代码清单 8.3–8.5 分别完成以下工作:从 sct2_Relationship_Full_US1000124_20220901.txt 在 Neo4j 中创建节点和关系;从 sct2_Description_Full-en_US1000124_20220901.txt 提取名称和别名;沿着层次结构,将第一层节点的信息传播到更深层的节点。

注意 这些代码清单的带注释版本以及更多细节,请参见 7.6.4 节。完整示例代码已放在本书的在线代码仓库中。

image.png

图 8.1 SNOMED 层次结构示例。位于更深层级的节点,可以利用第一层节点的信息进行分类;这些第一层节点代表本体中的原型实体。

代码清单 8.3 导入 SNOMED:加载关系

[...]
class SnomedRelationshipsImporter(BaseImporter): 
  [...]  
  def set_constraints(self): 
    queries = [
        (
            "CREATE CONSTRAINT IF NOT EXISTS FOR (n:SnomedEntity) "
            "REQUIRE n.id IS UNIQUE"
        ),
        (
            "CREATE INDEX snomedNodeName IF NOT EXISTS "
            "FOR (n:SnomedEntity) ON (n.name)"
        ),
        (
            "CREATE INDEX snomedRelationId IF NOT EXISTS "
            "FOR ()-[r:SNOMED_RELATION]-() ON (r.id)"
        ),
        (
            "CREATE INDEX snomedRelationType IF NOT EXISTS "
            "FOR ()-[r:SNOMED_RELATION]-() ON (r.type)"
        ),
        (
            "CREATE INDEX snomedRelationUmls IF NOT EXISTS "
            "FOR ()-[r:SNOMED_RELATION]-() ON (r.umls)"
        ),
    ]

    for q in queries:
      self.connection.query(q, db=self.db)

  def import_snomed_rels(self): 
    query = """
       UNWIND $batch as item
       MERGE (e1:SnomedEntity {id: item.sourceId})
       MERGE (e2:SnomedEntity {id: item.destinationId})
       MERGE (e1)-[:SNOMED_RELATION {id: item.typeId}]->(e2)
       FOREACH(ignoreMe IN CASE WHEN item.typeId = '116680003' 
       THEN [true] ELSE [] END|
         MERGE (e1)-[:SNOMED_IS_A]->(e2) 
       )
       """

size = self.get_csv_size(snomedRels_file) 
self.batch_store(snomed_rels_query, self.get_rows(snomedRels_file), size=size)

代码清单 8.4 导入 SNOMED:加载名称和别名

[...]
class SnomedNamesImporter(BaseImporter):
  [...]

  def import_snomed_names(self, snomedNames_file):
        snomed_names_concepts_query = """
        UNWIND $batch as item
        MATCH (e1:SnomedEntity)
        -[r:SNOMED_RELATION {id: item.conceptId}]->
        (e2:SnomedEntity)
        WHERE item.conceptId <> '116680003' AND r.id = item.conceptId 
        SET r.type = CASE 
                WHEN r.type IS NULL THEN item.termAsType
                ELSE r.type END, 
            r.aliases = CASE 
                WHEN item.termAsType IN r.aliases THEN r.aliases
                ELSE coalesce(r.aliases,[]) + item.termAsType END
        """

        snomed_names_entities_query = """
        UNWIND $batch as item
        MATCH (e:SnomedEntity {id: item.conceptId})
        SET e.name = CASE 
                WHEN e.name IS NULL THEN item.term
                ELSE e.name END, 
            e.aliases = CASE 
                WHEN item.term in e.aliases THEN  e.aliases
                ELSE coalesce(e.aliases, []) + item.term END 
        """
        size = self.get_csv_size(snomedNames_file)
        self.batch_store(
          snomed_names_concepts_query,
          self.get_rows(snomedNames_file),
         size=size)
        self.batch_store(
          snomed_names_entities_query,
          self.get_rows(snomedNames_file),
          size=size)
[...]

代码清单 8.5 导入 SNOMED:从第一层节点传播标签

[...]
class SnomedLabelPropagator():    
[...]  

  def get_rows(self):
    propagation_query = """
      MATCH p=(n:SnomedEntity)<-[:SNOMED_IS_A]-(m:SnomedEntity) 
      WHERE n.id= "138875005" // Root node
      WITH distinct m as first_node

      CALL apoc.path.expandConfig(first_node, { // #A
              relationshipFilter: '<SNOMED_IS_A',
                minLevel: 1,
                maxLevel: -1,
                uniqueness: 'RELATIONSHIP_GLOBAL'
            }) yield path

     UNWIND nodes(path) as other_level // #B
     WITH first_node, collect(DISTINCT other_level) as uniques
             UNWIND uniques as unique_other_level
             WITH first_node,unique_other_level
             WHERE not first_node.name in coalesce(unique_other_level.type,[])

             RETURN unique_other_level.id as id, first_node.name as label - C
            """

   with self._driver.session(database=self._database) as session:
     result = session.run(query=propagation_query)
     for record in iter(result):
        yield dict(record)
[...]

这里使用 SNOMED_IS_A 关系,通过实体之间的层级连接,把语义类型沿着树形结构传播下去。

8.3 使用 Ollama 和 Llama 3.1 8B 搭建模型

在前几章中,我们已经探讨了如何使用 OpenAI API 执行 NLP 任务。现在,我们将在此基础上进一步扩展,通过 OllamaLlama 3.1 8B(由 Meta 发布)在本地部署一个 NED 系统。

Ollama 是一个开源工具,允许用户直接在本地机器上运行 LLM。通过在本地运行模型,我们能够完全掌控自己的数据,同时降低延迟,并减少对外部提供商的依赖。Llama 3.1 8B 是一个拥有 80 亿参数的开源 LLM。它支持最长 128,000 tokens 的上下文长度,并针对多语言信息处理进行了优化。同时,它也被设计为能够高效部署在消费级硬件上。

要在本地机器上部署 Llama 3.1 8B,你首先需要下载并安装 Ollama。该工具兼容 macOS、Linux 和 Windows,安装文件可在 https://ollama.com/ 获取。Ollama 同时提供命令行和图形用户界面(GUI)两种方式;在我们的 NED 系统中,我们使用了如下命令行指令来下载并部署 Llama 3.1 8B。

代码清单 8.6 下载并安装 Llama 3.1 8B 的 Ollama 命令

ollama serve
ollama pull llama3.1:latest

Ollama 内建了对 OpenAI Chat Completions API 的兼容支持,因此我们可以像前几章那样,直接用 Python 代码与本地部署的模型交互。这极大简化了将模型集成进 NED 系统的过程。下面的代码清单展示了与模型交互所需的 Python 类。

代码清单 8.7 在 Python 中运行我们的 Llama 3.1 8B 模型

from openai import OpenAI

class LLM_Model():
    def __init__(self, url='http://localhost:11434/v1', key="default"):
        self.client = OpenAI(
          base_url= url,  #1
          api_key = key, #2
        )

    def generate(self, messages):
        response = self.client.chat.completions.create(
            model="llama3.1:latest",  #3
            messages=messages,
            temperature=0,
            max_tokens=4000,
            top_p=1,
            frequency_penalty=0,
            presence_penalty=0,
        )
        # It assumes as response the ChatGPT API format
        return response.choices[0].message.content

#1 模型服务所对应的默认 URL
#2 api_key 是 OpenAI Chat Completions API 所要求的字段,但对开源模型实际上不会被使用
#3 指定由 Ollama 下载的 llama3.1:latest

注意 我们在 2024 年 10 月使用当时最新版本的 Llama 3.1 模型,并配合后续章节中的提示词,得到了本示例中的结果。

使用像 Llama 3.1 这样的通用模型来执行 NED,将展示 LLM 在与领域本体结合时,如何在细分领域中同样取得良好效果。在下一节中,我们将拆解这一过程,并展示系统如何在复杂生物医学文本中解释和消歧实体,从而促进信息抽取与分析。

8.4 端到端 NED 流程

图 8.2 展示了我们示例中 NED 流程的思维模型:从输入文档一直到得到消歧后的提及。整个流程始于一份包含非结构化文本的输入文档,该文档会被一个基于 LLM 的命名实体识别(NER)组件分析。在这一阶段,LLM 利用领域本体中嵌入的知识,识别并标注输入文本中相关的生物医学实体。例如,像 “Zika” 这样的术语,可以依据 SNOMED 被识别为一个 Disease 概念。这个步骤将原始文本转换成结构化数据,供后续阶段继续处理。

image.png

图 8.2 一个为生物医学文本处理而设计的 NED 工作流,该系统使用 LLM 与领域本体(如 SNOMED)。工作流的每个阶段都需要多次交互,以便对输入文本中的实体进行准确消歧。

接下来,系统进入 NED 候选选择(CS) 阶段。一个全文搜索机制会为每个已识别的实体提及生成可能匹配项的列表。例如,术语 “Zika” 在 SNOMED 中可能对应多个实体,如 Zika Virus、Zika Virus InfectionCongenital Zika Virus Infection。这个步骤会建立一个潜在消歧目标池;系统将在下一阶段对它们进行评估,以找到最准确的匹配。

在最后一个阶段,也就是 NED 候选消歧(CD) 阶段,LLM 会进一步细化选择结果,为每个提及确定最精确的实体匹配。这里采用的是一种多步骤方法:先识别候选项在本体结构中的最短路径,然后将相关路径细节转换并总结为文本,从而利用上下文知识支持消歧,确保输入文档中提到的每个实体都被准确映射到本体中对应的消歧实体。

这种由 LLM 驱动的方法,在消歧过程的每一步都集成了领域本体。通过纳入本体中的层级结构和关系结构,模型就能在实体分类和消歧上作出更有依据的判断,尤其是在术语可能具有多重含义或多种关联的复杂场景中。

8.4.1 命名实体识别

NER 的目标,是从非结构化文本中识别并分类命名实体,并将其归入预定义类别,例如疾病、生物体和医疗操作。正如前几章所讨论的,一种实用的方法是使用提示工程(prompt engineering) :在提示中显式定义我们关心的实体类型。很多时候,数据科学家或数据工程师会与领域专家合作,来识别并定义这些实体。

在我们的场景中,我们引入了 SNOMED 的结构化医学知识,使 LLM 能够在生物医学文本中执行更精确、更具上下文感知能力的实体识别。图 8.3 展示了 NER 的输入与输出。

NER 要求我们从本体中取回所有预定义类别。下面的查询会从 SNOMED 中提取这些类别。

代码清单 8.8 从 SNOMED 中提取预定义类别

MATCH (n:SnomedEntity)
UNWIND n.type as named_entity
WITH DISTINCT named_entity, count(named_entity) as num_of_entities
ORDER BY num_of_entities DESC
RETURN collect(named_entity) as named_entities

我们可以把这个查询结果用于 NER 任务的提示词中。代码清单 8.9 是我们为此定义的提示消息的简化版本;完整提示词见代码仓库。

image.png

图 8.3 NED 的第一阶段是 NER。在我们的场景中,命名实体直接来源于本体。在 SNOMED 中,类别由本体的第一层节点定义,而这些节点的信息会传播到其他所有节点。

代码清单 8.9 NER 的简化提示词

system = """You are an assistant capable of extracting named entities in the
medical domain. 
Your task is to extract ALL single mentions of named entities from the text. 
You must only use one of the pre-defined entities from the following list:
{named_entities}. 
No other entity categories are allowed. 
For each sentence, extract the named entities and present the output in 
valid JSON format""".format(named_entities=named_entities))

input = """Risk factors for rhinocerebral mucormycosis include poorly
controlled diabetes mellitus and severe immunosuppression."""

assistant = [
  {
    "sentence": """Risk factors for rhinocerebral mucormycosis include
 poorly controlled diabetes mellitus and severe immunosuppression.""",
    "entities": [
      {
        "id": 0,
        "mention": "Risk factors",
        "label": "Events"
      },
      {
        "id": 1,
        "mention": "rhinocerebral mucormycosis",
        "label": "Disease"
      },
      {
        "id": 2,
        "mention": "poorly controlled diabetes mellitus",
        "label": "Disease"
      },
      {
        "id": 3,
        "mention": "severe immunosuppression",
        "label": "Qualifier value"
      }
    ]
  }
]

这个提示词的结构如下:

  • 系统指令 —— 系统消息要求从医学领域文本中抽取属于 SNOMED 本体类别的命名实体,并避免返回无关或超出范围的类别。

  • 输入文本 —— 在这个示例中,输入文本讨论的是某种医学状况(rhinocerebral mucormycosis)的风险因素,列举了 diabetes mellitus 和 immunosuppression 等医学术语,这些术语都需要被识别。

  • 助手输出 —— 系统返回结构化的 JSON 输出,包含以下字段:

    • sentence —— 系统按句子逐句处理文本,以确保对每一个意义单元进行单独分析。
    • entities —— 输出包含一个已识别实体的数组。每个条目包括一个 ID(用于在句子中唯一标识该实体)、一个在文本中发现的实体提及(如 “rhinocerebral mucormycosis”),以及一个依据 SNOMED 类别所赋予的标签,例如 Disease

下面给出一个传递给 LLM 的输入文本示例,该 LLM 已经被上述 NER 提示词进行了指令约束。

代码清单 8.10 用于 NER 的用户消息

user = """Severe outcomes of Zika are due to its capacity to cross the
placental barrier during pregnancy, causing microcephaly
and congenital malformations""".

下面则是 Llama 3.1 生成结果中的一个子集。

代码清单 8.11 来自代码清单 8.10 句子的 NER 输出

{
  "sentence": """Severe outcomes of Zika are due to its capacity to cross
the placental barrier during pregnancy, causing microcephaly
and congenital malformations. """,
  "entities": [
    {
      "id": 0,
      "mention": "Zika",
      "label": "Organism",
      "start": 19,
      "end": 22
    },
    {
      "id": 1,
      "mention": "microcephaly",
      "label": "Clinical finding (finding)",
      "start": 105,
      "end": 116
    },
    {
      "id": 2,
      "mention": "congenital malformations",
      "label": "Clinical finding (finding)",
      "start": 122,
      "end": 145
     }
   ]
}

从这个结果中,我们可以识别并分类出三个不同的实体:

  • “Zika” —— 被识别为 Organism,其在句子中的位置从字符 19 到 22。
  • “Microcephaly” —— 被标注为 Clinical finding (finding) ,表示它是一种医学状况。该提及出现在字符 105 到 116 之间。
  • “Congenital malformations” —— 同样被标注为 Clinical finding (finding) ,位置在字符 122 到 145 之间。

LLM 在准确检测一个提及在句子中的起始字符和结束字符时往往会遇到困难。因此,我们在后处理阶段使用了下面这个 Python 函数来生成 startend 字段。

代码清单 8.12 计算提及起始和结束字符位置的 Python 函数

def find_all_mention_indices(self, string, substring):
  indices = []
  start_index = 0

  while True:
    start_index = string.find(substring, start_index)

    if start_index == -1:
      break  # No more occurrences found

    end_index = start_index + len(substring) - 1
    indices.append((start_index, end_index))

    # Move start_index forward to search for the next occurrence
    start_index += len(substring)

  return indices

这个函数能够识别实体提及的位置,而这类信息通常是传统 NER 系统可以直接给出的。

8.4.2 候选选择

NED 流程的第二阶段是 CS(Candidate Selection) ,也就是识别那些可能与每个已识别命名实体的预期含义相匹配的相关实体或概念。图 8.4 展示了这一阶段的输入与输出,并突出说明了候选实体是如何被选出的。

image.png

图 8.4 NED 的第二阶段是候选选择。对于前一步检测到的每个实体提及,这一阶段会检索出可能与之对应的候选项。

如下一个代码清单所示,CS 的输入由两部分组成:一是 NER 过程在输入文本中标注出的实体提及,二是领域本体。其输出则是与每个提及相关联的一个或多个候选实体列表。

在这个步骤中,我们不使用 LLM,原因有两个。第一,我们希望直接从领域本体中检索候选项,而不是依赖 LLM 自身隐含的知识。第二,本体规模过大,不可能整体装入提示词中。因此,为了高效执行 CS,我们使用 Neo4j 的全文搜索能力,它可以识别本体中那些与提及字符串高度匹配的条目。

代码清单 8.13 用于 CS 的 Python 类

class CandidateSelection:
[...]
  def full_text_query(self):
    query = """
            CALL db.index.fulltext.queryNodes("names", $fulltextQuery,
            {limit: $limit})
            YIELD node
            WHERE node:SnomedEntity AND ANY(x IN node.type 
            WHERE x IN $labels)
            RETURN distinct node.name AS candidate_name, node.id 
            AS candidate_id
            """

    return query

  def generate_full_text_query(self, input):
    full_text_query = ""
    words = [el for el in input.split() if el]

    if len(words) > 1:
      for word in words[:-1]:
        full_text_query += f" {word}~0.80 AND "
        full_text_query += f" {words[-1]}~0.80"
    else:
      full_text_query = words[0] + "~0.80"

    return full_text_query.strip() [...]

我们指定 $labels 参数,以缩小查询的搜索空间。这些标签来自 NER 阶段的输出,从而强制系统只识别与所提及实体类型相关的那一部分实体。

注意 虽然全文搜索机制已经是一种可行方案,但它还可以通过引入向量搜索进一步增强,从而找回那些无法通过纯文本匹配识别出来的额外候选项。

当我们传入 “Zika” 作为输入词时,查询返回的 JSON 结果如下所示。

代码清单 8.14 来自 CS 步骤的更新后 NED 结果示例

{
  "id": 0,
  "mention": "Zika",
  "label": "Organism",
  "start": 19,
  "end": 22,
  "candidates": [
    {
      "snomed_id": "50471002",
      "name": "Zika virus"
    },
    {
      "snomed_id": "3928002",
      "name": "Zika virus disease"
    },
    {
      "snomed_id": "762725007",
      "name": "Congenital Zika virus infection"
    }
  ]
}

candidates 字段包含了系统为每个提及找到的一组潜在匹配项,也就是候选项。每个候选项都代表基于 SNOMED 对该提及的一种可能解释。每个候选项由以下字段刻画:

  • snomed_id —— 该概念在 SNOMED 中的唯一标识符
  • name —— 与该 snomed_id 关联的医学实体名称

在这个例子中,候选项分别是:

  • “Zika virus” (50471002)
  • “Zika virus disease” (3928002)
  • “Congenital Zika virus infection” (762725007)

这些候选项代表了 “Zika” 在临床术语体系中的可能医学含义,也为接下来消歧阶段的进一步细化奠定了基础。

8.4.3 候选消歧

NED 流程的最后一个阶段是 CD(Candidate Disambiguation) ,见图 8.5。我们采用一种策略:利用句子中与目标实体共同出现的其他医学实体所提供的上下文信息。通过将这些实体与领域本体中的结构化知识进行交叉参照,我们可以验证并细化已选择的候选项,从而确定最准确的匹配。

例如,考虑一个同时提到 “Zika”“microcephaly” 的句子。 “microcephaly”“Zika” 的共同出现提供了宝贵的上下文:它暗示更可能指向 Congenital Zika virus infection,因为这种感染已知会导致小头畸形。消歧过程就可以利用这一共现关系,把 Congenital Zika virus infection 排在其他可能的 “Zika” 含义(例如一般病毒或无关术语)之前。

image.png

图 8.5 NED 的第三阶段是候选消歧。其目标是结合图算法(最短路径检测)与 LLM,从前一阶段识别出的所有候选项中选出最佳匹配。

为了为输入文档中识别出的每个提及生成消歧后的实体,我们执行三个步骤:

  • 检测最短路径 —— 识别句子中不同提及所关联的候选实体之间的最短路径。通过映射这些连接,我们可以建立潜在关系,从而帮助澄清每个提及真正想表达的含义。
  • 将路径翻译为文本 —— 为了发挥 LLM 在处理文本信息上的优势,我们将连接候选实体的每条图路径翻译成自然语言句子。这样一来,LLM 就能够以它最擅长处理的格式——自然语言——去理解关系信息。
  • 总结文本路径 —— 把所有由图路径翻译而来的文本信息汇总为一个综合性解释。这个摘要抓住了关系的本质,并帮助 LLM 作出更准确的消歧决策。

图 8.6 对这一消歧流程进行了概览。LLM 支持了“路径到文本转换”和“文本路径摘要”两个步骤,使系统能够高效解释和压缩关系信息。接下来要介绍的最短路径检测,则利用 Neo4j 的 Graph Data Science(GDS) 库来识别候选项之间的连接关系。

image.png

图 8.6 NED 候选消歧步骤:
(1)检测句子中所有实体提及相关候选项之间的最短路径;
(2)将检测到的路径翻译为自然语言句子;
(3)将这些句子总结为对消歧有帮助的文本。

检测最短路径

这一步的目标,是在 CS 阶段为每个医学实体提及识别出的所有可能候选项之间,检测最短路径。下面的代码清单展示了用于执行这一操作的查询。

代码清单 8.15 从 SNOMED 本体中提取相关路径的 Python 类

class PathExtraction():
  def __init__(self, model, store, candidates, named_entities):
    self.model = model
    self.store = store
    self.candidates = candidates
    self.named_entities = named_entities
  [...]

  def get_co_occs_query(self, s1_id, s2_id):
    query = f"""
      CALL gds.degree.stream('snomedGraph')
      YIELD nodeId, score
      WITH gds.util.asNode(nodeId).name AS name, score AS degree
      ORDER BY degree DESC
      LIMIT 350
      WITH collect(name) as hub_nodes
      MATCH (s1), (s2)
      WHERE s1.id="{s1_id}" AND
            s2.id="{s2_id}"
      WITH s1,
           s2,
           allShortestPaths((s1)-[:SNOMED_RELATION*1..2]-(s2)) AS paths,
           hub_nodes
      UNWIND paths AS path
      WITH relationships(path) AS path_edges, nodes(path) as path_nodes,
              hub_nodes
      WITH [n IN path_nodes | n.name] AS node_names,
           [r IN path_edges | r.type] AS rel_types,
           [n IN path_edges | startnode(n).name] AS rel_starts,
           hub_nodes
      WHERE not any(x IN node_names WHERE x IN hub_nodes)
      WITH [i in range(0, size(node_names)-1) | CASE      WHEN i = size(node_names)-1      THEN "(" + node_names[size(node_names)-1] + ")"
      WHEN node_names[i] = rel_starts[i]
      THEN "(" + node_names[i] + ")" + '-[:' + rel_types[i] + ']->'
      ELSE "(" + node_names[i] + ")" + '<-[:' + rel_types[i] + ']-' END] 
      as string_paths
      RETURN DISTINCT apoc.text.join(string_paths, '') AS `Extracted paths`
    """.format(s1_id=s1_id, s2_id=s2_id, named_entities=named_entities)
    return query
[...]

这个查询的关键步骤如下:

  • 度数计算 —— 查询首先通过 CALL gds.degree.stream 从图中取出“度数”(关系数量)最高的节点。这些节点代表高度连接的枢纽节点(hub nodes) ,稍后会被排除,以便聚焦于更有意义、更加具体的连接。
  • 最短路径搜索 —— 查询基于实体 ID,在两个实体 s1s2 之间寻找所有最短路径,并将路径长度限制在一跳或两跳(关系)以内。这些路径随后会过滤掉经过“枢纽”节点的部分,从而避免那些泛化、过宽的关系。
  • 路径转换 —— 一旦路径被识别出来,查询会展开这些路径,并收集其中涉及的节点和关系。然后,它将这些路径格式化为可读字符串,用来显示关系的方向和类型(例如 (n1)-[:REL_TYPE]->(n2))。

下面的代码清单展示了一组已检测到的路径示例。

代码清单 8.16 使用 Neo4j GDS 库检测到的路径

[  {    "id": 1,    "path": "(Congenital Zika virus infection)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Micrencephaly)"  },  {    "id": 2,    "path": "(Congenital Zika virus infection)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Acrocephaly)"  },  {    "id": 3,    "path": "(Congenital Zika virus infection)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Multiple congenital malformations)"  },  {    "id": 4,    "path": "(Congenital Zika virus infection)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Congenital malformation)"  },  {    "id": 5,    "path": "(Congenital Zika virus infection)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-([X]Other congenital malformations)"  },  {    "id": 6,    "path": "(Micrencephaly)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Multiple congenital malformations)"  },  {    "id": 7,    "path": "(Micrencephaly)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Congenital malformation)"  },  {    "id": 8,    "path": "(Micrencephaly)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-([X]Other congenital malformations)"  },  {    "id": 9,    "path": "(Acrocephaly)-[:OCCURRENCE]->(Congenital)
    <-[:OCCURRENCE]-(Multiple congenital malformations)"  },  {    "id": 10,    "path": "(Acrocephaly)-[:IS_A]->(Craniosynostosis syndrome)
    -[:IS_A]->(Congenital malformation)"  },  {    "id": 11,    "path": "(Acrocephaly)-[:PATHOLOGICAL_PROCESS_(ATTRIBUTE)]
    ->(Pathological developmental process)
    <-[:PATHOLOGICAL_PROCESS_(ATTRIBUTE)]-(Congenital malformation)"  },  {   "id": 12,   "path": "(Acrocephaly)-[:OCCURRENCE]->(Congenital)
   <-[:OCCURRENCE]-(Congenital malformation)"  },  {  "id": 13,  "path": "(Acrocephaly)-[:OCCURRENCE]->(Congenital)
  <-[:OCCURRENCE]-([X]Other congenital malformations)" }]

在这个 JSON 输出中,每个条目都包含一个 id 和一条 path,而每条路径都展示了基于本体路径中的共现关系,生物医学实体之间的关联。具体来看:

  • Congenital Zika virus infection 的路径 —— 许多路径都以 Congenital Zika virus infection 开头,并通过 [:OCCURRENCE] 关系与多种先天性病症相连,例如 Micrencephaly、AcrocephalyMultiple congenital malformations。这意味着 Congenital Zika virus infection 与这些病症存在关联,可能表现为病因关系或共现关系。
  • 共享的先天性条件节点 —— Congenital 是一个共同节点,它把 MicrencephalyAcrocephaly 等多个先天性病症连接起来。这个中心节点表明,这些病症共享相似的发生属性。
  • 替代性关系 —— 有些路径使用了 [:IS_A][:PATHOLOGICAL_PROCESS_(ATTRIBUTE)] 这样的关系,体现出层次关系或属性关系。例如,Acrocephaly 被归类在 Craniosynostosis syndrome 之下,而后者又与 Congenital malformation 相连。

将路径翻译为文本

这一步会把图路径翻译成句子,从而使 LLM 能够以其最擅长理解的格式——自然语言——来处理复杂关系数据。这样的转换让模型更容易理解实体之间的连接关系,从而提供有助于区分相似术语的上下文。下面给出一个将路径翻译成句子的简化提示版本。

代码清单 8.17 将路径翻译成句子的简化提示词

system = """You are an assistant capable of translating a Neo4j graph path 
into a clear sentence. 
Use the exact entity names from the path while generating the sentence.
The sentences will assist a large language model (LLM) in disambiguating
biomedical entities. 
Ensure the output is a valid JSON with no extraneous characters."""

input = {
  "path": "
  (Hypertension)-[:RISK_FACTOR_FOR]->(Cardiovascular Disease)
  <-[:ASSOCIATED_WITH]-(Myocardial Infarction)"
}

assistant = {
  "sentence": "Hypertension is a risk factor for cardiovascular
  disease. Myocardial infarction is also associated with cardiovascular
  disease, indicating that hypertension may increase the risk of
  experiencing a myocardial infarction through its connection to
  cardiovascular disease."
}

这个提示的结构如下:

  • 系统指令 —— 系统负责把 Neo4j 图路径翻译成清晰、可读的自然语言句子。
  • 输入图路径 —— 在这个例子中,输入是一条来自 Neo4j 数据库的图路径,它可能表示复杂的关系结构。
  • 助手输出 —— 系统返回一个合法的 JSON 结构,其中展示生成出的句子。

每一条图路径都被翻译成一个清晰的句子,如下所示。

代码清单 8.18 将路径翻译成自然语言后的结果

[{  "sentence": "A Congenital Zika virus infection occurrence is associated
  with a Congenital occurrence, which in turn is associated with
  Micrencephaly." }, {  "sentence": "A Congenital Zika virus infection occurrence is associated
  with a Congenital occurrence, which in turn is associated with an
  Acrocephaly occurrence." },[...],
 {
  "sentence": "Micrencephaly occurs in Congenital and Multiple congenital
  malformations also occur in Congenital."
 },
 {
  "sentence": "Micrencephaly occurs in Congenital and is also an occurrence
  of Congenital malformation."
 },
 [...],
 {
  "sentence": "Acrocephaly occurs in Congenital and Other congenital
  malformations also occur in Congenital."
 }]

这些 JSON 条目使图路径信息变得可以被 LLM 理解,并进一步用于消歧。

文本路径摘要

在执行最终候选消歧之前,我们需要先对这些“由图路径翻译而来的句子”进行摘要。这样做可以减少模型的“认知负担”(即减少 token 数量),使模型更容易理解实体之间的关系,并在不被过多细节淹没的情况下,选出最准确的候选项。下面的代码清单给出了一个简化版提示词。

代码清单 8.19 用于总结文本路径的简化提示词

system = """You are an assistant that can summarize multiple sentences
derived from ontology paths into a short summary. This summary will be
used to support a named entity disambiguation task. 
Ensure the output is a valid JSON with no extraneous characters."""

input = {
  "sentences": [
    {
      "id": 1,
      "sentence": "Hypertension is a risk factor for cardiovascular
      disease. Myocardial infarction is also associated with cardiovascular
      disease, indicating that hypertension may increase the risk of
      experiencing a myocardial infarction through its connection to
      cardiovascular disease."
    },
    {
      "id": 2,
      "sentence": "Diabetes mellitus is a complication that arises from
      an endocrine disorder. Diabetic retinopathy is also associated with
      endocrine disorders, suggesting that diabetes mellitus can lead to the
      development of diabetic retinopathy through its link to endocrine
      dysfunction."
    },
    {
      "id": 3,
      "sentence": "Asthma is associated with respiratory disorders.
      Allergic rhinitis is also linked to respiratory disorders, which 
      implies that individuals with asthma may also experience allergic 
      rhinitis due to their common association with respiratory
      conditions."
    },
    {
      "id": 4,
      "sentence": "Osteoporosis leads to bone weakness. Bone fractures
      are a result of bone weakness, indicating that osteoporosis can 
      increase the likelihood of bone fractures due to the weakened state
      of the bones."
    }
  ]

assistant = {
  "context": "Hypertension is a risk factor for cardiovascular disease,
  which in turn increases the likelihood of experiencing a myocardial
  infarction. Similarly, diabetes mellitus is linked to endocrine disorders,
  potentially leading to complications such as diabetic retinopathy. Asthma
  and allergic rhinitis are both associated with respiratory disorders,
  suggesting a common link between these conditions. Finally, osteoporosis
  weakens bones, making individuals more susceptible to bone fractures."
}

这个提示的结构如下:

  • 系统指令 —— 系统被要求对来源于本体路径的句子进行摘要,并在摘要中保留所有识别出的实体。输出必须是一个合法 JSON 对象,每段摘要都放在 context 键下,以字符串形式给出。
  • 输入句子 —— 输入由多条句子组成,每句都包含医学状况及其影响或关联关系。
  • 助手输出 —— 系统针对每组相关实体,返回一句汇总后的摘要句,并以合法 JSON 格式输出。

输出结构会总结输入句子中的核心关系,并保留关键实体与关系,如代码清单 8.20 所示。

代码清单 8.20 摘要阶段的结果

{"context": "A Congenital Zika virus infection occurrence is associated
with various congenital malformations, including Micrencephaly,
Acrocephaly, Multiple congenital malformations, and Other congenital
malformations. These conditions all share a common link to the Congenital
entity."}

这一结果向 LLM 提供了复杂关系信息的一个浓缩版本,使其能够聚焦于最相关的上下文,从而更好地执行消歧。

消歧

在最后阶段,我们会把所有用于消歧的要素结合起来,包括已选出的候选项,以及摘要阶段所提供的文本细节。下面的代码清单展示了用于最终消歧的提示词。

代码清单 8.21 最终消歧提示词

system = """You are an assistant specialized in entity disambiguation.
Your task is to identify and accurately disambiguate the entities
mentioned in a given sentence, relying heavily on the contextual entities
present in surrounding sentences:
1. Original Sentence: The sentence that contains ambiguous entities that
need to be resolved. 
2. Candidate Entities: A list of potential entities extracted from the
sentence, with each entity having multiple possible meanings or labels.
3. Contextual Sentences: A collection of related or surrounding sentences
that provide additional context for disambiguating the mentioned entities.
Your objective is to use the entities mentioned in the contextual sentences
as the primary source of information to disambiguate the entities in the
original sentence. Analyze the candidate entities for each ambiguous
mention and select the one that aligns best with both the context and the
meaning provided by the contextual sentences. The output must be a valid
JSON."""

input = {
  "sentence": "Asthma and allergic rhinitis are commonly addressed
  together in treatment protocols, given their shared underlying
  inflammatory processes in allergic individuals.",
  "candidates": [
    {
      "id": 1,
      "candidates": [
        {
          "snomed_id": "233681001",
          "name": "Extrinsic asthma with asthma attack"
        },
        {
          "snomed_id": "195967001",
          "name": "Asthma"
        },
        {
          "snomed_id": "266361008",
          "name": "Intrinsic asthma"
        },
        {
          "snomed_id": "266364000",
          "name": "Asthma attack"
        },
        {
          "snomed_id": "270442000",
          "name": "Asthma monitored"
        },
        {
          "snomed_id": "170642006",
          "name": "Asthma severity"
        },
        {
          "snomed_id": "170643001",
          "name": "Occasional asthma"
        },
        {
          "snomed_id": "170644007",
          "name": "Mild asthma"
        },
        {
          "snomed_id": "170645008",
          "name": "Moderate asthma"
        }
      ]
   }
  ],
"context":"Asthma is associated with respiratory disorders. Allergic 
rhinitis is also linked to respiratory disorders, which implies that
individuals with asthma may also experience allergic rhinitis due to their
common association with respiratory conditions."}

  }

assistant = {
  "entities": [
    {
      "id": 1,
      "disambiguation": {
        "snomed_id": "195967001",
        "name": "Asthma"
      }
    },
    {
      "id": 2,
      "disambiguation": {
        "snomed_id": "61582004",
        "name": "Allergic rhinitis"
      }
    }
  ]
}

这个提示词的结构如下:

  • 系统指令 —— 系统被要求识别并准确消歧歧义实体。它必须分析原始句子中的每个实体,并选择那个与摘要化上下文所提供语义最一致的候选项,优先考虑那些与这些上下文细节相匹配的实体。

  • 输入结构

    • 原始句子 —— 主句中包含需要消歧的歧义实体。
    • 候选实体 —— 对每个提及,给出一组潜在的 SNOMED 实体候选项,每个候选项都代表一种可能解释或标签。
    • 上下文句子 —— 额外提供的句子,用于帮助澄清原始句子中歧义实体的含义。
  • 助手输出

    • id —— 实体提及的唯一标识符
    • disambiguation —— 一个对象,包含最终选中的 SNOMED 实体,即 snomed_idname,它应当与上下文信息最匹配。

当我们把这句话:

“Severe outcomes of Zika are due to its capacity to cross the placental barrier during pregnancy, causing microcephaly and congenital malformations.”

传给 LLM 时,得到了如下结果。

代码清单 8.22 消歧过程的结果

{
  "entities": [
    {
      "id": 0,
      "disambiguation": {
        "snomed_id": "762725007",
        "name": "Congenital Zika virus infection"
      }
    },
    {
      "id": 1,
      "disambiguation": {
        "snomed_id": "204030002",
        "name": "Micrencephaly"
      }
    },
    {
      "id": 2,
      "disambiguation": {
        "snomed_id": "116022009",
        "name": "Multiple congenital malformations"
      }
    }
  ]
}

每个实体都被映射到了 SNOMED 本体中最相关的概念,从而能够基于上下文信息实现精确识别和分类。

8.5 结论

本章深入探讨了一个结合开放式大语言模型领域本体的 NED 系统。通过将 SNOMED 这类领域本体与 Llama 3.1 8B 这样的开放式、通用型 LLM 结合起来,我们可以解决传统生物医学 NLP 工具(如 scispaCy)的一些局限。我们的这种灵活方法还能跨应用领域适配:利用 Neo4j 的 GDS 库完成最短路径检测,并结合全文搜索与 LLM 的消歧能力,可以构建出一个稳健的系统,用于在复杂文本中识别并准确消歧实体。通过“路径到文本转换”和“文本路径摘要”等技术,我们提升了 LLM 以自然语言形式处理关系数据的能力,从而增强了它区分相似实体的能力。这个框架为未来在特定领域中使用 LLM 执行 NED 任务打下了基础。

小结

  • 命名实体消歧对于在复杂领域中准确识别和区分实体至关重要。
  • 传统 NLP 工具(如 scispaCy)无法广泛适用于多种领域,也无法利用实体之间的关系;同时,它们的参考知识也难以扩展和更新。
  • 将通用型 LLM 与领域本体结合,可以解决这些问题:LLM 可以被本体中持续更新的知识所驱动,并利用其中的关系结构。
  • 我们部署了一个灵活的端到端 NED 流程,其中包含多个结合 LLM 与领域本体的阶段。
  • 为了充分发挥 LLM 与领域本体图结构结合后的能力,消歧被划分为三个步骤:检测最短路径、将路径翻译为文本、以及总结文本路径。
  • 未来的 NED 应用可以复用这一框架,并将其适配到其他拥有丰富本体、能够描述实体关系结构的领域中。