本章内容包括:
- 构建和使用知识图谱
- 实现开放信息抽取以从文本中生成知识图谱
- 使用语义知识图谱发现任意语义关系
- 使用知识图谱进行查询扩展和重写
- 使用知识图谱解读文档
在上一章中,我们主要关注了如何基于用户的行为信号学习查询与文档之间的相似性。在第二章中,我们还讨论了文本文档内容如何不像“非结构化数据”,而更像是一个包含丰富语义关系的巨大超结构化数据图,这些语义关系连接着我们文档集合中存在的许多字符序列、术语和短语。
在本章中,我们将演示如何使用我们内容中这些巨大的语义关系图来更好地解读特定领域的术语。我们将通过使用传统的知识图谱来显式建模领域内的关系,以及使用语义知识图谱来实时推断领域内微妙的语义关系来实现这一目标。
语义知识图谱是一种简单的语言模型(语言模型表示词序列的概率分布)。我们将使用语义知识图谱作为了解大型语言模型(LLMs)的一个跳板,在后续章节中我们将介绍LLMs。LLMs是深度神经网络,通常在数十亿个参数和大量数据(通常是已知的互联网数据)上进行训练,以建模人类知识的一般表示。然而,语义知识图谱是可查询的语言模型,仅表示实际存在于搜索索引中的关系。虽然语义知识图谱不具备一般的语言推理能力,但它们对于特定领域的上下文推理非常强大,正如我们将看到的那样。
在本章中,我们还将通过几个有趣的数据集,展示知识图谱在不同领域中如何构建并应用以改进查询理解。
5.1 使用知识图谱
在第2.4节中,我们介绍了知识图谱的概念,并讨论了它们与其他类型的知识模型(如本体、分类法、同义词和替代标签)之间的关系。如你所记得,知识图谱集成了这些其他类型的知识模型,因此在本章构建过程中,我们将统一称其为“知识图谱”。
知识图谱(或者任何图)通过节点(也称为顶点)和边的概念来表示。节点是知识图谱中表示的实体(例如术语、人物、地点、事物或概念),而边则表示两个节点之间的关系。图5.1展示了一个显示节点和边的图示例。
在这幅图中,你可以看到四个节点代表作者,一个节点代表他们共同撰写的研究论文,一个节点代表该论文被展示和发布的学术会议,然后是表示会议举办城市、省份、国家和日期的节点。通过遍历(或“跟踪”)节点之间独立的边,你可能会推断出其中一位作者在2016年10月曾在加拿大蒙特利尔。这种包含节点和边的任何结构都被视为图,但这个特定的图表示的是事实知识,因此也被视为知识图谱。
构建和表示知识图谱有多种方式,既可以通过显式建模数据为节点和边,也可以通过实时动态生成(发现)节点和边。后者被称为语义知识图谱。在本章中,我们将通过各种示例来展示,包括手动构建显式知识图谱、自动生成显式知识图谱以及使用已经存在于搜索索引中的语义知识图谱。
要开始使用知识图谱,基本上有三种选择:
- 使用图形数据库(如Neo4j、Apache TinkerPop、ArangoDB等)从零开始构建知识图谱。
- 插入现有的知识图谱(如ConceptNet、DBpedia、大型语言模型等)。
- 使用内容直接提取知识,自动生成知识图谱。
每种方法都有其优缺点,尽管这些方法不一定是互相排斥的。如果你正在构建一个通用知识搜索引擎(如网页搜索引擎),利用现有的知识图谱或大型语言模型是一个很好的起点。然而,如果你的搜索引擎是更具领域特定性的,现有的图谱中可能不包含你的领域特定实体和术语,这就需要你创建一个定制的知识图谱。
在本章中,我们将主要关注第三种方法:从你的内容中自动生成知识图谱。其他两种技术已经在外部资料中得到了很好的覆盖,使用诸如SPARQL、RDF三元组、Apache Jena等技术,或者像DBpedia和Yago这样的现有知识图谱。你仍然需要能够覆盖你的知识图谱并添加自定义内容,因此我们将提供示例,展示如何将显式定义的知识图谱(通过特定预定义关系列表构建)和隐式定义的知识图谱(通过从数据中动态发现的关系自动生成)集成到你的搜索平台中。
5.2 将我们的搜索引擎作为知识图谱
许多组织在构建知识图谱方面投入了大量资源,但在将它们集成到搜索引擎中时遇到了困难。幸运的是,我们在本示例中选择的默认搜索引擎实现(Apache Solr)内置了显式的图遍历功能,因此不需要引入新的外部系统来实现或遍历我们的知识图谱。
虽然使用支持更复杂图遍历语义的外部图形数据库(如Neo4J或ArangoDB)可能有一些优势,但使用这样的外部系统会使得协调请求、保持数据同步和基础设施管理变得更加复杂。此外,因为某些类型的图操作只能在搜索引擎中有效执行(例如使用倒排索引进行的语义知识图谱遍历,我们将在稍后遇到),因此将搜索引擎作为搜索和知识图谱功能的统一平台可以减少我们需要管理的系统数量。
我们将在第7章中重点关注实现语义搜索系统,包括语义查询解析、短语提取、拼写错误检测、同义词扩展和查询重写,这些都将被建模为显式构建的知识图谱。由于本章的目的是关注知识图谱学习,因此我们将大部分关于查询时集成模式的讨论推迟到第7章,届时我们将把本章和第6章中的所有内容结合到适当的知识图谱结构中。
5.3 从内容中自动提取知识图谱
虽然你需要能够修改知识图谱中的节点和边,但手动维护一个大规模的知识图谱是非常具有挑战性的。手动维护的知识图谱需要大量的领域专业知识,必须与不断变化的信息保持同步,并且容易受到维护者的偏见和错误的影响。
开放信息抽取是自然语言处理(NLP)研究中的一个发展领域。开放信息抽取旨在直接从文本内容中提取事实。这通常使用NLP库和语言模型来解析句子并评估它们之间的依赖图。依赖图是句子中每个单词和短语的词性拆解,以及哪些单词指代其他单词的指示。
最近的知识图谱提取方法通常使用专门训练的LLMs进行实体提取,例如UniRel(统一表示和联合关系三元组提取)和REBEL(通过端到端语言生成的关系提取)。由于LLM能够比传统的基于依赖图的方法更好地表示和提取实体之间的细微关系,因此LLM驱动的方法可能会随着时间的推移成为知识图谱提取的标准。然而,为了本章的学习目的,我们将专注于基于依赖图的方法,因为它将为理解从文本中提取知识图谱的机制以及构建自定义关系提取模式提供更好的基础。如果日后发现LLM驱动的方法更适合你的需求,你总是可以切换到更先进的方法。
在本节中,我们将使用语言模型和依赖图提取两种不同类型的关系:任意关系和下位关系。
5.3.1 从文本中提取任意关系
鉴于文本的超结构化性质以及典型句子和段落中表达的丰富关系,我们可以推断,应该能够识别句子的主语和宾语及其之间的关系。在本节中,我们将专注于提取文本内容中句子之间的任意关系。
通过分析句子中的名词和动词,通常可以推断出句子中存在的事实,并将该事实映射为RDF三元组(也称为语义三元组)。资源描述框架(RDF)是一种用于表示图和关系的数据模型。RDF三元组是一种三部分的数据结构,表示一个主题(起始节点)、关系(边)和对象(结束节点)。例如,在句子“Colin attends Riverside High School”中,动词“attends”可以提取为连接主语(“Colin”)和宾语(“Riverside High School”)的关系类型。因此,RDF三元组为("Colin", "attends", "Riverside High School")。
列表5.1展示了一个使用基于Python的spaCy库从文本内容中提取事实的示例。SpaCy是一个流行的自然语言处理库,提供最先进的统计神经网络模型,用于词性标注、依赖解析、文本分类和命名实体识别。
列表5.1 提取关系和解决共指
def extract_relationships(text, lang_model, coref_model):
resolved_text = resolve_coreferences(text, coref_model) #1
sentences = get_sentences(resolved_text, lang_model) #2
return resolve_facts(sentences, lang_model) #3
text = """
Data Scientists build machine learning models. They also write code.
Companies employ Data Scientists.
Software Engineers also write code. Companies employ Software Engineers.
"""
lang_model = spacy.load("en_core_web_sm")
coref_model = spacy.load("en_coreference_web_trf") #4
graph = extract_relationships(text, lang_model, coref_model)
print(graph)
输出:
sentence: Data Scientists build machine learning models.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Data Scientists also write code.
dependence_parse: ['nsubj', 'advmod', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Companies employ Data Scientists.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Software Engineers also write code.
dependence_parse: ['nsubj', 'advmod', 'ROOT', 'dobj', 'punct']
---------------------
sentence: Companies employ Software Engineers.
dependence_parse: ['nsubj', 'ROOT', 'dobj', 'punct']
---------------------
[['Data Scientists', 'build', 'machine learning models'],
['Data Scientists', 'write', 'code'],
['Companies', 'employ', 'Data Scientists'],
['Software Engineers', 'write', 'code'],
['Companies', 'employ', 'Software Engineers']]
如你所见,示例代码将文本内容解析为句子,然后确定这些句子中的主语、关系和宾语。然后,这些RDF三元组可以保存到显式构建的知识图谱中并进行遍历。
图5.2提供了此提取图的可视化。虽然这个示例很基础,但先进的算法可以从更复杂的语言模式中提取事实。我们在代码示例中使用的是spaCy库,它使用基于深度学习的神经语言模型来检测输入文本中的词性、短语、依赖关系和共指。然后,我们使用的机制将这些语言输出解析为RDF三元组,更加依赖规则,遵循英语语言中的已知语义模式。
不幸的是,当以这种方式将任意动词解析为关系时,提取的关系可能会变得相当杂乱。由于动词的变化形式不同,具有同义词,并且具有重叠的含义,因此通常需要修剪、合并并清理任意提取的关系列表。
相比之下,一些关系类型要简单得多,例如统计关系(“is related to”)和下位词关系(“is a”)。我们将重点关注这两种特殊类型的关系,并从下位词开始。
5.3.2 从文本中提取下位词和上位词
虽然将任意动词映射为知识图谱中清晰的关系列表可能是一个挑战,但提取下位词和上位词要容易得多。下位词是与更一般形式的实体保持“是一个”(is a)或“是…的实例”(is instance of)关系的实体,而更一般的形式被称为上位词。例如,对于术语“phillips head”、“screwdriver”和“tool”之间的关系,我们可以说“phillips head”是“screwdriver”的下位词,“tool”是“screwdriver”的上位词,而“screwdriver”既是“phillips head”的上位词,又是“tool”的下位词。
从文本中提取下位词/上位词关系的一种常见且相当准确的方法是使用Hearst模式,这些模式由Marti Hearst在《Automatic Acquisition of Hyponyms from Large Text Corpora》一文中描述(1992年在COLING第14届国际计算语言学大会上发表)。这些模式描述了常见的语言学模板,这些模板可以可靠地指示句子中存在下位词。以下是这些模式的一些示例。
列表5.2 识别语义关系的Hearst模式
simple_hearst_patterns = [
("(NP_\w+ (, )?such as (NP_\w+ ?(, )?(and |or )?)+)", "first"),
("(such NP_\w+ (, )?as (NP_\w+ ?(, )?(and |or )?)+)", "first"),
("((NP_\w+ ?(, )?)+(and |or )?other NP_\w+)", "last"),
("(NP_\w+ (, )?include (NP_\w+ ?(, )?(and |or )?)+)", "first"),
("(NP_\w+ (, )?especially (NP_\w+ ?(, )?(and |or )?)+)", "first")]
每个简单的模式都表示为一个Python元组,第一个元素是正则表达式,第二个元素是模式匹配中的位置(即,第一个或最后一个)。如果你不熟悉正则表达式,它提供了一种常见且强大的语法,用于在字符串中进行模式匹配。每当你看到“NP”字符时,这代表句子中存在名词短语。元组中的第二个元素(第一个或最后一个)指定句子中哪个名词短语表示上位词,所有其他匹配该模式的名词短语被认为是下位词。
在以下列表中,我们使用了接近50个这些Hearst模式来匹配文本内容中的各种“是一个”关系。
列表5.3 使用Hearst模式提取下位词关系
text_content = """Many data scientists have skills such as machine learning,
python, deep learning, apache spark, among others. Job candidates most
prefer job benefits such as commute time, company culture, and salary.
Google, Apple, or other tech companies might sponsor the conference.
Big cities such as San Francisco, Miami, and New York often appeal to
new graduates. Job roles such as Software Engineer, Registered Nurse,
and DevOps Engineer are in high demand. There are job benefits including
health insurance and pto."""
extracted_relationships = HearstPatterns().find_hyponyms(text_content)
facts = [[pair[0], "is_a", pair[1]] for pair in extracted_relationships]
print(*facts, sep="\n")
输出:
['machine learning', 'is_a', 'skill']
['python', 'is_a', 'skill']
['deep learning', 'is_a', 'skill']
['apache spark', 'is_a', 'skill']
['commute time', 'is_a', 'job benefit']
['company culture', 'is_a', 'job benefit']
['salary', 'is_a', 'job benefit']
['Google', 'is_a', 'tech company']
['Apple', 'is_a', 'tech company']
['San Francisco', 'is_a', 'big city']
['Miami', 'is_a', 'big city']
['New York', 'is_a', 'big city']
['Software Engineer', 'is_a', 'Job role']
['Registered Nurse', 'is_a', 'Job role']
['DevOps Engineer', 'is_a', 'Job role']
['health insurance', 'is_a', 'job benefit']
['pto', 'is_a', 'job benefit']
从此列表中你可以看到,通过专注于提取固定类型的关系(并且是最常见的类型——“是一个”关系),我们能够生成一份干净的分类事实列表,其中更具体的术语(下位词)指向更一般的术语(上位词),并且以“is_a”边连接。图5.3展示了该生成图的可视化效果。
通过使用Hearst模式,任意关系提取中存在的不一致性和噪声得到了显著减少。我们仍然可能对相似术语之间的关系存在歧义(例如拼写错误、替代拼写、已知短语或同义词),但这些歧义更容易解决。事实上,我们将在接下来的整个章节中讨论如何从你的信号和内容中学习这种特定领域的语言,以便在解释传入的用户查询时使用。
尽管将信息从我们的文本中提取到显式的知识图谱中以供后续遍历可能是有用的,但实际上这种提取过程是有损的,因为项的表示与这些项在我们内容中的原始上下文(即包含该文本的周围文本和文档)断开了连接。在下一节中,我们将介绍一种完全不同的知识图谱——语义知识图谱,它被优化为能够在不显式构建的情况下、也不将术语与其原始文本上下文分离的情况下,实时遍历和排序我们内容中术语和短语之间的关系。
5.4 通过遍历语义知识图谱学习意图
在第二章的2.1节和2.2节中,我们讨论了文本内容是“非结构化数据”这一误解,以及文本文档实际上如何代表超结构化数据。我们讨论了分布假设(“一个词语的意义由它与之共现的其他词语决定”),并讲解了如何将字符序列、术语、短语以及其他任意的术语序列视为模糊的外键,这些外键在文档之间关联了相似的概念。我们还讨论了文档之间的这些链接如何被看作是关系图中的边,进而使我们能够学习文档集合中术语和实体的上下文意义。
在本节中,我们将介绍语义知识图谱,这是一种工具和技术,能够帮助我们遍历文档中存在的那个巨大的语义关系图。
5.4.1 什么是语义知识图谱?
语义知识图谱(SKG)是“一种紧凑的、自动生成的模型,用于实时遍历和排名领域内的任何关系”。我们可以将SKG看作一个搜索引擎,不是匹配和排名文档,而是查找并排名与查询最匹配的术语。
例如,如果我们对关于健康话题的文档集合进行了索引并搜索“advil”,那么一个SKG将自动返回类似以下的值(不需要手动创建列表或数据建模):
advil 0.71
motrin 0.60
aleve 0.47
ibuprofen 0.38
alleve 0.37
这些结果可以被视为“动态同义词”,但与术语含义相同不同的是,它们更像是概念相关的术语。你可以将“advil”的词汇搜索查询扩展为包括这些其他术语,以提高搜索结果的召回率,或者提升那些在概念上与“advil”意义匹配的文档,而不仅仅是包含字母“a”,“d”,“v”,“i”,“l”这五个字符的字符串。
除了查找相关术语外,SKG还可以在你的倒排索引中的字段之间进行遍历(“找到与该职位名称最相关的技能”),跨越多个层次进行遍历(“找到与该查询最相关的职位名称,然后找到该查询及这些职位名称最相关的技能”),并使用你发送给搜索引擎的任何任意查询作为图遍历中的节点,以在任何字段中找到语义相关的术语。
SKG的使用场景非常广泛。它们可以用于查询扩展、生成基于内容的推荐、查询分类、查询歧义消解、异常检测、数据清洗和预测分析。我们将在本章的剩余部分探讨其中的几个应用场景,但首先我们需要设置一些数据集来测试我们的SKG。
5.4.2 索引数据集
SKG在那些文档之间使用的术语有更多重叠的数据集上表现最佳。两个词语在文档中越频繁地一起出现,我们就越能确定这些词语的出现频率是否超过了我们预期的统计频率。
尽管Wikipedia通常是许多用例的一个很好的起始数据集,它通常只有一个关于主要主题的页面,并且该页面应该是权威性的,因此在大多数文档之间不会有显著的重叠,这使得Wikipedia对于这个用例来说不是一个理想的数据集。相比之下,大多数其他由用户提交内容的网站(如问答网站、论坛帖子、招聘信息、社交媒体帖子、评论等)通常会有非常适合SKG用例的数据集。
在本章中,我们选择了两个主要数据集:一个是职位数据集(招聘网站帖子),另一个是包括以下论坛帖子在内的Stack Exchange数据集:
- health
- scifi
- devops
- travel
- cooking
5.4.3 SKG的结构
为了最好地利用SKG,理解它如何基于其底层结构工作的方式是很有帮助的。
与传统的知识图谱不同,传统的知识图谱必须显式地建模为节点和边,SKG是从搜索引擎的底层倒排索引中物化出来的。这意味着你只需要将文档索引到搜索引擎中,就可以生成一个SKG,不需要额外的数据建模。
倒排索引和相应的正向索引作为底层数据结构,使得能够实时遍历和排名文档集合中存在的任何任意语义关系。
图5.4展示了文档如何同时添加到正向索引和倒排索引中。在图的左侧,你可以看到三篇文档,每篇文档都有一个job_title字段、desc字段和skills字段。图的右侧展示了这些文档如何映射到你的搜索引擎中。我们可以看到,倒排索引将每个字段映射到一系列术语,然后将每个术语映射到一个包含文档列表的发布列表(以及文档中的位置,图中未包括的其他数据)。这使得查找任何字段中的术语并找到包含该术语的所有文档集变得既快速又高效。
除了广为人知的倒排索引外,你还可以在图5.4的中央看到不太为人所知的正向索引。正向索引可以被视为一种未反转的索引:对于每个字段,它将每个文档映射到该文档中包含的术语列表。正向索引是搜索引擎用于生成搜索结果的面板(也称为聚合)的工具,它展示了从一组文档中每个字段的顶部值。在基于Lucene的搜索引擎(如Solr、OpenSearch和Elasticsearch)中,正向索引通常在索引时通过启用字段上的一个叫做doc values的功能生成。或者,Apache Solr还允许你通过在查询时“反转”倒排索引来生成相同的正向索引,从而对即使没有在索引中添加doc values的字段也能够进行面板化。
如果你能够通过倒排索引搜索任意查询并找到文档集(从术语到文档的遍历),并且你也能够获取任意文档集并查找这些文档中的术语(从文档到术语的遍历),这意味着通过进行两次遍历(术语到文档,再到术语),你可以找到所有出现在匹配查询的文档中的相关术语。图5.5展示了如何进行这样的遍历,包括数据结构视图、集合论视图和图视图。
在数据结构视图中,表示我们的倒排和正向索引,我们看到术语是如何与文档相关联的,基于它们是否出现在文档中。只有当两个节点(在这种情况下是术语)在集合论视图中出现的文档之间有交集时,这些关系链接才会存在。最后,图视图展示了对相同底层数据结构的第三种视图,但在这种情况下,我们看到的是节点(而不是文档集)和边(而不是交集文档集)。本质上,SKG作为倒排索引之上的抽象存在,并且每次搜索引擎索引内容时都会构建和更新它。
我们通常认为搜索引擎的主要功能是接收查询、找到匹配的文档,并按相关性排序返回这些文档。我们在第3章中专门讨论了这个过程,讲解了匹配(3.2.4–3.2.6节)、TF-IDF排序(3.1节)以及常用的BM25排序函数(3.2.1节)。然而,在使用SKG时,我们的重点是匹配和排序相关的术语,而不是相关的文档。
任何任意的查询(任何你能解析为文档集的内容)都可以是图中的一个节点,你可以从这个节点遍历到任何其他术语(或任意查询)在任何文档字段中的位置。此外,由于每次边的遍历都使用倒排索引(术语到文档)和正向索引(文档到术语),因此将这些遍历链成多级图遍历是轻而易举的,正如图5.6所示。
在该图中,数据结构视图显示了从一个技能节点(Java)开始,遍历到其他技能节点(Java、Oncology、Hibernate 和 Scala)的一层,再到职位名称节点(软件工程师、数据科学家和Java开发者)的一层。你可以看到,并不是所有的节点都相互连接——例如,Oncology节点没有出现在图视图中,因为没有任何原始节点可以通过任何边连接到它——它没有重叠的文档。
鉴于并非所有可能的节点都适用于任何给定的遍历,SKG还必须能够对节点之间的关系进行评分并分配权重,以便在任何图遍历中优先处理这些边。我们将在下一节中讨论如何对边进行评分和分配权重。
5.4.4 计算边权重以衡量节点的相关性
鉴于SKG的主要功能是发现节点之间相关的语义关系,计算语义相似度的能力至关重要。那么,究竟什么是语义相似度呢?
如果你还记得,在第2.3节中介绍的分布假设指出,出现在相同上下文中并具有相似分布的词语往往具有相似的含义。从直觉上来说,这是有道理的——术语“疼痛”或“肿胀”更可能出现在同时提到“advil”、“ibuprofen”或“冰袋”的文档中,而不是一些随机的文档。然而,值得注意的是,“冰袋”也可能出现在包含诸如“冰箱”、“公路旅行”或“寒冷”等术语的文档中,而“advil”和“ibuprofen”则不太可能。
这些例子展示了具有相似含义的词语(及其上下文),但我们也需要考虑像“a”、“the”、“of”、“and”、“if”、“they”等无数非常常见的停用词。这些词也会频繁出现在“疼痛”、“肿胀”、“advil”、“ibuprofen”或我们所检查的其他词汇的相同上下文中。这指向了分布假设的第二部分——词语必须具有相似的分布。实质上,这意味着在一些包含第一个术语的文档中,任何第二个术语如果比在其他随机术语的文档中更频繁地与第一个术语共同出现,则该第二个术语往往在语义上与第一个术语相似。
实际上,由于“the”或“a”通常与几乎所有其他术语共同出现,因此尽管它们的共现频率很高,但它们不会被认为在语义上与这些术语相似。然而,“疼痛”和“ibuprofen”这样的术语,比任何一个术语与其他随机术语一起出现的频率要高得多,因此它们被认为在语义上是相似的。
以下方程展示了计算术语与一组文档之间语义相关性的一个方法:
其中:
- x 是一个查询(通常是一个术语或术语序列),其相关性是相对于另一个查询,即前景查询 fg 来计算的。Dx 是匹配查询 x 的文档集合。
- Dfg 是匹配前景查询 fg 的文档集合。x 的相关性是相对于这个前景集合来计算的。
- Dbg 是匹配背景查询 bg 的文档集合。这个 bg 查询应与 x 和 fg 无关,通常设置为匹配整个文档集合 D 或 D 的一个随机样本。
- Px 是在背景集合中随机文档中找到 x 的概率,计算方式为:
这种相关性计算(在概念上类似于正态分布中的z分数)依赖于“前景”文档集和“背景”文档集的概念,并使得术语x的分布能够在这两个集合之间进行统计比较。例如,如果前景集合是所有匹配“pain”查询的文档,而背景集合是所有文档,那么术语“advil”的相关性将衡量“advil”在同时包含“pain”一词的文档中出现的频率(前景集)与在任何随机文档中出现的频率(背景集)的比较。最常见的是使用sigmoid函数对相关性得分进行归一化,将值映射到-1.0到1.0之间,0.0表示术语之间没有关系。为了简单起见,我们将在代码和所有后续示例中使用这种归一化的值范围。
如果两个术语高度相关,它们的相关性将是一个接近1.0的正数。如果术语高度不相关(意味着它们倾向于只出现在不同的领域),得分将更接近-1.0。最后,完全没有语义相关性的术语——如停用词——通常会有接近零的相关性得分。
Apache Solr直接在其面板API中内置了SKG功能。面板化提供了从术语到文档集再到术语的遍历能力,相关性聚合函数(RelatednessAgg)实现了我们刚才描述的语义相似度计算。以下列出了在Stack Exchange健康数据集中搜索与“advil”语义相关的术语。
列表5.4 发现与“advil”语义相关的术语
health_skg = get_skg(engine.get_collection("health"))
nodes_to_traverse = [{"field": "body", #1
"values": ["advil"]}, #2
{"field": "body",
"min_occurrences": 2, #3
"limit": 8}] #4
traversal = health_skg.traverse(*nodes_to_traverse) #5
print_graph(traversal, "advil") #6
#1 查找起始节点的字段
#2 我们的起始节点是查询“advil”。
#3 通过排除未出现至少此次数的术语来减少噪声
#4 将返回多少个节点(术语)
#5 执行图遍历
#6 打印SKG遍历的结果
输出:
['advil', 'is_a', 'painkiller']
['motrin', 'is_a', 'painkiller']
['aleve', 'is_a', 'painkiller']
['ibuprofen', 'is_a', 'painkiller']
['alleve', 'is_a', 'painkiller']
['tylenol', 'is_a', 'painkiller']
['naproxen', 'is_a', 'painkiller']
['acetaminophen', 'is_a', 'painkiller']
如你所见,在Stack Exchange健康数据集中的所有术语中,最语义相关的术语的排名顺序是类似的止痛药。这就是使用分布假设来发现和排序术语的语义相似度的神奇之处——它使我们能够实时自动发现关系,这些关系可以进一步提高我们对传入查询的理解。
以下是一个Solr SKG请求,它使用Solr的JSON面板化API和按函数排序的能力——我们刚刚讨论的相关性计算。
{
"limit": 0,
"params": {
"q": "*",
"fore": "{!${defType} v=$q}",
"back": "*",
"defType": "edismax",
"f0_0_query": "advil"
},
"facet": {
"f0_0": {
"type": "query",
"query": "{!edismax qf=body v=$f0_0_query}",
"field": "body",
"sort": {"relatedness": "desc"},
"facet": {"relatedness": {"type": "func",
"func": "relatedness($fore,$back)"},
"f1_0": {
"type": "terms",
"mincount": 2,
"limit": 8,
"sort": {"relatedness": "desc"},
"facet": {"relatedness": {"type": "func",
"func": "relatedness($fore,$back)"}
}}}}}}
列表5.4中的 skg.traverse(*nodes_to_traverse) 函数抽象了这种引擎特定的语法,但如果你想理解你的特定搜索引擎或向量数据库内部如何处理这些类型的知识图谱遍历,你可以在笔记本中查看该函数。我们将主要展示 skg.traverse 抽象,但你总是可以直接调用 skg.transform_request(*nodes_to_traverse) 函数,查看并调试内部引擎特定的请求。
在下一节中,我们将讨论如何应用从这个SKG遍历返回的相关术语,以提高查询的相关性。
5.4.5 使用SKG进行查询扩展
仅仅依靠输入的搜索关键词进行匹配和排名并不总能提供足够的上下文来找到并排名最佳结果。在这些情况下,通过动态扩展或以其他方式增强查询,包含概念相关的术语,可以显著提高搜索结果的质量。在本节中,我们将介绍如何生成这些相关术语,并展示几种应用这些术语以提高搜索结果质量的策略。
考虑到SKG能够从任何关键词或查询出发,找到其他高度相关的术语,它的一个明显用例就是动态扩展查询以包括相关术语。这种扩展有时被称为稀疏词汇扩展,因为它操作的是由基于术语的(词汇)特征构成的查询标记的稀疏向量。实现这种查询扩展的一种著名技术是SPLADE(稀疏词汇和扩展模型),我们将在第7.4.3节中讨论。语义知识图谱也提供了一种生成上下文稀疏词汇扩展的好方法,并且它们的优势在于不需要对你的数据集进行额外的微调。这使得文档即使不一定包含用户输入的确切关键词,但如果它们包含其他具有非常相似意义的术语,也能进行匹配。例如,代替用户查询的“advil”,由SKG生成的扩展查询可能看起来像这样:advil OR motrin^0.59897 OR aleve^0.4662 OR ibuprofen^0.3824 OR ...。
让我们通过一个来自不同领域的数据集(Stack Exchange scifi数据集)的示例,演示这种查询扩展的实现步骤。以下列出了此过程的第一步:搜索一个生僻术语(作为SKG中的节点),并找到其他相关术语(作为SKG中的相关节点)。在这种情况下,我们将使用“vibranium”(振金)作为起始节点。
列表5.5 发现未知术语“vibranium”的上下文
stackexchange_skg = get_skg(engine.get_collection("stackexchange"))
query = "vibranium"
nodes_to_traverse = [{"field": "body", "values": [query]},
{"field": "body", "min_occurrences": 2, "limit": 8}]
traversal = stackexchange_skg.traverse(*nodes_to_traverse)
print_graph(traversal, query)
响应:
vibranium 0.94237
wakandan 0.8197
adamantium 0.80724
wakanda 0.79122
alloy 0.75724
maclain 0.75623
klaw 0.75222
america's 0.74002
对于那些不熟悉“vibranium”术语的人来说,它是一种强大的虚构金属,出现在Marvel漫画和电影中(通过2018年好莱坞电影《黑豹》最为广为人知)。返回的最相关术语与“Wakandan”和“Wakanda”相关,这两个术语分别是振金的起源地——虚构的文化和国家,“adamantium”是Marvel漫画中的另一种强大的(虚构)金属,以及“Maclain”和“Klaw”这两个与振金紧密相关的Marvel漫画角色。Maclain创造了用于制作美国队长盾牌的振金“合金”,因此这些词语的相关性也就能理解了。
自动生成的知识图谱在识别相关信息方面非常有效。通过使用SKG并扩展查询以包含附加的相关上下文,你可以显著提高搜索请求的召回率。通过增强与查询概念(而不仅仅是文本)最匹配的结果,你还可以提高排名靠前的搜索结果的精确度。
以下列出了一个示例,展示了如何将这个原始查询和SKG输出转化为扩展查询。
列表5.6 使用SKG中的节点扩展查询
expansion = ""
for term, stats in traversal["graph"][0]["values"][query] \
["traversals"][0]["values"].items():
expansion += f'{term}^{stats["relatedness"]} '
expanded_query = f"{query}^5 " + expansion
print(f"Expanded Query:\n{expanded_query}")
扩展查询:
vibranium^5 vibranium^0.94237 wakandan^0.8197 adamantium^0.80724
wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002
在这个例子中,我们对与原始查询“vibranium”相关的任何关键词进行简单的布尔OR搜索,并通过5倍加权增强原始查询术语的权重,根据每个后续术语的语义相似度得分来加权它们对相关性得分的影响。选择将原始术语的权重加5倍是任意的——你可以选择任何值来分配相对于其他(扩展)术语的相对相关性增强。
你可能还会注意到,“vibranium”一词出现了两次——第一次作为原始术语,第二次作为扩展术语(因为该术语与自己是最语义相关的)。如果你正在搜索单个关键词,这几乎总是会发生,但由于你的查询可能包含短语或其他结构,这些结构使得原始查询与返回的术语(如果有的话)不同,通常一个好的做法是将原始查询作为扩展(重写)查询的一部分,这样用户的实际查询总能体现在结果中。
尽管前面的扩展查询应该能够合理地对结果进行排名(优先考虑匹配多个相关术语的文档),但它也非常侧重于召回率(扩展到包括所有相关内容),而不是精确度(确保包含的内容都相关)。增强查询可以通过多种方式构建,具体取决于你的主要目标。
重写的查询可以执行简单的扩展,要求最小匹配的百分比或数量,要求与原始查询相同的特定术语匹配,甚至仅更改相同初始结果集的排名。以下列表展示了几种示例,使用最小匹配阈值和百分比,可以根据需要在精准度和召回率之间调整平衡。
列表 5.7 不同的查询扩展策略
def generate_request(query, min_match=None, boost=None):
request = {"query": query,
"query_fields": ["title", "body"]}
if min_match:
request["min_match"] = min_match
if boost:
request["query_boosts"] = boost
return request
simple_expansion = generate_request(f"{query} {expansion}", "1")
increased_conceptual_precision = \
generate_request(f"{query} {expansion}", "30%")
increased_precision_same_recall = \
generate_request(f"{query} AND ({expansion})", "2")
slightly_increased_recall = generate_request(f"{query} {expansion}", "2")
same_results_better_ranking = generate_request(query, "2", expansion)
接下来让我们看一下每种查询扩展技术的最终搜索查询。
简单查询扩展: simple_expansion
{
"query": "vibranium vibranium^0.94237 wakandan^0.8197 adamantium^0.80724 wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002",
"query_fields": ["title", "body"],
"min_match": "0%"
}
这个简单的查询扩展与之前描述的相同,匹配包含原始查询或任何语义相关术语的文档。
增加精准度,减少召回率查询: increased_conceptual_precision
{
"query": "vibranium AND (vibranium^0.94237 wakandan^0.8197 adamantium^0.80724 wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002)",
"query_fields": ["title", "body"],
"min_match": "30%"
}
此增加精准度、减少召回率的示例指定了“最小匹配”阈值为30%,意味着文档必须至少包含查询中30%(向下舍入)的术语才能匹配。
增加前几结果的精准度,不减少召回率查询: increased_precision_same_recall
{
"query": "vibranium AND (vibranium^0.94237 wakandan^0.8197 adamantium^0.80724 wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002)",
"query_fields": ["title", "body"],
"min_match": "2"
}
这个增加精准度、保持召回率的查询要求术语“vibranium”匹配,并且当其他扩展术语匹配时,会将文档排名更高,从而提高前几名结果的精准度。
稍微增加召回率查询: slightly_increased_recall
{
"query": "vibranium vibranium^0.94237 wakandan^0.8197 adamantium^0.80724 wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002",
"query_fields": ["title", "body"],
"min_match": "2"
}
这个稍微增加召回率的查询要求匹配两个术语,但并不明确要求原始查询,因此它可以扩展到其他在概念上相似但不一定包含原始查询术语的文档。由于术语“vibranium”重复出现两次,因此任何仅包含“vibranium”的文档也会匹配。
相同结果,更好的概念排名: same_results_better_ranking
{
"query": "vibranium",
"query_fields": ["title", "body"],
"min_match": "2",
"query_boosts": "vibranium^0.94237 wakandan^0.8197 adamantium^0.80724 wakanda^0.79122 alloy^0.75724 maclain^0.75623 klaw^0.75222 america's^0.74002"
}
这个最终查询返回与原始查询“vibranium”相同的文档,但根据它们与知识图谱中语义相似术语的匹配程度对它们进行不同的排名。它确保在所有匹配的文档中都存在该关键词,并且返回所有包含用户查询的文档,同时通过提升更具上下文相关性的文档来大大改善排名。
当然,重写查询以包括增强语义上下文时,您可以探索无限数量的查询排列,但上述示例应该能很好地展示可用的选项类型以及您可能需要权衡的内容。
5.4.6 使用SKG进行基于内容的推荐
在上一节中,我们探讨了如何通过发现和使用SKG中的相关节点来扩展查询,包括多种重写查询的方式,以优化精准度、召回率,或甚至在相同结果的基础上提高概念排名。除了通过语义相关术语扩展查询外,还可以使用SKG通过基于文档内术语的语义相似性,将文档转化为查询,从而生成基于内容的推荐。
由于SKG中的节点可以表示任何任意的查询,我们可以从文档中提取术语,并将它们建模为任意节点,依据一些已知的文档上下文来评分。这意味着我们可以从文档中提取几十个或上百个术语,将它们相对于文档的主题进行评分,然后使用与文档语义最相似的术语生成一个最能代表文档细微、上下文化意义的查询。
以下列表演示了如何将一篇被归类为“星际大战”的文档翻译为查询,并根据该主题对文档中的所有术语进行排名。
列表 5.8 计算文档术语与“星际大战”相关性的示例
from aips import extract_phrases
stackexchange_skg = get_skg(engine.get_collection("stackexchange"))
classification = "star wars"
document = """this doc contains the words luke, magneto, cyclops,
darth vader, princess leia, wolverine, apple, banana,
galaxy, force, blaster, and chloe."""
parsed_document = extract_phrases(document)
nodes_to_traverse = [{"field": "body", "values": [classification]},
{"field": "body", "values": parsed_document}]
traversal = stackexchange_skg.traverse(*nodes_to_traverse)
print_graph(traversal, classification)
得分节点:
luke 0.75212
force 0.73248
darth vader 0.69378
galaxy 0.58693
princess leia 0.50491
blaster 0.47143
this 0.19193
the 0.17519
words 0.10144
and 0.09709
contains 0.03434
doc 0.00885
chloe 0.0
cyclops -0.01825
magneto -0.02175
banana -0.0319
wolverine -0.03362
apple -0.03894
在这些结果中,你可以看到一份基于与“星际大战”主题的语义相似性排序的文档术语列表。得分较低的术语与指定主题的相关性较低或为负。以下列表筛选出与主题相关性高于0.25的术语,从而获取文档中的相关术语。
列表 5.9 从得分短语生成推荐查询
def get_scored_terms(traversal):
return {term: data["relatedness"]
for term, data in traversal["graph"][0]["values"]["star wars"] \
["traversals"][0]["values"].items()}
rec_query = " ".join(f'"{term}"^{score}'
for term, score in get_scored_terms(traversal).items()
if score > 0.25)
print(f"Expanded Query:\n{rec_query}")
扩展查询:
"luke"^0.75212 "force"^0.73248 "darth vader"^0.69378 "galaxy"^0.58693
"princess leia"^0.50491 "blaster"^0.47143
接下来的列表演示了此过程的最后一步——运行搜索以返回与原始文档最语义相似的前几个文档。
列表 5.10 运行基于内容的推荐查询
stackexchange_collection = engine.get_collection("stackexchange")
request = {"query": rec_query,
"query_fields": ["title", "body"],
"return_fields": ["title"],
"limit": 5,
"filters": [("title", "*")]}
response = stackexchange_collection.search(**request)
print(json.dumps(response["docs"], indent=2))
输出:
[ {"title": "At the end of Return of the Jedi, did Darth Vader learn
↪that Princess Leia was his daughter?"}, {"title": "Did Luke know the "Chosen One" prophecy?"}, {"title": "Was Darth Vader at his strongest during Episode III?"}, {"title": "Why couldn't Snoke or Kylo Ren trace Luke using the Force?"}, {"title": "Does Kylo Ren know that Darth Vader reconciled with Luke?"}]
我们刚刚创建的是一个基于内容的推荐算法。当用户行为信号不足以进行信号基础的推荐(如协同过滤)(参见第4.2.3节)时,基于内容的方法仍然可以生成具有上下文和领域感知的推荐。
本节中的示例基于文档中的术语生成了基于内容的推荐查询,但值得注意的是,SKG不仅限于使用传入的术语。你可以在遍历过程中增加一个额外的层级,找到与原始文档中的术语语义相关的其他术语,即使它们实际上并未包含在文档中。这对于细分主题尤其有用,当没有足够的文档与推荐查询匹配时,进一步遍历将打开新的探索可能性。
在下一节中,我们将超越“相关性图”关系,看看我们能否使用SKG生成并遍历一些更有趣的边。
5.4.7 使用SKG建模任意关系
到目前为止,我们所有的SKG遍历都使用了“与……相关”关系。也就是说,我们一直在使用相关性函数来查找两个单词或短语之间语义关系的强度,但我们只衡量了节点之间是“相关的”,而没有衡量它们是如何相关的。那么,如果我们能找到节点之间其他类型的边,而不仅仅是“与……相关”类型的边呢?
如果你还记得,SKG中的节点是通过执行与一组文档匹配的查询动态生成的。如果你从节点“engineer”开始,该节点在内部表示为包含“engineer”一词的所有文档的集合。如果节点被标记为“software engineer”,那么该节点在内部表示为包含“software”术语与包含“engineer”术语的所有文档的交集。如果搜索是“software engineer” OR java,则该节点在内部表示为包含术语“software”在“engineer”术语之前的位置(即短语)与包含术语“java”的所有文档的并集。所有查询,无论其复杂性如何,内部都表示为一组文档。
你可能还记得,边是通过查找包含两个节点的文档集合来形成的。这意味着节点和边都是使用相同的机制表示的——即一组文档。从实践角度讲,这意味着如果我们能通过构造一个近似有趣关系的查询(而非实体)来构造节点,我们就可以通过“关系节点”将两个节点联系起来,类似于传统图结构中如何使用边将节点联系起来。
让我们通过一个例子来演示。回到我们的科幻数据集,假设我们想问一个关于Jean Grey的问题,她是漫威漫画《X战警》中的一位热门角色。具体来说,假设我们想弄清楚谁爱上了Jean Grey。
我们可以通过使用起始节点“jean grey”,遍历到“in love with”节点,然后请求与“in love with”相关的最顶层术语来完成这个任务。列表5.11演示了这个查询。通过遍历一个旨在捕捉显式语言关系(在本例中是“in love with”)的节点,我们可以使用中间节点来建模起始节点和终止节点之间的边。
列表 5.11 通过“关系节点”实现边的物化
scifi_skg = get_skg(engine.get_collection("scifi"))
starting_node = "jean grey"
relationship = "in love with"
nodes_to_traverse = [{"field": "body", "values": [starting_node]},
{"field": "body", "values": [relationship],
"default_operator": "OR"},
{"field": "body",
"min_occurrences": 25, "limit": 10}]
traversal = scifi_skg.traverse(*nodes_to_traverse)
print_graph(traversal, starting_node, relationship)
输出:
jean 0.84915
grey 0.74742
summers 0.61021
cyclops 0.60693
xavier 0.53004
wolverine 0.48053
mutant 0.46532
x 0.45028
mutants 0.42568
magneto 0.42197
如果你不熟悉这些角色,下面是Jean Grey的相关背景:她与两位变种人有过反复的关系,其中一位名为Cyclops(真名:Scott Summers),另一位是Wolverine。此外,大多数粉丝不知道,Jean Grey的两位导师——Professor Charles Xavier和Magneto——也曾在漫画书中对Jean Grey有过爱慕之情。
如果我们查看列表5.11中的结果,我们会看到所有这些预期的名字都列在其中。前两个术语,“jean”和“grey”,是最相关的,因为我们是在查找相对于Jean Grey的“in love with”。她的名字与她自己会高度语义相关。接下来的两个术语,“summers”和“cyclops”,都指代同一个人,即Jean的最著名的爱人。接着我们看到“xavier”和“wolverine”,列表中的最后一个结果是“magneto”。图5.7展示了此遍历的部分底层图关系。
通过使用中间节点(即“in love with”)来建模其他节点之间的关系,我们可以在节点之间形成任何类型的边,只要我们能够将该边表达为搜索查询。
虽然在列表5.11中的图遍历结果相当不错,但我们确实看到“x”(可能来自“X-Men”)和“mutant”这两个术语也出现了。Jean Grey和其他列出的所有人都是《X战警》中的变种人,这就是这些术语如此语义相关的原因。然而,这些术语并不是“谁爱上了Jean Grey?”这一问题的最佳答案。
这引出了一个重要的问题:SKG是一个统计知识图谱。“in love with”关系的存在完全基于我们集合中术语的统计相关性,因此,就像任何本体学习方法一样,这里也会有噪声。也就是说,对于一个没有明确建模实体的自动生成图谱来说,这些结果已经相当不错。
如果我们想提高这些结果的质量,最简单的做法之一就是对内容进行预处理,识别实体(人物、地点和事物)并索引这些实体,而不仅仅是单一的关键词。这将导致实际人物的名字(例如,“Scott Summers”,“Charles Xavier”,“Jean Grey”)被返回,而不仅仅是单个关键词(例如,“summers”,“xavier”,“jean”,“grey”)。
还值得指出的是,关系的遍历完全依赖于这些关系是否在底层文档集合中有所讨论。在本例中,许多论坛帖子讨论了这些人与Jean Grey的关系。如果相关文档不足,返回的结果可能会很差或根本不存在。为了避免结果中的噪声,我们设置了min_occurrences阈值为25,表示至少必须有25篇文档讨论“jean grey”、“in love with”以及其他已找到并评分的节点。我们建议将min_occurrences设置为大于1的数值,以避免假阳性。
虽然从探索的角度来看,遍历像“in love with”这样的任意语言关系是有用的,但从查询理解的角度来看,通常仅使用默认的“is related to”关系,并利用术语之间的相关性得分,已经足以满足大多数语义搜索用例。然而,通过多级关系遍历来生成更好的上下文仍然是有用的。具体来说,从一个术语遍历到一个分类字段以提供一些额外的上下文,然后再遍历该分类中与该术语相关的含义,是非常有帮助的。我们将在第6章中更详细地介绍这一策略,重点讨论如何消除具有多重含义的术语歧义。
5.5 使用知识图谱进行语义搜索
通过提供接受任意查询的能力,并以上下文敏感的方式动态发现相关术语,SKG(语义知识图谱)成为查询解释和相关性排名的关键工具。我们已经看到,SKG不仅能够帮助解释和扩展查询,还能提供实时分类查询和关键词的能力,并消除查询中术语的多重含义。
在本章的早期,我们还探讨了如何通过开放信息提取技术构建显式的知识图谱。可能尚不明显的是,如何解析任意传入的查询,并在知识图谱中查找适当的上下文和实体。在第7章中,我们将花大部分篇幅讨论如何构建一个端到端的语义搜索系统,该系统能够解析查询并整合这些知识图谱能力。
我们仍然需要在知识图谱中添加一些对搜索引擎非常重要的关键关系,比如拼写错误、同义词和特定领域的短语。我们将在下一章中介绍如何通过用户信号或内容自动学习这些领域特定术语的来源,重点讲解如何学习领域特定的语言。
总结
- 知识图谱建模了领域内实体之间的关系,可以通过已知的关系显式构建,也可以从内容中动态提取。
- 开放信息提取(Open Information Extraction)是从内容中提取事实(主题、关系、对象三元组)的一种过程,它可以用于学习任意关系(通常会导致噪声数据),或者从文本中提取下位词和上位词关系(噪声较少),并将其转化为显式的知识图谱。
- 语义知识图谱(SKG)支持任意语义关系的遍历和排名,可以在搜索索引中的任何内容之间进行关系发现和排序。这使得可以直接使用索引中的内容作为知识图谱和语言模型,而无需额外的数据建模。
- 不依赖用户信号的基于内容的推荐可以通过对文档中最具语义兴趣的术语和短语进行排名,并使用这些术语作为查询来查找和排名其他相关文档。
- SKG通过推动领域敏感和上下文敏感的关系发现和查询扩展,帮助更好地理解用户意图。
[1] Grainger等人,“The Semantic Knowledge Graph: A compact, auto-generated model for real-time traversal and ranking of any relationship within a domain.” 2016 IEEE International Conference on Data Science and Advanced Analytics (DSAA), pp. 420–429. IEEE, 2016.