Files
2026-05-12 09:41:56 +08:00

387 lines
16 KiB
Python

"""
图索引模块
实现实体和关系的键值对结构 (K,V)
K: 索引键(简短词汇或短语)
V: 详细描述段落(包含相关文本片段)
"""
import json
import logging
from typing import Dict, List, Tuple, Any, Optional
from dataclasses import dataclass
from collections import defaultdict
from langchain_core.documents import Document
logger = logging.getLogger(__name__)
@dataclass
class EntityKeyValue:
"""实体键值对"""
entity_name: str
index_keys: List[str] # 索引键列表
value_content: str # 详细描述内容
entity_type: str # 实体类型 (Recipe, Ingredient, CookingStep)
metadata: Dict[str, Any]
@dataclass
class RelationKeyValue:
"""关系键值对"""
relation_id: str
index_keys: List[str] # 多个索引键(可包含全局主题)
value_content: str # 关系描述内容
relation_type: str # 关系类型
source_entity: str # 源实体
target_entity: str # 目标实体
metadata: Dict[str, Any]
class GraphIndexingModule:
"""
图索引模块
核心功能:
1. 为实体创建键值对(名称作为唯一索引键)
2. 为关系创建键值对(多个索引键,包含全局主题)
3. 去重和优化图操作
4. 支持增量更新
"""
def __init__(self, config, llm_client):
self.config = config
self.llm_client = llm_client
# 键值对存储
self.entity_kv_store: Dict[str, EntityKeyValue] = {}
self.relation_kv_store: Dict[str, RelationKeyValue] = {}
# 索引映射:key -> entity/relation IDs
self.key_to_entities: Dict[str, List[str]] = defaultdict(list)
self.key_to_relations: Dict[str, List[str]] = defaultdict(list)
def create_entity_key_values(self, recipes: List[Any], ingredients: List[Any],
cooking_steps: List[Any]) -> Dict[str, EntityKeyValue]:
"""
为实体创建键值对结构
每个实体使用其名称作为唯一索引键
"""
logger.info("开始创建实体键值对...")
# 处理菜谱实体
for recipe in recipes:
entity_id = recipe.node_id
entity_name = recipe.name or f"菜谱_{entity_id}"
# 构建详细内容
content_parts = [f"菜品名称: {entity_name}"]
if hasattr(recipe, 'properties'):
props = recipe.properties
if props.get('description'):
content_parts.append(f"描述: {props['description']}")
if props.get('category'):
content_parts.append(f"分类: {props['category']}")
if props.get('cuisineType'):
content_parts.append(f"菜系: {props['cuisineType']}")
if props.get('difficulty'):
content_parts.append(f"难度: {props['difficulty']}")
if props.get('cookingTime'):
content_parts.append(f"制作时间: {props['cookingTime']}")
# 创建键值对
entity_kv = EntityKeyValue(
entity_name=entity_name,
index_keys=[entity_name], # 使用名称作为唯一索引键
value_content='\n'.join(content_parts),
entity_type="Recipe",
metadata={
"node_id": entity_id,
"properties": getattr(recipe, 'properties', {})
}
)
self.entity_kv_store[entity_id] = entity_kv
self.key_to_entities[entity_name].append(entity_id)
# 处理食材实体
for ingredient in ingredients:
entity_id = ingredient.node_id
entity_name = ingredient.name or f"食材_{entity_id}"
content_parts = [f"食材名称: {entity_name}"]
if hasattr(ingredient, 'properties'):
props = ingredient.properties
if props.get('category'):
content_parts.append(f"类别: {props['category']}")
if props.get('nutrition'):
content_parts.append(f"营养信息: {props['nutrition']}")
if props.get('storage'):
content_parts.append(f"储存方式: {props['storage']}")
entity_kv = EntityKeyValue(
entity_name=entity_name,
index_keys=[entity_name],
value_content='\n'.join(content_parts),
entity_type="Ingredient",
metadata={
"node_id": entity_id,
"properties": getattr(ingredient, 'properties', {})
}
)
self.entity_kv_store[entity_id] = entity_kv
self.key_to_entities[entity_name].append(entity_id)
# 处理烹饪步骤实体
for step in cooking_steps:
entity_id = step.node_id
entity_name = f"步骤_{entity_id}"
content_parts = [f"烹饪步骤: {entity_name}"]
if hasattr(step, 'properties'):
props = step.properties
if props.get('description'):
content_parts.append(f"步骤描述: {props['description']}")
if props.get('order'):
content_parts.append(f"步骤顺序: {props['order']}")
if props.get('technique'):
content_parts.append(f"技巧: {props['technique']}")
if props.get('time'):
content_parts.append(f"时间: {props['time']}")
entity_kv = EntityKeyValue(
entity_name=entity_name,
index_keys=[entity_name],
value_content='\n'.join(content_parts),
entity_type="CookingStep",
metadata={
"node_id": entity_id,
"properties": getattr(step, 'properties', {})
}
)
self.entity_kv_store[entity_id] = entity_kv
self.key_to_entities[entity_name].append(entity_id)
logger.info(f"实体键值对创建完成,共 {len(self.entity_kv_store)} 个实体")
return self.entity_kv_store
def create_relation_key_values(self, relationships: List[Tuple[str, str, str]]) -> Dict[str, RelationKeyValue]:
"""
为关系创建键值对结构
关系可能有多个索引键,包含从LLM增强的全局主题
"""
logger.info("开始创建关系键值对...")
for i, (source_id, relation_type, target_id) in enumerate(relationships):
relation_id = f"rel_{i}_{source_id}_{target_id}"
# 获取源实体和目标实体信息
source_entity = self.entity_kv_store.get(source_id)
target_entity = self.entity_kv_store.get(target_id)
if not source_entity or not target_entity:
continue
# 构建关系描述
content_parts = [
f"关系类型: {relation_type}",
f"源实体: {source_entity.entity_name} ({source_entity.entity_type})",
f"目标实体: {target_entity.entity_name} ({target_entity.entity_type})"
]
# 生成多个索引键(包含全局主题)
index_keys = self._generate_relation_index_keys(
source_entity, target_entity, relation_type
)
# 创建关系键值对
relation_kv = RelationKeyValue(
relation_id=relation_id,
index_keys=index_keys,
value_content='\n'.join(content_parts),
relation_type=relation_type,
source_entity=source_id,
target_entity=target_id,
metadata={
"source_name": source_entity.entity_name,
"target_name": target_entity.entity_name,
"created_from_graph": True
}
)
self.relation_kv_store[relation_id] = relation_kv
# 为每个索引键建立映射
for key in index_keys:
self.key_to_relations[key].append(relation_id)
logger.info(f"关系键值对创建完成,共 {len(self.relation_kv_store)} 个关系")
return self.relation_kv_store
def _generate_relation_index_keys(self, source_entity: EntityKeyValue,
target_entity: EntityKeyValue,
relation_type: str) -> List[str]:
"""
为关系生成多个索引键,包含全局主题
"""
keys = [relation_type] # 基础关系类型键
# 根据关系类型和实体类型生成主题键
if relation_type == "REQUIRES":
# 菜谱-食材关系的主题键
keys.extend([
"食材搭配",
"烹饪原料",
f"{source_entity.entity_name}_食材",
target_entity.entity_name
])
elif relation_type == "HAS_STEP":
# 菜谱-步骤关系的主题键
keys.extend([
"制作步骤",
"烹饪过程",
f"{source_entity.entity_name}_步骤",
"制作方法"
])
elif relation_type == "BELONGS_TO_CATEGORY":
# 分类关系的主题键
keys.extend([
"菜品分类",
"美食类别",
target_entity.entity_name
])
# 使用LLM增强关系索引键(可选)
if getattr(self.config, 'enable_llm_relation_keys', False):
enhanced_keys = self._llm_enhance_relation_keys(source_entity, target_entity, relation_type)
keys.extend(enhanced_keys)
# 去重并返回
return list(set(keys))
def _llm_enhance_relation_keys(self, source_entity: EntityKeyValue,
target_entity: EntityKeyValue,
relation_type: str) -> List[str]:
"""
使用LLM增强关系索引键,生成全局主题
"""
prompt = f"""
分析以下实体关系,生成相关的主题关键词:
源实体: {source_entity.entity_name} ({source_entity.entity_type})
目标实体: {target_entity.entity_name} ({target_entity.entity_type})
关系类型: {relation_type}
请生成3-5个相关的主题关键词,用于索引和检索。
返回JSON格式:{{"keywords": ["关键词1", "关键词2", "关键词3"]}}
"""
try:
response = self.llm_client.chat.completions.create(
model=self.config.llm_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=200
)
result = json.loads(response.choices[0].message.content.strip())
return result.get("keywords", [])
except Exception as e:
logger.error(f"LLM增强关系索引键失败: {e}")
return []
def deduplicate_entities_and_relations(self):
"""
去重相同的实体和关系,优化图操作
"""
logger.info("开始去重实体和关系...")
# 实体去重:基于名称
name_to_entities = defaultdict(list)
for entity_id, entity_kv in self.entity_kv_store.items():
name_to_entities[entity_kv.entity_name].append(entity_id)
# 合并重复实体
entities_to_remove = []
for name, entity_ids in name_to_entities.items():
if len(entity_ids) > 1:
# 保留第一个,合并其他的内容
primary_id = entity_ids[0]
primary_entity = self.entity_kv_store[primary_id]
for entity_id in entity_ids[1:]:
duplicate_entity = self.entity_kv_store[entity_id]
# 合并内容
primary_entity.value_content += f"\n\n补充信息: {duplicate_entity.value_content}"
# 标记删除
entities_to_remove.append(entity_id)
# 删除重复实体
for entity_id in entities_to_remove:
del self.entity_kv_store[entity_id]
# 关系去重:基于源-目标-类型
relation_signature_to_ids = defaultdict(list)
for relation_id, relation_kv in self.relation_kv_store.items():
signature = f"{relation_kv.source_entity}_{relation_kv.target_entity}_{relation_kv.relation_type}"
relation_signature_to_ids[signature].append(relation_id)
# 合并重复关系
relations_to_remove = []
for signature, relation_ids in relation_signature_to_ids.items():
if len(relation_ids) > 1:
# 保留第一个,删除其他
for relation_id in relation_ids[1:]:
relations_to_remove.append(relation_id)
# 删除重复关系
for relation_id in relations_to_remove:
del self.relation_kv_store[relation_id]
# 重建索引映射
self._rebuild_key_mappings()
logger.info(f"去重完成 - 删除了 {len(entities_to_remove)} 个重复实体,{len(relations_to_remove)} 个重复关系")
def _rebuild_key_mappings(self):
"""重建键到实体/关系的映射"""
self.key_to_entities.clear()
self.key_to_relations.clear()
# 重建实体映射
for entity_id, entity_kv in self.entity_kv_store.items():
for key in entity_kv.index_keys:
self.key_to_entities[key].append(entity_id)
# 重建关系映射
for relation_id, relation_kv in self.relation_kv_store.items():
for key in relation_kv.index_keys:
self.key_to_relations[key].append(relation_id)
def get_entities_by_key(self, key: str) -> List[EntityKeyValue]:
"""根据索引键获取实体"""
entity_ids = self.key_to_entities.get(key, [])
return [self.entity_kv_store[eid] for eid in entity_ids if eid in self.entity_kv_store]
def get_relations_by_key(self, key: str) -> List[RelationKeyValue]:
"""根据索引键获取关系"""
relation_ids = self.key_to_relations.get(key, [])
return [self.relation_kv_store[rid] for rid in relation_ids if rid in self.relation_kv_store]
def get_statistics(self) -> Dict[str, Any]:
"""获取键值对存储统计信息"""
return {
"total_entities": len(self.entity_kv_store),
"total_relations": len(self.relation_kv_store),
"total_entity_keys": sum(len(kv.index_keys) for kv in self.entity_kv_store.values()),
"total_relation_keys": sum(len(kv.index_keys) for kv in self.relation_kv_store.values()),
"entity_types": {
"Recipe": len([kv for kv in self.entity_kv_store.values() if kv.entity_type == "Recipe"]),
"Ingredient": len([kv for kv in self.entity_kv_store.values() if kv.entity_type == "Ingredient"]),
"CookingStep": len([kv for kv in self.entity_kv_store.values() if kv.entity_type == "CookingStep"])
}
}