MicroFish/backend/app/utils/neo4j/schema.py

259 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Neo4j Schema 管理器
处理 Label、Relationship Type、Index 的创建和管理
"""
from typing import Dict, Any, List, Optional
from neo4j import Driver, Session
from .driver import get_neo4j_driver
from ..logger import get_logger
logger = get_logger('mirofish.neo4j.schema')
class Neo4jSchemaManager:
"""
Neo4j Schema 管理器
负责创建和管理:
1. 节点 Label
2. 关系类型 (Relationship Type)
3. 索引 (Index)
4. 约束 (Constraint)
"""
# 保留的 Label 名称(不能用作自定义实体类型)
RESERVED_LABELS = {"Entity", "Node", "_GraphMetadata"}
def __init__(self, driver: Optional[Driver] = None):
self.driver = driver or get_neo4j_driver()
def setup_graph_schema(
self,
graph_id: str,
entity_types: List[Dict[str, Any]],
edge_types: List[Dict[str, Any]]
) -> None:
"""
设置图谱的 Schema本体
Args:
graph_id: 图谱ID
entity_types: 实体类型定义列表
edge_types: 关系类型定义列表
"""
with self.driver.session() as session:
# 1. 创建图谱根节点(用于隔离不同图谱的数据)
self._create_graph_root_node(session, graph_id)
# 2. 创建实体 Label以 Entity 开头)
for entity_def in entity_types:
label_name = entity_def["name"]
if label_name in self.RESERVED_LABELS:
logger.warning(f"实体类型 {label_name} 是保留名称,跳过")
continue
full_label = f"Entity_{label_name}"
self._create_entity_label(session, full_label, entity_def)
# 3. 创建关系类型
for edge_def in edge_types:
rel_type = edge_def["name"]
self._create_relationship_type(session, rel_type, edge_def)
# 4. 创建索引
self._create_indexes(session, graph_id)
logger.info(
f"Schema 设置完成: {len(entity_types)} 个实体类型, "
f"{len(edge_types)} 个关系类型"
)
def _create_graph_root_node(self, session: Session, graph_id: str) -> None:
"""创建图谱根节点,用于数据隔离"""
cypher = """
MERGE (g:_GraphMetadata {graph_id: $graph_id})
ON CREATE SET
g.created_at = datetime(),
g.entity_count = 0,
g.edge_count = 0
RETURN g
"""
session.run(cypher, graph_id=graph_id)
def _create_entity_label(
self,
session: Session,
label: str,
entity_def: Dict[str, Any]
) -> None:
"""
创建实体 Label
Args:
session: Neo4j 会话
label: Label 名称 (e.g., "Entity_Student")
entity_def: 实体定义
"""
description = entity_def.get("description", f"A {label} entity")
# 构建属性定义
properties = entity_def.get("attributes", [])
# 基本属性始终存在
base_props = {
"uuid": "STRING",
"name": "STRING",
"summary": "STRING",
"graph_id": "STRING",
"created_at": "STRING"
}
# 构建 CREATE LABEL 语句Neo4j 不支持程序化创建 Label
# 所以我们在节点创建时直接使用动态 Label
# 这里主要是记录日志
logger.debug(f"实体 Label: {label}, 描述: {description}")
def _create_relationship_type(
self,
session: Session,
rel_type: str,
edge_def: Dict[str, Any]
) -> None:
"""
创建关系类型
Args:
session: Neo4j 会话
rel_type: 关系类型名称 (e.g., "STUDIES_AT")
edge_def: 关系定义
"""
description = edge_def.get("description", f"A {rel_type} relationship")
logger.debug(f"关系类型: {rel_type}, 描述: {description}")
def _create_indexes(self, session: Session, graph_id: str) -> None:
"""
创建索引
为常用的查询字段创建索引以提高性能
"""
indexes = [
# 节点索引
("entity_uuid_index", "INDEX FOR (n:Entity) ON (n.uuid)"),
("entity_name_index", "INDEX FOR (n:Entity) ON (n.name)"),
("entity_graph_id_index", "INDEX FOR (n:Entity) ON (n.graph_id)"),
# 关系索引
("rel_source_index", "INDEX FOR ()-[r]-() ON (r.source_node_uuid)"),
("rel_target_index", "INDEX FOR ()-[r]-() ON (r.target_node_uuid)"),
]
for index_name, index_query in indexes:
try:
# 使用 IF NOT EXISTS 避免重复创建
session.run(f"CREATE INDEX {index_name} IF NOT EXISTS FOR {index_query.split(' FOR ')[1]}")
logger.debug(f"索引已创建: {index_name}")
except Exception as e:
# 忽略已存在的索引错误
logger.debug(f"索引 {index_name} 创建跳过: {e}")
def create_fulltext_index(self, index_name: str, node_labels: List[str], properties: List[str]) -> None:
"""
创建全文索引
Args:
index_name: 索引名称
node_labels: 节点 Label 列表
properties: 属性列表
"""
with self.driver.session() as session:
labels_str = ":".join(node_labels)
props_str = ", ".join([f"n.{p}" for p in properties])
cypher = f"""
CREATE FULLTEXT INDEX {index_name}
FOR (n:{labels_str}) ON EACH [{props_str}]
"""
try:
session.run(cypher)
logger.info(f"全文索引已创建: {index_name}")
except Exception as e:
logger.warning(f"全文索引创建失败: {index_name}, {e}")
def drop_graph_data(self, graph_id: str) -> None:
"""
删除图谱的所有数据(保留 Schema
Args:
graph_id: 图谱ID
"""
with self.driver.session() as session:
# 删除该图谱的所有节点(级联删除关系)
cypher = """
MATCH (n:Entity)
WHERE n.graph_id = $graph_id
DETACH DELETE n
"""
result = session.run(cypher, graph_id=graph_id)
summary = result.consume()
nodes_deleted = summary.counters.nodes_deleted
# 更新图谱元数据
cypher2 = """
MATCH (g:_GraphMetadata {graph_id: $graph_id})
SET g.entity_count = 0, g.edge_count = 0, g.deleted_at = datetime()
"""
session.run(cypher2, graph_id=graph_id)
logger.info(f"图谱数据已删除: {graph_id}, 删除了 {nodes_deleted} 个节点")
def get_graph_stats(self, graph_id: str) -> Dict[str, Any]:
"""
获取图谱统计信息
Args:
graph_id: 图谱ID
Returns:
统计信息字典
"""
with self.driver.session() as session:
# 统计节点
node_cypher = """
MATCH (n:Entity)
WHERE n.graph_id = $graph_id
RETURN count(n) AS node_count
"""
node_record = session.run(node_cypher, graph_id=graph_id).single()
node_count = node_record["node_count"] if node_record else 0
# 统计边
edge_cypher = """
MATCH ()-[r]->()
WHERE r.graph_id = $graph_id
RETURN count(r) AS edge_count
"""
edge_record = session.run(edge_cypher, graph_id=graph_id).single()
edge_count = edge_record["edge_count"] if edge_record else 0
# 统计实体类型分布
type_cypher = """
MATCH (n:Entity)
WHERE n.graph_id = $graph_id
WITH labels(n) AS lbs, count(*) AS cnt
UNWIND lbs AS label
WITH label, cnt WHERE NOT label IN ['Entity', 'Node']
RETURN label AS entity_type, sum(cnt) AS count
"""
type_result = session.run(type_cypher, graph_id=graph_id)
entity_types = {record["entity_type"]: record["count"] for record in type_result}
return {
"graph_id": graph_id,
"node_count": node_count,
"edge_count": edge_count,
"entity_types": entity_types
}