
从零开始到知识图谱英雄:用 Llms 构建强大的 Kg!
将你的熊猫数据框转变为知识图谱,使用大语言模型。 从零开始构建你自己的LLM图形构建器,通过LangChain实现LLM图形转换器,并对你的知识图谱进行问答。
关于维基百科1000部电影的知识图谱 — 作者提供的图片
在今天的人工智能世界中,知识图谱变得越来越重要,因为它们支持了许多大语言模型背后的知识检索系统。许多公司的数据科学团队正在大力投资于检索增强生成(RAG),因为这是一种有效提高大语言模型输出准确性并防止幻觉的方法。
但这并不仅仅是这样;从个人的角度来看,图形检索增强生成正在使人工智能领域民主化。这是因为以前如果我们想要将模型定制到某个用例——无论是出于乐趣还是商业——我们会有三种选择:
- 预训练模型,以便在你的用例行业中提供更大的数据集曝光。
- 在特定数据集上微调模型。
- 上下文提示。
至于预训练,这个选项非常昂贵且技术复杂,对于大多数开发者来说并不是一个可行的选择。
微调比预训练要简单,尽管微调的成本取决于模型和训练语料库,但通常是一个更实惠的选项。这是大多数人工智能开发者的首选策略。然而,每周都有新模型发布,你需要不断地对新模型进行微调。
第三个选项涉及直接在提示中提供知识。然而,这只有在模型学习所需的知识相对较小时才有效。尽管模型的上下文越来越大,但回忆某个元素的准确性与提供的上下文大小呈反比关系。
这三种选择都听起来不太合适。有没有其他选项让模型学习所有必要的知识,以便在某个任务或主题上专业化?没有。
但是,模型不需要一次性学习所有知识,因为当我们询问大语言模型时,我们很可能只想获取一条或几条信息。图形检索增强生成在这里提供帮助,通过基于查询检索所需信息的方式,无需进一步训练。
让我们看看图形检索增强生成是什么样的:
- 图形构建:在这里,我们从数据源创建节点(实体)和边(关系),并将它们加载到我们的知识图谱中。这通常是一个更手动的步骤,我们使用查询语言,通常是OpenCypher,将实体上传并通过边将它们相互连接。
- 节点索引:这一步涉及创建一个数据结构,使我们能够高效检索数据。在知识图谱中,这通常涉及创建一个向量搜索索引,其中每个索引都与一个向量嵌入相关联。
- 图形检索器:在这里,我们构建一个检索函数,使我们能够计算相似度分数并检索最相关的节点,以作为上下文供大语言模型提供答案。在最简单的情况下,我们对查询计算余弦相似度,该查询被转换为向量嵌入,并与向量索引中的所有向量嵌入进行比较。
- RAG评估:最后一步对实际测量大语言模型的准确性和性能是有用的。在实验过程中,评估不同的大语言模型和RAG框架在你的特定用例上的表现是有帮助的。
现在我们对RAG管道的整体构成有了一个概览,我们可能会被吸引去直接尝试复杂的数学函数进行图形检索,以确保最佳的信息检索准确性。但……等一下。我们还没有知识图谱。这个步骤可能看起来像数据科学中的经典数据清洗和预处理步骤(无聊……)。但是如果我告诉你有一个更好的替代方案呢?一个引入更多科学和自动化的选项。实际上,最近的研究集中在如何自动构建知识图谱,因为这个步骤对于良好的信息检索至关重要。想想看,如果知识图谱中的数据不好,你的图形检索增强生成就不可能达到顶尖的性能。
在本文中,我们将深入探讨第一步:如何在不实际构建知识图谱的情况下构建知识图谱。
从CSV到知识图谱
现在,让我们通过一个实际的例子来使事情更加具体。让我们解决一个最重要的存在性问题:看哪部电影?……你有多少次感到无聊,工作疲惫,唯一能做的就是看电影?你开始在电影中滚动,直到意识到两个小时已经过去。
为了解决这个问题,让我们使用维基百科电影数据集创建一个知识图谱,并与KG进行对话。首先,让我们使用大语言模型实现一个“从头开始”的解决方案。然后,让我们看看通过LangChain(截至2024年11月仍处于实验阶段)实现的最新解决方案之一,这是一个最受欢迎和强大的大语言模型框架,以及另一个流行的解决方案LlamaIndex。
让我们从Kaggle下载这个公共数据集(许可证:CC BY-SA 4.0):
或者如果你懒得动手,直接克隆我的GitHub仓库:
文件夹knowledge-builder
包含了我们将在本文中讨论的Jupyter笔记本和数据。
前提条件
在我们开始之前,我们需要访问 Neo4j Desktop 和一个 LLM API密钥或一个本地 LLM。如果您已经拥有它们,可以跳过此部分,直接进入操作。如果没有,让我们来设置它们。
利用 Neo4j 有几种方法,但为了简单起见,我们将使用 Neo4j Desktop,因此我们将本地托管数据库。但这是一个小数据集,因此运行此应用程序不会损坏您的笔记本电脑。
要安装 Neo4j,只需访问 Neo4j Desktop 下载页面 并点击下载。安装后打开 Neo4j Desktop。登录或创建一个 Neo4j 账户(激活软件所需)。
登录后,创建一个 新项目:
- 点击左上角的
**+**
按钮。 - 为您的项目命名(例如,“Wiki 电影 KG”)。
在您的项目中,点击 **添加数据库**
。选择 本地 DBMS 并点击 创建本地图形。
配置您的数据库:
- 名称:输入一个名称(例如,
neo4j
)。 - 密码:设置一个密码(例如,
ilovemovies
)。记住这个密码以备后用。
点击 创建 以初始化数据库。
接下来,我们来看看我们的 LLM。运行此笔记本的首选方式是使用 Ollama。Ollama 是一个本地托管的 LLM 解决方案,可以让您轻松下载和设置 LLM 到您的笔记本电脑上。它支持许多开源 LLM,包括 Meta 的 Llama 和 Google 的 Gemma。
要下载 Ollama,请访问 Ollama 的官方网站,并下载适合您操作系统的安装程序。安装后打开 Ollama 应用程序。
打开终端并使用以下命令列出可用模型:
ollama list
安装并运行一个模型。我们将使用 qwen2.5-coder:latest
,这是一个在代码任务上微调的 7B 语言模型。
ollama run qwen2.5-coder:latest
验证安装:
您现在应该看到:
另一个免费的替代方案是 Google 的 Gemini,它允许我们每天运行 1500 个请求。这个解决方案实际上优于前一个,因为我们使用的是一个更大更强大的模型。但是,根据您每天执行脚本的次数,您可能会达到限制。
要获取 Gemini 的免费 API密钥,请访问 网站 并点击“获取 API密钥”。然后按照说明复制生成的 API 密钥。我们稍后将使用它。
: clean_text(director) } connection.execute_query(DIRECTOR_QUERY, parameters={**movie_params, **director_params})
for actor in str(row["Main Actor and Actresses"]).split(","):
actor_params = {
"name": clean_text(actor)
}
connection.execute_query(ACTOR_QUERY, parameters={**movie_params, **actor_params})
except Exception as e:
logger.error(f"Error loading movie {row['Title']}: {e}")
通过这些步骤,我们将电影数据加载到Neo4j图形数据库中,同时跟踪进度并处理错误。这使得我们的知识图谱逐步建立起来。
### 更新节点和边属性
`**SET**` 用于更新节点或边的属性。在这种情况下,我们为电影节点提供了年份、评分、类型、时长和概述;并为导演和演员提供了名称属性。
另外,请注意,我们使用特殊符号 `$` 来定义参数。
接下来,让我们调用函数并加载所有电影:
```python
load_movies_to_neo4j(movies, conn)
加载所有1000部电影大约需要一分钟。
执行完成后,让我们运行一个 Cypher 查询以检查电影是否正确上传:
query = """
MATCH (m:Movie)-[:ACTED_IN]-(a:Actor)
RETURN m.title, a.name
LIMIT 10;
"""
results = conn.execute_query(query)
for record in results:
print(record)
这个查询现在应该看起来很熟悉。我们使用 **MATCH**
来查找图中的模式。
(m:Movie)
:匹配标签为 “Movie” 的节点。[:ACTED_IN]
:匹配类型为 “ACTED_IN” 的关系。(a:Actor)
:匹配标签为 “Actor” 的节点。
此外,我们使用 **RETURN**
来指定要显示的内容——在这种情况下,电影标题和演员名称,使用 **LIMIT**
限制结果为前10个匹配项。
你应该得到类似于以下的输出:
[<Record m.title='Daniel Boone' a.name='William Craven'>,
<Record m.title='Daniel Boone' a.name='Florence Lawrence'>,
<Record m.title='Laughing Gas' a.name='Bertha Regustus'>,
<Record m.title='Laughing Gas' a.name='Edward Boulden'>,
<Record m.title="A Drunkard'S Reformation" a.name='Arthur V. Johnson'>,
<Record m.title='The Adventures Of Dollie' a.name='Arthur V. Johnson'>,
<Record m.title='A Calamitous Elopement' a.name='Linda Arvidson'>,
<Record m.title='The Adventures Of Dollie' a.name='Linda Arvidson'>,
<Record m.title='The Black Viper' a.name='D. W. Griffith'>,
<Record m.title='A Calamitous Elopement' a.name='Harry Solter'>]
好的,这10条记录代表电影和演员,确认这些电影已经上传到知识图谱上。
接下来,让我们查看实际的图形。切换到 Neo4j Desktop,选择为本次练习创建的电影数据库,然后点击使用 Neo4j 浏览器打开。这将打开一个新标签页,在这里你可以运行 Cypher 查询。然后,运行以下查询:
MATCH p=(m:Movie)-[r]-(n)
RETURN p
LIMIT 100;
你现在应该看到类似于以下的内容:
手动知识图谱 — 作者图片
然而,这确实需要一些时间来探索数据集,进行一些清理,并手动编写 Cypher 查询。在下一部分,我们将利用大语言模型创建一个基本的知识图谱。
创建自定义大语言模型以自动化数据上传
在本节中,我们创建一个自定义流程,其中大语言模型根据数据集自动生成节点定义、关系和Cypher查询。此方法也可以应用于其他数据框,并自动识别模式。然而,请考虑到它无法与现代解决方案的性能相匹配,例如我们将在下一节中介绍的来自LangChain的LLM图形转换器。相反,利用本节内容了解可能的“从零开始”工作流程,发挥创造力,并随后设计自己的Graph-Builder
。实际上,如果当前SOTA(最先进)方法有一个主要限制,那就是对数据和模式的性质高度敏感。因此,能够跳出框架思考对于从头设计Graph-RAG框架或能够根据需求调整现有SOTA Graph-RAG至关重要。
现在,让我们开始设置我们将在本练习中使用的大语言模型。您可以使用LangChain支持的任何大语言模型,只要它足够强大以匹配合理的性能。
有两种免费的方法,Gemini可以免费使用高达每天1500次请求,使用他们的Gemini Flash
模型,和Ollama
,它允许您轻松下载开源模型到您的笔记本电脑,并设置一个可以通过LangChain轻松调用的API。我用Gemini和自定义Ollama模型测试了笔记本,尽管Gemini保证了更好的性能,但我强烈建议出于学习目的选择Ollama,因为玩弄“自己的”LLM更加酷。
在Ollama示例中,我们将使用qwen2.5-coder 7B
,它经过微调以处理特定的代码任务,并在代码生成、推理和修复方面表现出色。根据您的内存可用性和笔记本性能,您可以下载14B或32B版本,这将保证更高的性能。
让我们初始化模型:
llm = OllamaLLM(model="qwen2.5-coder:latest")
如果您选择Gemini作为解决方案,请取消注释第一行代码并注释第二行。此外,如果您选择Gemini,请记得提供API密钥。
让我们首先提取数据集的结构并定义节点及其属性:
node_structure = "\\n".join([
f"{col}: {', '.join(map(str, movies[col].unique()[:3]))}..."
for col in movies.columns
])
print(node_structure)
对于数据集中的每一列(例如,Genre
、Director
),我们展示几个示例值。这使得大语言模型理解数据格式和每一列的典型值。
- 发行年份:1907, 1908, 1909…
- 标题:Daniel boone, Laughing gas, The adventures of dollie…
- 来源/种族:美国…
- 导演:Wallace mccutcheon 和 ediwin s. porter, Edwin stanton porter, D. w. griffith…
- 演员:William craven, florence lawrence, Bertha regustus, edward boulden, Arthur v. johnson, linda arvidson…
- 类型:传记, 喜剧, 剧情…
- 剧情:Boone的女儿与一位印第安少女交朋友,Boone和他的同伴开始了一次狩猎探险。在他离开的期间,Boone的小屋被印第安人袭击,印第安人放火并绑架了Boone的女儿。Boone回来后,发誓要复仇,然后出发前往印第安营地。他的女儿逃脱了但被追赶。印第安人遇到Boone,这引发了一场在悬崖边缘的巨大斗争。一支燃烧的箭射入印第安营地。Boone被绑在火刑柱上并受到折磨。燃烧的箭将印第安营地点燃,引发恐慌。Boone被他的马救了,Boone与印第安首领进行刀战,杀死了印第安首领。[2] 剧情是关于一位黑人女性因牙痛去看牙医并被给予笑气。在回家的路上,以及在其他情况下,她无法停止大笑,遇到的每个人都“感染”了她的笑声,包括一个小贩和警察。一个美丽的夏日,一对父母带着他们的女儿Dollie去河边游玩。母亲拒绝购买一个吉普赛人的商品。吉普赛人试图抢劫母亲,但父亲将他赶走。吉普赛人回到营地并设计了一个计划。他们返回并在父母分心时绑架Dollie。组织了一支救援队,但吉普赛人把Dollie带到他的营地。他们用布塞住Dollie的嘴,并在救援队到达营地之前把她藏在一个桶里。一旦他们离开,吉普赛人就逃走了。当马车穿过河流时,桶掉入水中。Dollie仍然被封在桶里,被危险的水流冲走。一个在河里钓鱼的男孩发现了这个桶,Dollie安全地与父母团聚…
生成节点
接下来,我们使用LLM提示模板来指示模型如何提取节点及其属性。让我们首先看一下整个代码的样子:
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def validate_node_definition(node_def: Dict) -> bool:
"""验证节点定义结构"""
if not isinstance(node_def, dict):
return False
return all(
isinstance(v, dict) and all(isinstance(k, str) for k in v.keys())
for v in node_def.values()
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
def get_node_definitions(chain, structure: str, example: Dict) -> Dict[str, Dict[str, str]]:
"""获取节点定义并带有重试逻辑"""
try:
response = chain.invoke({"structure": structure, "example": example})
node_defs = ast.literal_eval(response)
if not validate_node_definition(node_defs):
raise ValueError("无效的节点定义结构")
return node_defs
except (ValueError, SyntaxError) as e:
logger.error(f"解析节点定义时出错: {e}")
raise
node_example = {
"NodeLabel1": {"property1": "row['property1']", "property2": "row['property2']"},
"NodeLabel2": {"property1": "row['property1']", "property2": "row['property2']"},
"NodeLabel3": {"property1": "row['property1']", "property2": "row['property2']"},
}
define_nodes_prompt = PromptTemplate(
input_variables=["example", "structure"],
template=("""
分析以下数据集结构并提取节点的实体标签及其属性。\\n
节点属性应基于数据集列及其值。\\n
将结果作为字典返回,其中键是节点标签,值是节点属性。\\n\\n
示例: {example}\\n\\n
数据集结构:\\n{structure}\\n\\n
确保包括所有可能的节点标签及其属性。\\n
如果一个属性可以是自己节点,请将其作为单独的节点标签包含。\\n
请不要报告三重反引号以识别代码块,只需返回元组列表。\\n
仅返回包含节点标签和属性的字典,不要包含任何其他文本或引号。
""")
)
try:
node_chain = define_nodes_prompt | llm
node_definitions = get_node_definitions(node_chain, structure=node_structure, example=node_example)
logger.info(f"节点定义: {node_definitions}")
except Exception as e:
logger.error(f"获取节点定义失败: {e}")
raise
在这里,我们首先使用logging
库设置日志记录,这是一个用于在执行过程中跟踪事件(如错误或状态更新)的Python模块:
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
我们使用basicConfig
配置日志记录,以显示INFO
级别或更高的消息,并初始化logger
实例,我们将在整个代码中使用它来记录消息。
这一步实际上不是必需的,您可以用简单的打印语句替代。然而,这是一种良好的工程实践。
接下来,我们创建一个函数来验证大语言模型生成的节点:
def validate_node_definition(node_def: Dict) -> bool:
"""验证节点定义结构"""
if not isinstance(node_def, dict):
return False
return all(
isinstance(v, dict) and all(isinstance(k, str) for k in v.keys())
for v in node_def.values()
)
该函数的输入是一个字典
### 节点定义和关系提取
* `**chain**`: LangChain 管道(提示 + 大语言模型)。我们稍后将定义该链。
* `**structure**`: 数据集结构(列和示例值)。
* `**example**`: 一个示例节点定义,以指导大语言模型。
接下来,`**chain.invoke**` 将 `structure` 和 `example` 发送给大语言模型,并接收一个字符串作为响应。`**ast.literal_eval**` 将字符串响应转换为 Python 字典。
我们使用 `validate_node_definition` 检查解析后的字典是否格式正确,如果结构无效,则会引发 `ValueError`。
```python
except (ValueError, SyntaxError) as e:
logger.error(f"解析节点定义时出错: {e}")
raise
如果响应无法解析或验证,则会记录错误,函数将引发异常。
接下来,让我们为大语言模型提供一个提示模板,以指导其进行节点生成任务:
define_nodes_prompt = PromptTemplate(
input_variables=["example", "structure"],
template=("""
分析下面的数据集结构并提取节点的实体标签及其属性。\\n
节点属性应基于数据集列及其值。\\n
返回结果为字典,其中键为节点标签,值为节点属性。\\n\\n
示例: {example}\\n\\n
数据集结构:\\n{structure}\\n\\n
确保包含所有可能的节点标签及其属性。\\n
如果某个属性可以作为节点,请将其作为单独的节点标签包含。\\n
请不要报告三重反引号以标识代码块,只需返回元组列表。\\n
仅返回包含节点标签和属性的字典,不要包含任何其他文本或引号。
"""),
)
请注意,我们提供了在本节开头定义的节点结构,以及如何生成节点字典的示例:
node_example = {
"NodeLabel1": {"property1": "row['property1']", "property2": "row['property2']"},
"NodeLabel2": {"property1": "row['property1']", "property2": "row['property2']"},
"NodeLabel3": {"property1": "row['property1']", "property2": "row['property2']"},
}
在该示例中,键是节点标签(例如,Movie
,Director
),值是映射到数据集列的属性字典(例如,row['property1']
)。
接下来,让我们执行该链:
try:
node_chain = define_nodes_prompt | llm
node_definitions = get_node_definitions(node_chain, structure=node_structure, example=node_example)
logger.info(f"节点定义: {node_definitions}")
except Exception as e:
logger.error(f"获取节点定义失败: {e}")
raise
在 LangChain 中,我们使用结构 prompt | llm | ...
创建一个链,该结构将提示模板与大语言模型结合,形成管道。我们使用 **get_node_definitions**
获取并验证节点定义。
如果过程失败,错误将被记录,程序将引发异常。
如果过程成功,它将生成类似于以下内容的结果:
INFO:__main__:节点定义: {'Movie': {'Release Year': "row['Release Year']", 'Title': "row['Title']"}, 'Director': {'Name': "row['Director']"}, 'Cast': {'Actor': "row['Cast']"}, 'Genre': {'Type': "row['Genre']"}, 'Plot': {'Description': "row['Plot']"}}
生成关系
一旦定义了节点,我们就会识别它们之间的关系。首先,让我们看看整个代码的样子:
class RelationshipIdentifier:
"""识别图形数据库中节点之间的关系。"""
RELATIONSHIP_EXAMPLE = [
("NodeLabel1", "RelationshipLabel", "NodeLabel2"),
("NodeLabel1", "RelationshipLabel", "NodeLabel3"),
("NodeLabel2", "RelationshipLabel", "NodeLabel3"),
]
PROMPT_TEMPLATE = PromptTemplate(
input_variables=["structure", "node_definitions", "example"],
template="""
考虑以下数据集结构:\\n{structure}\\n\\n
考虑以下节点定义:\\n{node_definitions}\\n\\n
基于数据集结构和节点定义,识别节点之间的关系(边)。\\n
将关系作为三元组列表返回,其中每个三元组包含起始节点标签、关系标签和结束节点标签,每个三元组是一个元组。\\n
请仅返回元组列表。请不要报告三重反引号以标识代码块,只需返回元组列表。\\n\\n
示例:\\n{example}
"""
)
def __init__(self, llm: Any, logger: logging.Logger = None):
self.llm = llm
self.logger = logger or logging.getLogger(__name__)
self.chain = self.PROMPT_TEMPLATE | self.llm
def validate_relationships(self, relationships: List[Tuple]) -> bool:
"""验证关系结构。"""
return all(
isinstance(rel, tuple) and
len(rel) == 3 and
all(isinstance(x, str) for x in rel)
for rel in relationships
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
def identify_relationships(self, structure: str, node_definitions: Dict) -> List[Tuple]:
"""使用重试逻辑识别关系。"""
try:
response = self.chain.invoke({
"structure": structure,
"node_definitions": str(node_definitions),
"example": str(self.RELATIONSHIP_EXAMPLE)
})
relationships = ast.literal_eval(response)
if not self.validate_relationships(relationships):
raise ValueError("无效的关系结构")
self.logger.info(f"识别到 {len(relationships)} 个关系")
return relationships
except Exception as e:
self.logger.error(f"识别关系时出错: {e}")
raise
def get_relationship_types(self) -> List[str]:
"""提取唯一的关系类型。"""
return list(set(rel[1] for rel in self.identify_relationships()))
identifier = RelationshipIdentifier(llm=llm)
relationships = identifier.identify_relationships(node_structure, node_definitions)
print("关系:", relationships)
由于此代码需要比节点生成更多的操作,我们将代码组织在一个类中——RelationshipIdentifier
——以封装所有关系提取、验证和记录的逻辑。
我们使用类似的逻辑,因此提供一个关系示例:
RELATIONSHIP_EXAMPLE = [
("NodeLabel1", "RelationshipLabel", "NodeLabel2"),
("NodeLabel1", "RelationshipLabel", "NodeLabel3"),
("NodeLabel2", "RelationshipLabel", "NodeLabel3"),
]
在这里,每个关系是一个元组,包含:
- 起始节点标签: 源节点的标签(例如,
Movie
)。 - 关系标签: 连接类型(例如,
DIRECTED_BY
)。 - 结束节点标签: 目标节点的标签(例如,
Director
)。
接下来,我们定义实际的提示模板:
PROMPT_TEMPLATE = PromptTemplate(
input_variables=["structure", "node_definitions", "example"],
template="""
考虑以下数据集结构:\\n{structure}\\n\\n
考虑以下节点定义:\\n{node_definitions}\\n\\n
基于数据集结构和节点定义,识别节点之间的关系(边)。\\n
将关系作为三元组列表返回,其中每个三元组包含起始节点标签、关系标签和结束节点标签,每个三元组是一个元组。\\n
请仅返回元组列表。请不要报告三重反引号以标识代码块,只需返回元组列表。\\n\\n
示例:\\n{example}
"""
)
在这种情况下,我们有三个输入变量:
**structure**
: 数据集结构,列出列和示例值。我们在本节开头定义了它。**node_definitions**
: 节点标签及其属性的字典。这些是由大语言模型在上一节生成的节点。**example**
: 元组格式的示例关系。
接下来,我们使用三个属性初始化类:
def __init__(self, llm: Any, logger: logging.Logger = None):
self.llm = llm
self.logger = logger or logging.getLogger(__name__)
self.chain = self.PROMPT_TEMPLATE | self.llm
- `llm
### 识别关系
```python
self.logger.info(f"识别到 {len(relationships)} 个关系")
return relationships
我们再次使用重试装饰器在失败的情况下重新尝试大语言模型链,并以与我们在节点生成中调用它的方式类似地调用该链。
此外,我们使用 **ast.literal_eval**
将大语言模型的字符串输出转换为 Python 列表,并使用 validate_relationships
确保输出格式正确。
except Exception as e:
self.logger.error(f"识别关系时出错: {e}")
raise
如果方法失败,它会记录错误并最多重试 3 次。
最后一个方法返回唯一的关系标签(例如,DIRECTED_BY
、ACTED_IN
):
def get_relationship_types(self) -> List[str]:
"""提取唯一的关系类型。"""
return list(set(rel[1] for rel in self.identify_relationships()))
它调用 identify_relationships
方法以获取关系列表。然后,从每个元组中提取第二个元素(关系标签),使用 set
去除重复项,并将结果转换回列表。
现在,终于是生成关系的时候了:
identifier = RelationshipIdentifier(llm=llm)
relationships = identifier.identify_relationships(node_structure, node_definitions)
print("关系:", relationships)
如果大语言模型在 3 次尝试内成功,它将返回类似于以下的元组格式关系列表:
INFO:__main__:识别到 4 个关系
关系: [('电影', '导演', '导演'), ('电影', '主演', '演员'), ('电影', '类型', '类型'), ('电影', '情节', '情节')]
生成 Cypher 查询
在定义了节点和关系后,我们创建 Cypher 查询以将它们加载到 Neo4j 中。该过程遵循与节点生成和关系生成相似的逻辑。然而,我们定义了更多的验证步骤,因为生成的输出将用于将数据加载到知识图谱中。因此,我们需要最大化成功的机会。让我们先看一下整个代码:
class CypherQueryBuilder:
"""为 Neo4j 图形数据库构建 Cypher 查询。"""
输入示例
INPUT_EXAMPLE = """
节点标签1: 值1, 值2
节点标签2: 值1, 值2
"""
示例 Cypher
EXAMPLE_CYPHER = example_cypher = """
CREATE (n1:节点标签1 {属性1: "row['属性1']", 属性2: "row['属性2']"})
CREATE (n2:节点标签2 {属性1: "row['属性1']", 属性2: "row['属性2']"})
CREATE (n1)-[:关系标签]->(n2);
"""
提示模板
PROMPT_TEMPLATE = PromptTemplate(
input_variables=["structure", "node_definitions", "relationships", "example"],
template="""
考虑以下节点定义:\n{node_definitions}\n\n
考虑以下关系:\n{relationships}\n\n
生成 Cypher 查询以使用下面的节点定义和关系创建节点和关系。请记得用数据集中的实际数据替换占位符值。\n
包含节点定义中每个节点的所有属性,并创建关系。\n
返回一个字符串,每个查询用分号分隔。\n
不要在响应中包含任何其他文本或引号。\n
请仅返回包含 Cypher 查询的字符串。请不要报告三个反引号以标识代码块。\n\n
示例输入:\n{input}\n\n
示例输出 Cypher 查询:\n{cypher}
"""
)
初始化
def __init__(self, llm: Any, logger: logging.Logger = None):
self.llm = llm
self.logger = logger or logging.getLogger(__name__)
self.chain = self.PROMPT_TEMPLATE | self.llm
验证方法
def validate_cypher_query(self, query: str) -> bool:
"""使用大语言模型和正则表达式模式验证 Cypher 查询语法。"""
验证提示
VALIDATION_PROMPT = PromptTemplate(
input_variables=["query"],
template="""
验证此 Cypher 查询并返回 TRUE 或 FALSE:
查询: {query}
检查规则:
1. 有效的 CREATE 语句
2. 正确的属性格式
3. 有效的关系语法
4. 无缺失的括号
5. 有效的属性名称
6. 有效的关系类型
仅在查询有效时返回 TRUE,查询无效时返回 FALSE。
"""
)
验证的尝试-异常
try:
basic_valid = all(re.search(pattern, query) for pattern in [
r'CREATE \\(',
r'\\{.*?\\}',
r'\\)-\\[:.*?\\]-\\>'
])
if not basic_valid:
return False
validation_chain = VALIDATION_PROMPT | self.llm
result = validation_chain.invoke({"query": query})
is_valid = "TRUE" in result.upper()
if not is_valid:
self.logger.warning(f"大语言模型验证失败,查询: {query}")
return is_valid
except Exception as e:
self.logger.error(f"验证错误: {e}")
return False
清理查询
def sanitize_query(self, query: str) -> str:
"""清理和格式化 Cypher 查询。"""
return (query
.strip()
.replace('\\n', ' ')
.replace(' ', ' ')
.replace("'row[", "row['")
.replace("]'", "'\]"))
使用重试逻辑构建查询
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
def build_queries(self, node_definitions: Dict, relationships: List) -> str:
"""使用重试逻辑构建 Cypher 查询。"""
try:
response = self.chain.invoke({
"node_definitions": str(node_definitions),
"relationships": str(relationships),
"input": self.INPUT_EXAMPLE,
"cypher": self.EXAMPLE_CYPHER
})
if '```' in response:
response = response.split('```')[1]
queries = self.sanitize_query(response)
if not self.validate_cypher_query(queries):
raise ValueError("无效的 Cypher 查询语法")
self.logger.info("成功生成 Cypher 查询")
return queries
except Exception as e:
self.logger.error(f"构建 Cypher 查询时出错: {e}")
raise
拆分查询
def split_queries(self, queries: str) -> List[str]:
"""将组合查询拆分为单独的语句。"""
return [q.strip() for q in queries.split(';') if q.strip()]
构建 Cypher 查询
builder = CypherQueryBuilder(llm=llm)
cypher_queries = builder.build_queries(node_definitions, relationships)
print("Cypher 查询:", cypher_queries)
我们提供一个提示模板以帮助大语言模型:
PROMPT_TEMPLATE = PromptTemplate(
input_variables=["structure", "node_definitions", "relationships", "example"],
template="""
考虑以下节点定义:\n{node_definitions}\n\n
考虑以下关系:\n{relationships}\n\n
生成 Cypher 查询以使用下面的节点定义和关系创建节点和关系。请记得用数据集中的实际数据替换占位符值。\n
包含节点定义中每个节点的所有属性,并创建关系。\n
返回一个字符串,每个查询用分号分隔。\n
不要在响应中包含任何其他文本或引号。\n
请仅返回包含 Cypher 查询的字符串。请不要报告三个反引号以标识代码块。\n\n
示例输入:\n{input}\n\n
示例输出 Cypher 查询:\n{cypher}
"""
)
现在我们提供四个输入给提示:
**structure**
: 数据集结构作为上下文。**node_definitions**
: 生成的节点及其属性。**relationships**
: 节点之间生成的边。**example**
: 用于格式参考的示例查询。
再次初始化
def __init__(self, llm: Any, logger: logging.Logger = None):
self.llm = llm
self.logger = logger or logging.getLogger(__name__)
self.chain = self.PROMPT_TEMPLATE | self.llm
再次
validation_chain = VALIDATION_PROMPT | self.llm
result = validation_chain.invoke({"query": query})
is_valid = "TRUE" in result.upper()
验证是在提示中指定的,我们要求大语言模型确保我们拥有:
1. 有效的 CREATE 语句
2. 正确的属性格式
3. 有效的关系语法
4. 没有缺失的括号
5. 有效的属性名称
6. 有效的关系类型
尽管我们现在应该处于良好的状态,但让我们添加一个方法,以进一步清理生成的输出:
def sanitize_query(self, query: str) -> str:
"""清理并格式化 Cypher 查询。"""
return (query
.strip()
.replace('\\n', ' ')
.replace(' ', ' ')
.replace("'row[", "row['")
.replace("]'", "'\]"))
特别是,我们正在删除不必要的空格以及换行符 (`\n`),并修复数据集引用的潜在格式问题(例如,`row['property1']`)。
请考虑根据您使用的模型更新此方法。较小的模型可能需要更多的清理。
接下来,我们定义一个查询调用方法:
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
def build_queries(self, node_definitions: Dict, relationships: List) -> str:
"""构建具有重试逻辑的 Cypher 查询。"""
try:
response = self.chain.invoke({
"node_definitions": str(node_definitions),
"relationships": str(relationships),
"input": self.INPUT_EXAMPLE,
"cypher": self.EXAMPLE_CYPHER
})
## 获取三重反引号内的响应
if '```' in response:
response = response.split('```')[1]
## 清理响应
queries = self.sanitize_query(response)
验证查询
if not self.validate_cypher_query(queries):
raise ValueError("无效的 Cypher 查询语法")
self.logger.info(“成功生成 Cypher 查询”) return queries
except Exception as e:
self.logger.error(f"构建 Cypher 查询时出错: {e}")
raise
此方法的工作方式与关系构建器类中的方法类似,唯一的补充是:
if '```' in response:
response = response.split('```')[1]
在这里,LLM 可能会提供额外的 markdown 格式来指定它是一个代码块。如果在 LLM 的响应中存在这一点,我们只会检索三重反引号中的代码。
接下来,我们定义一个方法,将单个字符串的 Cypher 查询拆分为单独的语句:
def split_queries(self, queries: str) -> List[str]:
"""将组合查询拆分为单独的语句。"""
return [q.strip() for q in queries.split(';') if q.strip()]
例如,这个 Cypher 查询:
CREATE (n1:Movie {title: "Inception"}); CREATE (n2:Director {name: "Nolan"});
这将变成:
["CREATE (n1:Movie {title: 'Inception'})", "CREATE (n2:Director {name: 'Nolan'})"]
这将很有用,以便我们可以遍历查询列表。
最后,我们初始化类并生成 Cypher 查询:
builder = CypherQueryBuilder(llm=llm)
cypher_queries = builder.build_queries(node_definitions, relationships)
print("Cypher 查询:", cypher_queries)
如果成功,输出将如下所示:
INFO:__main__:成功生成 Cypher 查询
Cypher 查询: CREATE (m:Movie {Release_Year: "row['Release Year']", Title: "row['Title']"}) CREATE (d:Director {Name: "row['Director']"}) CREATE (c:Cast {Actor: "row['Cast']"}) CREATE (g:Genre {Type: "row['Genre']"}) CREATE (p:Plot {Description: "row['Plot']"}) CREATE (m)-[:Directed_By]->(d) CREATE (m)-[:Starring]->(c) CREATE (m)-[:Has_Genre]->(g) CREATE (m)-[:Contains_Plot]->(p)
最后,我们遍历数据集并为每一行执行生成的 Cypher 查询。
logs = ""
total_rows = len(df)
def sanitize_value(value):
if isinstance(value, str):
return value.replace('"', '')
return str(value)
for index, row in tqdm(df.iterrows(),
total=total_rows,
desc="加载数据到 Neo4j",
position=0,
leave=True):
cypher_query = cypher_queries
for column in df.columns:
cypher_query = cypher_query.replace(
f"row['{column}']",
f'{sanitize_value(row[column])}'
)
try:
conn.execute_query(cypher_query)
except Exception as e:
logs += f"第 {index+1} 行出错: {str(e)}\n"
请注意,我们定义了一个空字符串变量 logs
,用于捕获潜在的失败。此外,我们添加了一个清理函数,以便传递给每个行输入中的值:
def sanitize_value(value):
if isinstance(value, str):
return value.replace('"', '')
return str(value)
这将防止包含双引号的字符串破坏查询语法。
接下来,我们遍历数据集:
for index, row in tqdm(df.iterrows(),
total=total_rows,
desc="加载数据到 Neo4j",
position=0,
leave=True):
cypher_query = cypher_queries
for column in df.columns:
cypher_query = cypher_query.replace(
f"row['{column}']",
f'{sanitize_value(row[column])}'
)
try:
conn.execute_query(cypher_query)
except Exception as e:
logs += f"第 {index+1} 行出错: {str(e)}\n"
正如我在练习开始时提到的,我们使用 tqdm
添加一个美观的进度条,以可视化处理了多少行。我们传递 df.iterrows()
以遍历数据框,提供索引和行数据。total=total_rows
由 tqdm
用于计算进度。我们添加 desc="加载数据到 Neo4j"
以提供进度条的标签。最后,position=0, leave=True
确保进度条在控制台中保持可见。
接下来,我们用实际的数据集值替换占位符,比如 row['column_name']
,并传递每个值到 sanitize_value
函数中,执行查询。
让我们检查一下我们的数据集是否已经上传。切换到 Neo4j 浏览器,运行以下 Cypher 查询:
MATCH p=(m:Movie)-[r]-(n)
RETURN p
LIMIT 100;
在我的例子中,LLM 生成了以下图形:
自定义 LLM 生成的知识图谱 — 作者提供的图像
这与我们手动上传的知识图谱非常相似。对于一个简单的 LLM 来说,这不错吧?尽管这需要相当多的代码,但我们现在可以将其重用于多个数据集,更重要的是,我们可以将其作为创建更复杂的 LLM 图形构建器的基础。
在我们的示例中,我们没有通过提供实体、关系和属性来帮助我们的 LLM。然而,考虑将它们作为示例添加,以提高 LLM 的性能。此外,现代方法利用思维链来提出额外的节点和关系,这使得模型能够顺序推理输出以进一步改进它。另一种策略是提供行样本,以更好地适应每行提供的值。
在下一个示例中,我们将看到使用 LangChain 的现代 Graph-RAG 实现。
LLM图形转换器与LangChain
为了使我们的图形更智能,我们使用LangChain从文本描述中提取实体(电影、演员等)和关系。LLMGraphTransformer
旨在将基于文本的文档转换为图形文档,使用大语言模型。
让我们先初始化它:
llm_transformer = LLMGraphTransformer(
llm=llm,
)
提供的LLM与我们为自定义图形构建器使用的相同。在这种情况下,我们使用默认参数,以便模型可以自由地实验节点、边和属性。然而,有一些参数值得了解,以便可能进一步提升该算法的性能:
**allowed_nodes**
和**allowed_relationships**
:我们没有指定,因此默认情况下,所有节点和关系类型都是允许的。**strict_mode=True**
:确保如果指定了约束,则仅允许的节点和关系包含在输出中。**node_properties=False**
:禁用节点的属性提取。**relationship_properties=False**
:禁用关系的属性提取。**prompt**
:传递一个ChatPromptTemplate
以自定义LLM的上下文。这与我们在自定义LLM中所做的类似。
该算法的一个缺陷是它相当慢,特别是考虑到我们没有提供节点和关系的列表。因此,我们将仅使用数据集中可用的1000行中的100行来加快速度:
接下来,让我们准备我们的数据集。我们说“LLMGraphTransformer
旨在将基于文本的文档转换为图形文档”,这意味着我们需要将我们的熊猫数据框转换为文本:
df_sample = movies.head(100)
documents = []
for _, row in tqdm(df_sample.iterrows(),
total=len(df_sample),
desc="Creating documents",
position=0,
leave=True):
try:
text = ""
for col in df.columns:
text += f"{col}: {row[col]}\\n"
documents.append(Document(page_content=text))
except KeyError as e:
tqdm.write(f"Missing column: {e}")
except Exception as e:
tqdm.write(f"Error processing row: {e}")
这将把每一行转换为文本,并将其添加到LangChain的Document
对象中,该对象与LangChain的LLMGraphTransformer
兼容。
接下来,我们运行LLM并开始生成:
graph_documents = await llm_transformer.aconvert_to_graph_documents(documents)
请注意,在这种情况下,我们使用await
和aconvert_to_graph_documents
而不是convert_to_graph_documents
来异步处理文档,从而在大规模应用中实现更快的执行。
接下来,请耐心等待,因为这将需要几分钟(约30分钟)。转换完成后,让我们打印生成的节点和关系:
print(f"Nodes:{graph_documents[0].nodes}")
print(f"Relationships:{graph_documents[0].relationships}")
在我的情况下,我得到了以下结果:
Nodes: [Node(id="Boone's cabin", type='Cabin', properties={}), Node(id='Boone', type='Boone', properties={}), Node(id='Indian maiden', type='Person', properties={}), Node(id='Indian chief', type='Chief', properties={}), Node(id='Florence Lawrence', type='Person', properties={}), Node(id='William Craven', type='Person', properties={}), Node(id='Wallace mccutcheon and ediwin s. porter', type='Director', properties={}), Node(id='Burning arrow', type='Arrow', properties={}), Node(id='Boone', type='Person', properties={}), Node(id='Indian camp', type='Camp', properties={}), Node(id='American', type='Ethnicity', properties={}), Node(id="Boone's horse", type='Horse', properties={}), Node(id='None', type='None', properties={}), Node(id='an indian maiden', type='Maiden', properties={}), Node(id='William craven', type='Cast', properties={}), Node(id='florence lawrence', type='Cast', properties={}), Node(id='Swears vengeance', type='Vengeance', properties={}), Node(id='Daniel Boone', type='Person', properties={}), Node(id='Indians', type='Group', properties={}), Node(id='Daniel boone', type='Title', properties={}), Node(id="Daniel Boone's daughter", type='Person', properties={})]
Relationships: [Relationship(source=Node(id='Daniel Boone', type='Person', properties={}), target=Node(id='Daniel boone', type='Title', properties={}), type='TITLE', properties={}), Relationship(source=Node(id='Daniel Boone', type='Person', properties={}), target=Node(id='American', type='Ethnicity', properties={}), type='ORIGIN_ETHNICITY', properties={}), Relationship(source=Node(id='Daniel Boone', type='Person', properties={}), target=Node(id='Wallace mccutcheon and ediwin s. porter', type='Director', properties={}), type='DIRECTED_BY', properties={}), Relationship(source=Node(id='William Craven', type='Person', properties={}), target=Node(id='William craven', type='Cast', properties={}), type='CAST', properties={}), Relationship(source=Node(id='Florence Lawrence', type='Person', properties={}), target=Node(id='florence lawrence', type='Cast', properties={}), type='CAST', properties={}), Relationship(source=Node(id="Daniel Boone's daughter", type='Person', properties={}), target=Node(id='an indian maiden', type='Maiden', properties={}), type='BEFRIENDS', properties={}), Relationship(source=Node(id='Boone', type='Person', properties={}), target=Node(id='Indian camp', type='Camp', properties={}), type='LEADS_OUT_ON_A_HUNTING_EXPEDITION', properties={}), Relationship(source=Node(id='Indians', type='Group', properties={}), target=Node(id="Boone's cabin", type='Cabin', properties={}), type='ATTACKS', properties={}), Relationship(source=Node(id='Indian maiden', type='Person', properties={}), target=Node(id='None', type='None', properties={}), type='ESCAPES', properties={}), Relationship(source=Node(id='Boone', type='Person', properties={}), target=Node(id='Swears vengeance', type='Vengeance', properties={}), type='RETURNS', properties={}), Relationship(source=Node(id='Boone', type='Person', properties={}), target=Node(id='None', type='None', properties={}), type='HEADS_OUT_ON_THE_TRAIL_TO_THE_INDIAN_CAMP', properties={}), Relationship(source=Node(id='Indians', type='Group', properties={}), target=Node(id='Boone', type='Boone', properties={}), type='ENCOUNTERS', properties={}), Relationship(source=Node(id='Indian camp', type='Camp', properties={}), target=Node(id='Burning arrow', type='Arrow', properties={}), type='SET_ON_FIRE', properties={}), Relationship(source=Node(id='Boone', type='Person', properties={}), target=Node(id='None', type='None', properties={}), type='GETS_TIED_TO_THE_STAKE_AND_TOURCED', properties={}), Relationship(source=Node(id='Indian camp', type='Camp', properties={}), target=Node(id='Burning arrow', type='Arrow', properties={}), type='SETS_ON_FIRE', properties={}), Relationship(source=Node(id='Indians', type='Group', properties={}), target=Node(id="Boone's horse", type='Horse', properties={}), type='ENCOUNTERS', properties={}), Relationship(source=Node(id='Boone', type='Person', properties={}), target=Node(id='Indian chief', type='Chief', properties={}), type='HAS_KNIFE_FIGHT_IN_WHICH_HE_KILLS_THE_INDIAN_CHIEF', properties={})]
接下来,是时候将生成的图形文档添加到我们的知识图谱中。我们可以通过利用LangChain与Neo4j的集成来实现:
graph = Neo4jGraph(url=uri, username=user, password=password, enhanced_schema=True)
graph.add_graph_documents(graph_documents)
让我们将图形连接存储在graph
变量中,传递您在此应用程序开头使用的相同URL、用户名和密码。然后,让我们调用add_graph_documents
方法,将所有图形文档添加到我们的数据库中。
完成后,让我们最后一次切换到Neo4j浏览器,检查我们的新知识图谱。
和往常一样,运行以下查询以查看知识图谱:
MATCH p=(m:Movie)-[r]-(n)
RETURN p
LIMIT 100;
在我的情况下,知识图谱看起来是这样的:
好吧,这就是一个知识图谱。
但我们还没有完成。你可能还记得我们的任务是实际
使用 Gemini 与聊天模型
如果您正在使用 Gemini,请确保通过取消注释顶部的 llm
变量来切换到聊天模型。
在上面的代码中,我们向大语言模型提供了一个提示,以帮助其生成,并传递图形变量的图形模式。
最后,让我们问它一些问题:
chain.run("Give me an overview of the movie titled David Copperfield.")
在我的情况下,输出是:
生成的 Cypher:
MATCH p=(n:Title {id: 'David Copperfield'})-[*1..2]-( )
RETURN p
完整上下文:
[
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'CAST_IN', {'id': 'Florence La Badie'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'RELEASE_YEAR', {'id': '1911'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'GENRE', {'id': 'Drama'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'DIRECTED', {'id': 'Theodore Marston'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'ORIGIN_ETHNICITY', {'id': 'American'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'CAST_IN', {'id': 'Mignon Anderson'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'CAST_IN', {'id': 'William Russell'}]},
{'p': [{'id': 'David Copperfield'}, 'TITLE', {'id': 'David Copperfield'}, 'CAST_IN', {'id': 'Marie Eline'}]}
]
INFO:httpx:HTTP 请求: POST http://127.0.0.1:11434/api/generate “HTTP/1.1 200 OK”
完成链。
‘David Copperfield 是一部1911年美国剧情片,由 Theodore Marston 导演。电影主演包括 David Copperfield、Florence La Badie、Mignon Anderson、William Russell 和 Marie Eline,分别扮演不同角色。它通过 David Copperfield 的各种伙伴和经历的视角,提供了他生活和冒险的概述。’
通过设置 verbose=True
我们输出中间步骤:生成的 Cypher 查询和提供的上下文。
结论
本文作为构建知识图谱现代方法的介绍。首先,我们探讨了传统方法,并对Cypher语言进行了概述,然后创建了一个简单的LLM图形构建器,以自动化图形构建过程,匹配手动过程中的表现。最后,我们进一步介绍了LangChain的LLMGraphTransformer
,显著改善了我们的知识图谱。然而,这仅仅是我们图形检索增强生成之旅的开始,特别是我们的图形构建器之旅。我们还有许多现代方法需要探索并从头开始构建,我们将在未来的文章中进行讨论。
此外,我们上面讨论的策略仍然没有考虑图形检索增强生成的第二个组成部分:图形检索器。尽管改善实际图形也会提高检索性能,但我们仍然没有从检索的角度进行思考。例如,按照我们目前采取的方向,标签、节点、边和属性越多越好。然而,这实际上使得检索器功能更难识别要检索的正确信息。确实,现代方法还考虑在知识图谱中遵循树结构,通过创建宏观区域并进一步细分为微观区域来帮助检索。
到目前为止,我希望你在不同的数据集上测试所有这些代码,并在我们的简单LLM图形构建器的定制方面发挥创意。尽管LLMGraphTransformer
似乎是加速灵活图形构建器的一个非常方便的选择,且只需最少量的代码,但这种方法需要更多的时间来构建我们的知识图谱。此外,从头开始构建将确保你掌握并完全理解图形检索增强生成背后的每个组件。