在完成实体抽取和实体消歧之后,就需要开始构建完整的知识图谱了,构建知识图谱的方法仍然在kgAgent.py文件中的NER_Agent类下,嵌套结构如下:
python
def get_target_kg_all
def get_target_kg_single
def get_retriever_context
def get_sentences_for_entity
现在我们对这些方法一一深挖讲解: 1.匹配原句函数
python
def get_sentences_for_entity(self, entity_dic, entity_id, id_to_sentence):
"""
这个函数是一个"考古定位器",它的任务是:
给定一个实体(比如"苹果公司"),找出所有提到它的原始文献。
为什么需要这个?
- 在实体消歧阶段,多个文本块可能提到同一个实体
- 这些文本块的ID都被记录在实体的 chunkid 字段中(用;;;分隔)
- 现在我们需要把这些ID转换回实际的文本内容
"""
# 第1步:安全检查 - 确保要查找的实体确实存在
if entity_id not in entity_dic:
raise ValueError(f"Entity '{entity_id}' not found in entity_dic.")
# 为什么用 raise 而不是 return None?
# 因为这是一个编程错误,不是正常的业务逻辑,应该立即暴露问题
# 第2步:获取实体的来源标记
chunkids = entity_dic[entity_id].get('chunkid', '')
# .get('chunkid', '') 的妙处:
# - 如果实体有 chunkid 字段,返回其值
# - 如果没有,返回空字符串,避免 KeyError
# 这是防御性编程的典范
if not chunkids:
return [] # 没有来源信息,返回空列表
# 为什么返回 [] 而不是 None?
# 因为调用者期望的是列表,空列表可以直接迭代,None 会导致错误
# 第3步:解析多个来源ID
chunkid_list = chunkids.split(';;;')
# 为什么用 ';;;' 作为分隔符?
# - 三个分号几乎不会在正常文本中出现
# - 避免了与常见分隔符(逗号、分号)的冲突
chunkid_list = [cid.strip() for cid in chunkid_list if cid.strip()]
# 这行代码做了两件事:
# 1. strip() 去除每个ID前后的空格
# 2. if cid.strip() 过滤掉空字符串
# 防止 "chunk1;;; ;;;chunk2" 这种情况产生空ID
# 第4步:将ID转换为实际文本
sentences = []
for chunkid in chunkid_list:
if chunkid in id_to_sentence:
sentences.append(id_to_sentence[chunkid])
else:
print(f"Warning: Chunk ID '{chunkid}' not found in id_to_sentence.")
# 为什么只是警告而不是报错?
# 因为数据可能不完整,但我们希望尽可能多地恢复信息
# 宁可得到部分结果,也不要完全失败
return sentences
实际能解决的问题可以通过下面的例子来理解,这个方法相当于匹配到实体出现在原文中的句子,并把这些句子以list的形式返回出来。
makefile
# 输入数据
entity_dic = {
"entity1": {
"name": "苹果公司",
"chunkid": "doc1_chunk1;;;doc1_chunk3;;;doc2_chunk1"
}
}
id_to_sentence = {
"doc1_chunk1": "苹果公司成立于1976年。",
"doc1_chunk3": "苹果公司推出了iPhone。",
"doc2_chunk1": "苹果公司市值超过万亿。"
}
# 输出
result = ["苹果公司成立于1976年。", "苹果公司推出了iPhone。", "苹果公司市值超过万亿。"]
2.查询语义相似的原文内容
ini
def get_retriever_context(self, query, sentences, sentence_to_id, vectors, top_k=5):
"""
这是一个"智能雷达",它能找到与查询语义最相似的文本。
核心思想:
- 实体可能以不同形式出现在文本中
- 通过向量相似度,我们能找到语义相关但措辞不同的文本
- 这些额外的文本能提供更丰富的上下文信息
"""
# 第1步:将查询文本转换为向量
query_vector = self.embeddings.embed_query(query)
# embed_query vs embed_documents 的区别:
# - embed_query: 针对短查询优化,通常会加入特殊的查询标记
# - embed_documents: 针对文档优化,适合较长文本
# 这里用 embed_query 因为实体名称通常很短
# 第2步:计算余弦相似度
sentence_vectors = np.array(vectors)
# 为什么转换为 numpy array?
# - numpy 的向量运算比 Python 列表快100倍以上
# - cosine_similarity 函数需要 numpy 格式
similarities = cosine_similarity([query_vector], sentence_vectors)[0]
# 为什么 [query_vector] 要加方括号?
# - cosine_similarity 期望第一个参数是二维数组(多个查询)
# - [0] 取出第一行,因为我们只有一个查询
# 第3步:选择最相似的 top_k 个句子
top_indices = np.argsort(similarities)[::-1][:top_k]
# 这行代码的三个技巧:
# 1. np.argsort(): 返回排序后的索引,而不是值
# 2. [::-1]: Python 的切片技巧,反转数组(从大到小)
# 3. [:top_k]: 只取前 k 个
retriever_context = []
for idx in top_indices:
sentence = sentences[idx]
similarity = similarities[idx]
sentence_id = sentence_to_id[sentence]
retriever_context.append((sentence, similarity, sentence_id))
# 为什么返回元组而不是字典?
# - 元组更轻量,内存占用小
# - 三元组的顺序固定,便于解包:sentence, sim, id = context[0]
return retriever_context
实际能解决的问题可以通过下面的例子来理解,这个方法相当于以query中的值为核心(该值是一个实体),通过计算语义相似度来获取句子list中最相似的前几个句子,把它们进行返回。
ini
# 假设我们有这样的场景:
# 1. 文档中的所有句子
sentences = [
"苹果公司是全球知名的科技公司。", # 句子0
"乔布斯于1976年创立了苹果公司。", # 句子1
"iPhone是苹果公司的主要产品之一。", # 句子2
"微软是苹果的主要竞争对手。", # 句子3
"苹果公司总部位于加州库比蒂诺。", # 句子4
"今天天气很好,适合吃苹果。" # 句子5(无关句子)
]
# 2. 句子到ID的映射
sentence_to_id = {
"苹果公司是全球知名的科技公司。": "chunk_001",
"乔布斯于1976年创立了苹果公司。": "chunk_002",
"iPhone是苹果公司的主要产品之一。": "chunk_003",
"微软是苹果的主要竞争对手。": "chunk_004",
"苹果公司总部位于加州库比蒂诺。": "chunk_005",
"今天天气很好,适合吃苹果。": "chunk_006"
}
# 3. 查询
query = "苹果公司" # 这通常是某个实体的名称
# 4. 调用函数(top_k=3,返回最相似的3个句子)
result = get_retriever_context(
query="苹果公司",
sentences=sentences,
sentence_to_id=sentence_to_id,
vectors=vectors, # 预先计算好的句子向量
top_k=3
)
# get_retriever_context 的返回值:
result = [
(
"苹果公司是全球知名的科技公司。", # 第1相似的句子
0.95, # 相似度分数(0-1之间)
"chunk_001" # 对应的chunk ID
),
(
"乔布斯于1976年创立了苹果公司。", # 第2相似的句子
0.89, # 相似度分数
"chunk_002" # 对应的chunk ID
),
(
"苹果公司总部位于加州库比蒂诺。", # 第3相似的句子
0.87, # 相似度分数
"chunk_005" # 对应的chunk ID
)
]
3.获取单个实体的知识图谱,包括了前面两个方法,即找到原始文本和扩展语义。再调用llm生成图谱,返回结果。
python
def get_target_kg_sigle(self, entity_dic, entity_id, id_to_sentence,
sentences, sentence_to_id, vectors, output_file):
"""
这是整个系统的"心脏",它orchestrates(编排)所有组件:
1. 收集原始上下文(准确性)
2. 扩展语义上下文(完整性)
3. 调用 LLM 提取知识(智能性)
4. 持久化结果(可追溯性)
"""
# 第1步:获取实体的原始出处文本
chunk_text_list = self.get_sentences_for_entity(entity_dic, entity_id, id_to_sentence)
# 这些是"铁证"------实体确实在这些文本中出现过,返回的是实体对应的原文原句,实现准确性
# 第2步:获取实体名称作为检索查询
query = entity_dic[entity_id].get('name', '')
# 用实体名称作为查询,寻找更多相关信息
# 第3步:通过向量检索获取扩展上下文
context = self.get_retriever_context(query, sentences, sentence_to_id, vectors, top_k=5)
# top_k=5 是经验值:
# - 太少:可能错过重要信息
# - 太多:引入噪音,增加 LLM 负担
# 返回的是一系列语义相似的上下文,实现完整性
# 第4步:提取检索到的句子文本
sentences = [item[0] for item in context]
# context 中每个元素是 (句子, 相似度, ID)
# 我们只需要句子文本,其他要素我们不需要
# 第5步:合并并去重
unique_sentences = list(set(chunk_text_list + sentences))
# 为什么要去重?
# - 原始文本和检索文本可能有重叠
# - 重复信息会让 LLM 困惑或产生偏见
# set() 自动去重,list() 转回列表
# 第6步:构建最终输入文本
chunk_text = ", ".join(unique_sentences)
# 为什么用逗号+空格连接?
# - 保持文本的可读性
# - LLM 能够理解这是多个独立的信息片段
# 第7步:准备并执行 LLM 调用
prompt = ChatPromptTemplate.from_template(extract_entiry_centric_kg_en_v2)
chain = prompt | self.model
# 管道操作符 | 创建了一个处理链
# 此处的extract_entiry_centric_kg_en_v2是prompt.py里的构建知识图谱的提示词
result = chain.invoke({
"text": chunk_text, # 所有相关文本
"target_entity": entity_dic[entity_id].get('name'), # 焦点实体
"related_kg": 'none' # 可以传入已有知识
})
# 第8步:解析 LLM 响应
if hasattr(result, 'content'):
result_json = json.loads(result.content)
else:
result_json = json.loads(result)
# 兼容不同的 LLM 响应格式
# 第9步:持久化完整记录
with open(output_file, 'a') as f: # 'a' 模式:追加而不覆盖
combined_data = {
"chunk_text": chunk_text, # 输入文本(可审计)
"entity": entity_dic[entity_id], # 实体信息(可追溯)
"kg": result_json # 提取结果(核心输出)
}
f.write(json.dumps(combined_data) + '\n')
# JSONL 格式:每行一个 JSON,便于流式处理
return result_json
4.这一步又包装了前面的单个实体生成知识图谱的方法,遍历处理每一个实体,为其生成知识图谱。
python
def get_target_kg_all(self, entity_dic, id_to_sentence, sentences,
sentence_to_id, vectors, output_file):
"""
这是"自动化生产线",将单个实体的处理扩展到批量。
"""
results = {}
for entity_id in entity_dic:
# 这个 if 检查看似多余(entity_id 肯定在 entity_dic 中)
# 但它是防御性编程:防止在循环过程中字典被意外修改
if entity_id in entity_dic:
result = self.get_target_kg_sigle(
entity_dic, entity_id, id_to_sentence,
sentences, sentence_to_id, vectors, output_file
)
results[entity_id] = result
else:
print(f"Entity {entity_id} not found in entity_dic.")
return results # 返回所有实体的知识图谱
ini
def convert_knowledge_graph(self, input_data):
output = {
"entities": [],
"relations": []
}
entity_registry = {}
# First pass: Process original entities
# 目标:遍历所有"个人档案",把档案的主人公登记到市民名册里,并记录他们的属性。
for entity_key in input_data:
central_entity = input_data[entity_key]["central_entity"]
entity_name = central_entity["name"]
# 这是去重的核心:如果市民名册里还没有这个名字,就给他登记!
if entity_name not in entity_registry:
# 创建一个标准格式的实体记录
entity = {
"name": entity_name,
"type": central_entity["type"],
"description": central_entity.get("description", ""),
"attributes": {} # 准备一个空字典来存放属性
}
# 将LLM返回的属性列表,转换为更易用的字典格式
if "attributes" in central_entity:
for attr in central_entity["attributes"]:
# 之前: [{"key": "founded_year", "value": "1976"}]
# 之后: {"founded_year": "1976"}
entity["attributes"][attr["key"]] = attr["value"]
# 将整理好的实体记录,以其名字为键,存入市民名册
entity_registry[entity_name] = entity
# Second pass: Process relationships
# 目标:再次遍历所有实体"个人档案",这次专门看档案里的"社会关系"部分。
for entity_key in input_data:
central_entity = input_data[entity_key]["central_entity"]
if "relationships" in central_entity:
for rel in central_entity["relationships"]:
# 这行代码非常灵活,因为LLM可能返回单个目标,也可能返回一个列表
# e.g., "founder": "Steve Jobs" 或 "co-founders": ["Steve Jobs", "Steve Wozniak"]
target_names = rel["target_name"] if isinstance(rel["target_name"], list) else [rel["target_name"]]
target_type = rel["target_type"]
# 遍历关系中的每一个目标实体
for target_name in target_names:
# 关键一步:检查关系中提到的人,是否已经在市民名册里了
# 比如,如果"比尔·盖茨"只在关系中被提及,从未作为中心实体出现过
if target_name not in entity_registry:
# 那么,就地为他创建一个基本的登记记录
entity_registry[target_name] = {
"name": target_name,
"type": target_type,
"description": rel.get("target_description", ""),
"attributes": {}
}
# 构建一个标准的"关系四元组"并添加到总的关系网络中
relation_description = rel.get("relation_description", "")
output["relations"].append([
central_entity["name"], # 主语 (Subject)
rel["relation"], # 谓语 (Predicate)
target_name, # 宾语 (Object)
relation_description # 关系描述 (可选)
])
# 将市民名册(字典)中的所有值(实体记录)取出来,转换成一个列表
output["entities"] = list(entity_registry.values())
# 返回组装完成的、标准的知识图谱
return output
这段代码有点长,我们通过举例一些变量来理解实现的功能:
输入数据 (input_data
) 示例
这是 get_target_kg_all
函数的输出,是一个以实体ID为键的字典:1. "苹果公司" 和 "史蒂夫·乔布斯" 被定义了两次。关系也是从两个角度描述的,存在冗余。
json
{
"entity1": {
"central_entity": {
"name": "苹果公司",
"type": "Company",
"attributes": [{"key": "founded_year", "value": "1976"}],
"relationships": [{
"relation": "founder",
"target_name": "史蒂夫·乔布斯",
"target_type": "Person"
}]
}
},
"entity2": {
"central_entity": {
"name": "史蒂夫·乔布斯",
"type": "Person",
"attributes": [],
"relationships": [{
"relation": "founded",
"target_name": "苹果公司",
"target_type": "Company"
}]
}
}
}
函数通过两次遍历 input_data
来完成这个复杂的转换,非常高效。
entity_registry
:实体注册表(核心工具)
在开始之前,函数创建了一个空字典 entity_registry = {}
。 这就像是书记官手上的空白市民名册。它的作用是:
- 快速查找:用实体名称作为键,可以瞬间判断一个实体是否已被登记。
- 自动去重:因为字典的键是唯一的,所以天然就防止了重复登记。
执行第一个for循环进行扫描后,可以得到一系列实体
makefile
entity_registry = {
"苹果公司": {
"name": "苹果公司",
"type": "Company",
"description": "",
"attributes": {"founded_year": "1976"}
},
"史蒂夫·乔布斯": {
"name": "史蒂夫·乔布斯",
"type": "Person",
"description": "",
"attributes": {}
}
}
执行第二个for循环进行扫描后,可以得到记录的所有关系
css
output["relations"] = [ ["苹果公司", "founder", "史蒂夫·乔布斯", ""],
["史蒂夫·乔布斯", "founded", "苹果公司", ""]
]
最终,通过这个转换函数,就把多个以自身为中心的实体+关系分解成了所有实体+所有关系的输出,从而完成整个文本知识图谱的构建。