把构建换到应用时踩过的坑
这次做知识图谱项目,从 NER 到图数据库,中间折腾了三个月,踩的坑比想的多。
先试了开源的 NER 模型,发现中文场景下效果一般。
为什么做知识图谱
产品要做智能问答,传统的关键词匹配搞不定复杂推理,老板说上知识图谱。
需求倒不复杂:用户问「张三和李四有什么关系」,系统能从结构化数据里找出两个人之间的路径,还能给出中间经过的实体。
听起来简单,实际上从数据到可用图谱,中间的坑比想象多。
知识抽取
实体识别
先试了开源的 NER 模型,发现中文场景下效果一般。人名识别还行,但公司名、产品名经常分错。
# 使用 HanLP 进行中文实体识别
from pyhanlp import *
text = "张三在阿里巴巴集团工作,负责天猫商城的推荐算法。"
# 加载 NER 模型
ner_model = HanLP.load(HanLP.PretrainedNER.MSR)
result = ner_model(text)
print(result)
# 输出格式:[张三/nr, 在/p, 阿里巴巴集团/nt, 工作/v, ,/w, 负责/v, 天猫商城/nt, 的/u, 推荐算法/n, 。/w]
坑一:领域实体识别不准。业务里有很多专业术语,通用模型完全不认识。
解决:自己训练了一个 BERT-NER 模型,加了领域词典。
# 自定义实体识别模型
from transformers import BertTokenizer, BertForTokenClassification
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = BertForTokenClassification.from_pretrained('bert-base-chinese', num_labels=10)
# 添加自定义标签
label_list = ['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-PROD', 'I-PROD', 'B-LOC', 'I-LOC', 'B-TIME']
# 训练代码省略...
# 实际训练用了 5000 条标注数据,效果提升明显
关系抽取
实体找到了,还要知道实体之间什么关系。试过几种方案:
方案一:基于规则的关系抽取
import re
text = "张三在阿里巴巴集团担任高级工程师"
# 定义关系规则
patterns = {
'WORKS_AT': r'(.+?)在(.+?)担任(.+)',
'LOCATED_IN': r'(.+?)位于(.+)',
'FOUNDED_BY': r'(.+?)由(.+?)创立'
}
for relation, pattern in patterns.items():
match = re.search(pattern, text)
if match:
entity1 = match.group(1)
entity2 = match.group(2)
print(f"Relation: {relation}, Entity1: {entity1}, Entity2: {entity2}")
坑二:规则写不完,中文表达太灵活。
方案二:基于深度学习的关系抽取
# 使用 BERT 进行关系分类
from transformers import BertTokenizer, BertForSequenceClassification
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=6)
# 关系类别
relation_types = ['WORKS_AT', 'LOCATED_IN', 'FOUNDED_BY', 'PART_OF', 'RELATED_TO', 'NO_RELATION']
# 输入格式:[CLS] 实体1 [SEP] 实体2 [SEP] 上下文 [SEP]
def prepare_input(entity1, entity2, context):
input_text = f"[CLS] {entity1} [SEP] {entity2} [SEP] {context} [SEP]"
return tokenizer(input_text, return_tensors='pt')
# 推理
def predict_relation(entity1, entity2, context):
inputs = prepare_input(entity1, entity2, context)
outputs = model(**inputs)
predicted_class = torch.argmax(outputs.logits, dim=1).item()
return relation_types[predicted_class]
# 示例
relation = predict_relation("张三", "阿里巴巴集团", "张三在阿里巴巴集团担任高级工程师")
print(f"Predicted relation: {relation}")
坑三:训练数据不够,关系类别定义不清晰。
解决:简化关系类型,优先处理高频关系。
图数据库选择
Neo4j 方案
图数据库选了 Neo4j,主要考虑:
- 生态成熟,文档完善
- Cypher 查询语言相对直观
- 社区版功能够用
# 安装 Neo4j
docker run -d \
--name neo4j \
-p7474:7474 -p7687:7687 \
-e NEO4J_AUTH=neo4j/password \
neo4j:5.12.0
# 验证安装
docker exec -it neo4j cypher-shell -u neo4j -p password
数据导入
从 CSV 批量导入数据:
from neo4j import GraphDatabase
class KnowledgeGraphImporter:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self.driver.close()
def import_entities(self, csv_file, entity_type):
with self.driver.session() as session:
session.run(f"""
LOAD CSV WITH HEADERS FROM 'file:///{csv_file}' AS row
MERGE (e:{entity_type} {{name: row.name}})
SET e.id = row.id, e.properties = row.properties
""")
def import_relations(self, csv_file, relation_type):
with self.driver.session() as session:
session.run(f"""
LOAD CSV WITH HEADERS FROM 'file:///{csv_file}' AS row
MATCH (source {{name: row.source}})
MATCH (target {{name: row.target}})
MERGE (source)-[r:{relation_type}]->(target)
SET r.weight = toFloat(row.weight), r.confidence = toFloat(row.confidence)
""")
# 使用示例
importer = KnowledgeGraphImporter("bolt://localhost:7687", "neo4j", "password")
importer.import_entities("companies.csv", "Company")
importer.import_entities("persons.csv", "Person")
importer.import_relations("works_at.csv", "WORKS_AT")
坑四:CSV 导入速度慢,大文件容易超时。
解决:分批导入,使用 UNWIND 批处理。
def batch_import(self, entities, entity_type, batch_size=1000):
with self.driver.session() as session:
for i in range(0, len(entities), batch_size):
batch = entities[i:i + batch_size]
session.run(f"""
UNWIND $batch AS row
MERGE (e:{entity_type} {{name: row.name}})
SET e.id = row.id, e.properties = row.properties
""", batch=batch)
图谱查询
基础查询
查询两个实体之间的关系:
// 查询张三和李四之间的路径
MATCH path = shortestPath(
(p1:Person {name: "张三"})-[*]-(p2:Person {name: "李四"})
)
RETURN path, length(path) as distance;
// 查询张三的所有同事
MATCH (p:Person {name: "张三"})-[:WORKS_AT]->(c:Company)<-[:WORKS_AT]-(colleague:Person)
RETURN colleague.name, c.name as company;
// 查询阿里巴巴集团的所有产品
MATCH (c:Company {name: "阿里巴巴集团"})-[:OWNS]->(p:Product)
RETURN p.name, p.category;
推理查询
// 查询张三的二度人脉
MATCH (p:Person {name: "张三"})-[:WORKS_AT]->(c:Company)<-[:WORKS_AT]-(colleague:Person)
MATCH (colleague)-[:WORKS_AT]->(other:Company)<-[:WORKS_AT]-(second_degree:Person)
WHERE second_degree.name <> "张三" AND second_degree.name <> colleague.name
RETURN DISTINCT second_degree.name, other.name as company;
// 查询可能存在的合作关系
MATCH (c1:Company {name: "阿里巴巴集团"})-[:INVESTED_IN]->(startup:Company)<-[:FOUNDED_BY]-(p:Person)
MATCH (p)-[:WORKS_AT]->(c2:Company)
WHERE c2.name <> "阿里巴巴集团"
RETURN DISTINCT c2.name as potential_partner, startup.name as investment;
坑五:复杂查询性能差,特别是涉及长路径。
解决:添加索引,限制查询深度。
// 创建索引
CREATE INDEX person_name_idx FOR (p:Person) ON (p.name);
CREATE INDEX company_name_idx FOR (c:Company) ON (c.name);
// 限制查询深度
MATCH path = (p:Person {name: "张三"})-[*1..3]-(other:Person)
WHERE other.name <> "张三"
RETURN other.name, length(path) as distance
ORDER BY distance
LIMIT 20;
应用场景
智能问答
class KnowledgeGraphQA:
def __init__(self, neo4j_uri, user, password):
self.driver = GraphDatabase.driver(neo4j_uri, auth=(user, password))
self.templates = {
"relationship": "MATCH (p1:Person {{name: $entity1}})-[r]-(p2:Person {{name: $entity2}}) RETURN type(r) as relation",
"colleagues": "MATCH (p:Person {{name: $name}})-[:WORKS_AT]->(c:Company)<-[:WORKS_AT]-(colleague:Person) RETURN colleague.name",
"products": "MATCH (c:Company {{name: $name}})-[:OWNS]->(p:Product) RETURN p.name"
}
def answer(self, question):
# 解析问题(简化版,实际需要 NLP)
if "有什么关系" in question:
entity1, entity2 = self._extract_entities(question)
return self._query_relation(entity1, entity2)
elif "同事" in question:
name = self._extract_name(question)
return self._query_colleagues(name)
else:
return "抱歉,我不理解这个问题。"
def _query_relation(self, entity1, entity2):
with self.driver.session() as session:
result = session.run(
self.templates["relationship"],
entity1=entity1, entity2=entity2
)
relations = [record["relation"] for record in result]
if relations:
return f"{entity1} 和 {entity2} 之间有 {', '.join(relations)} 关系"
else:
return f"没有找到 {entity1} 和 {entity2} 之间的关系"
def _query_colleagues(self, name):
with self.driver.session() as session:
result = session.run(
self.templates["colleagues"],
name=name
)
colleagues = [record["colleague.name"] for record in result]
if colleagues:
return f"{name} 的同事有:{', '.join(colleagues)}"
else:
return f"没有找到 {name} 的同事信息"
# 使用示例
qa = KnowledgeGraphQA("bolt://localhost:7687", "neo4j", "password")
# 用户提问
print(qa.answer("张三和李四有什么关系?"))
print(qa.answer("张三的同事有哪些?"))
推荐系统
# 基于知识图谱的推荐
class KnowledgeGraphRecommender:
def __init__(self, neo4j_uri, user, password):
self.driver = GraphDatabase.driver(neo4j_uri, auth=(user, password))
def recommend_products(self, user_name, top_n=5):
with self.driver.session() as session:
# 查询用户购买过的产品
result = session.run("""
MATCH (u:User {name: $name})-[:PURCHASED]->(p:Product)
RETURN p.name, p.category
""", name=user_name)
purchased = list(result)
# 基于相似用户推荐
recommendations = session.run("""
MATCH (u1:User {name: $name})-[:PURCHASED]->(p:Product)<-[:PURCHASED]-(u2:User)
MATCH (u2)-[:PURCHASED]->(p2:Product)
WHERE NOT (u1)-[:PURCHASED]->(p2)
RETURN p2.name, count(*) as score
ORDER BY score DESC
LIMIT $limit
""", name=user_name, limit=top_n)
return [record["p2.name"] for record in recommendations]
# 使用示例
recommender = KnowledgeGraphRecommender("bolt://localhost:7687", "neo4j", "password")
products = recommender.recommend_products("张三")
print(f"推荐给张三的产品:{products}")
踩过的坑总结
坑六:实体消歧
同一个实体名称对应多个真实实体,比如「苹果」可能是公司、水果、品牌。
解决:添加上下文信息,用实体类型限定。
def disambiguate_entity(entity_name, context):
# 分析上下文确定实体类型
if "公司" in context or "企业" in context:
entity_type = "Company"
elif "水果" in context or "食物" in context:
entity_type = "Food"
else:
entity_type = "Unknown"
# 查询图谱
with driver.session() as session:
result = session.run("""
MATCH (e {name: $name})
WHERE labels(e) = [$type]
RETURN e
""", name=entity_name, type=entity_type)
return result.single()
坑七:知识更新困难
业务数据变化快,图谱更新不及时会出错。
解决:建立更新机制,定期同步。
class KnowledgeGraphUpdater:
def __init__(self, neo4j_uri, user, password):
self.driver = GraphDatabase.driver(neo4j_uri, auth=(user, password))
def update_entity(self, entity_type, entity_name, properties):
with self.driver.session() as session:
session.run(f"""
MERGE (e:{entity_type} {{name: $name}})
SET e += $properties
SET e.updated_at = datetime()
""", name=entity_name, properties=properties)
def delete_entity(self, entity_type, entity_name):
with self.driver.session() as session:
session.run(f"""
MATCH (e:{entity_type} {{name: $name}})
DETACH DELETE e
""", name=entity_name)
def batch_update(self, updates):
with self.driver.session() as session:
for update in updates:
if update["action"] == "update":
self.update_entity(update["type"], update["name"], update["properties"])
elif update["action"] == "delete":
self.delete_entity(update["type"], update["name"])
坑八:图谱规模大时查询慢
节点超过百万后,查询性能明显下降。
解决:
- 合理使用索引
- 查询限制深度
- 使用缓存
import hashlib
import json
class CachedQuery:
def __init__(self, neo4j_driver, cache_ttl=3600):
self.driver = neo4j_driver
self.cache = {}
self.cache_ttl = cache_ttl
def query(self, cypher, params=None):
cache_key = self._get_cache_key(cypher, params)
if cache_key in self.cache:
cached = self.cache[cache_key]
if time.time() - cached["timestamp"] < self.cache_ttl:
return cached["result"]
with self.driver.session() as session:
result = list(session.run(cypher, params or {}))
self.cache[cache_key] = {
"result": result,
"timestamp": time.time()
}
return result
def _get_cache_key(self, cypher, params):
key_string = f"{cypher}:{json.dumps(params, sort_keys=True)}"
return hashlib.md5(key_string.encode()).hexdigest()
性能优化
内存配置
Neo4j 默认内存配置不够,需要调整:
# neo4j.conf
dbms.memory.heap.initial_size=512m
dbms.memory.heap.max_size=2G
dbms.memory.pagecache.size=2G
# 重启
docker restart neo4j
查询优化
// 使用 PROFILE 分析查询
PROFILE MATCH path = (p1:Person {name: "张三"})-[*]-(p2:Person {name: "李四"})
RETURN path;
// 优化后的查询
MATCH (p1:Person {name: "张三"})
MATCH (p2:Person {name: "李四"})
MATCH path = shortestPath((p1)-[*]-(p2))
RETURN path;
数据质量
数据清洗
def clean_entity_name(name):
"""清洗实体名称"""
# 去除前后空格
name = name.strip()
# 统一全角半角
name = name.replace('(', '(').replace(')', ')')
name = name.replace(':', ':').replace(',', ',')
# 去除特殊字符
name = re.sub(r'[^\w\s一-鿿]', '', name)
return name
def normalize_relation(relation):
"""标准化关系名称"""
relation_mapping = {
'工作在': 'WORKS_AT',
'任职于': 'WORKS_AT',
'位于': 'LOCATED_IN',
'坐落在': 'LOCATED_IN',
'创立了': 'FOUNDED_BY'
}
return relation_mapping.get(relation, relation)
数据验证
def validate_graph_data(driver):
"""验证图谱数据质量"""
with driver.session() as session:
# 检查孤立节点
result = session.run("""
MATCH (n)
WHERE NOT (n)-[]-()
RETURN labels(n) as labels, count(*) as count
""")
isolated = [record for record in result]
if isolated:
print(f"发现孤立节点:{isolated}")
# 检查重复关系
result = session.run("""
MATCH ()-[r]->()
WITH type(r) as rel_type, startNode(r) as start, endNode(r) as end
WITH rel_type, start, end, count(*) as count
WHERE count > 1
RETURN rel_type, start.name, end.name, count
""")
duplicates = [record for record in result]
if duplicates:
print(f"发现重复关系:{duplicates}")
# 检查缺失属性
result = session.run("""
MATCH (n:Person)
WHERE n.name IS NULL OR n.name = ""
RETURN count(*) as count
""")
missing = [record for record in result]
if missing[0]["count"] > 0:
print(f"发现缺失名称的Person节点:{missing[0]['count']}个")
写在最后
知识图谱这东西,看着概念多,拆开做其实就几件事:实体抽取、关系抽取、图存储、查询优化。
解决了:
- 结构化知识表示
- 复杂关系推理
- 智能问答基础
带来了:
- 数据处理复杂度
- 维护成本不低
- 效果依赖数据质量
要不要上知识图谱,先想清楚:
- 业务真的需要复杂推理吗?
- 有足够的数据质量支撑吗?
- 有持续维护的能力吗?
不是所有场景都适合,但一旦用对了,价值确实明显。
这次知识图谱项目花了三个月,从 NLP 到图数据库踩了不少坑,最后系统上线后问答准确率达到 78%,比关键词匹配提升了 40 个点。
版权声明: 本文首发于 指尖魔法屋-把构建换到应用时踩过的坑(https://blog.thinkmoon.cn/post/163-knowledge-graph-construction-application-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。