kgrag/graph_search/graph_retrieval.py
2026-06-30 13:35:52 +08:00

884 lines
40 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.

"""
===========================================
图谱检索模块 - GraphRetrieval
===========================================
"""
import logging
import time
import os
from typing import Optional, List, Dict, Tuple, Any, Literal
# 导入拆分后的模块
from graph_search.query_intent import is_aggregate_query
from graph_search.cypher_validator import CypherValidator
from graph_search.cypher_fixer import parse_cypher_error,force_undirected_relationships,fix_cypher_based_on_error,fix_common_cypher_errors
from graph_search.entry_node_handler import filter_and_sort_entry_nodes,format_entry_nodes
from graph_search.result_formatter import format_entry_nodes_as_results
from graph_search.aggregate_handler import extract_aggregate_functions, is_aggregate_result_zero
from graph_search.init_prompts import *
# 加载 .env 文件中的环境变量
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# 如果 python-dotenv 未安装,跳过加载(环境变量可能已通过其他方式设置)
pass
logger = logging.getLogger(__name__)
# ========== ========== 导入依赖 ========== ==========
# 注意:需要安装以下包
# pip install neo4j langchain langchain-community neo4j-graphrag
from langchain_core.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
from neo4j_graphrag.retrievers.text2cypher import extract_cypher
class GraphRetrieval:
"""
知识图谱检索模块(带完整验证)
功能说明:
1. 生成Cypher查询语句支持错误反馈自动修正
2. 多层验证语法、Schema、Dry Run、LLM语义验证
3. 多轮生成和修正机制(错误反馈给生成函数)
4. 降级策略:模板匹配 -> LLM生成 -> 简单查询
"""
def __init__(self,
driver, # Neo4j driverGraphDatabase.driver实例
neo4j_schema: str, # Neo4j schema字符串从graph_service.py传递由Neo4jGraph获取
llm = None, # LLM实例用于生成和验证Cypher如ChatTongyi
allowed_node_types: List[str] = None, # 允许的节点类型列表从graph_service.py传递
allowed_relationship_types: List[str] = None, # 允许的关系类型列表从graph_service.py传递
):
"""
初始化图谱检索器
"""
self.driver = driver
self.neo4j_schema = neo4j_schema # 完整schema字符串格式从graph_service.py传递
self.llm = llm
# Dry Run验证
self.skip_dry_run_validation = False
# ========== 配置选项Cypher生成最大尝试次数 ==========
# 配置方式:在 .env 文件中添加 CYPHER_MAX_ATTEMPTS=2 或 CYPHER_MAX_ATTEMPTS=3
self.default_max_attempts = int(os.getenv("CYPHER_MAX_ATTEMPTS", "2"))
logger.info(f"GraphRetrieval: Cypher生成默认最大尝试次数: {self.default_max_attempts}(通过 .env 文件或环境变量 CYPHER_MAX_ATTEMPTS 配置)")
# ========== 保存最后使用的Cypher用于调试 ==========
self.last_cypher = None
# ========== 初始化验证器 ==========
self.validator = CypherValidator(
driver=self.driver,
neo4j_schema=self.neo4j_schema,
allowed_node_types=allowed_node_types,
allowed_relationship_types=allowed_relationship_types
)
def _force_undirected_relationships(self, cypher: str) -> str:
"""包装函数,调用模块中的函数"""
return force_undirected_relationships(cypher)
def _parse_cypher_error(self, error_message: str, cypher: str) -> Dict[str, Any]:
"""包装函数,调用模块中的函数"""
return parse_cypher_error(error_message, cypher)
def _fix_cypher_based_on_error(self, cypher: str, error_info: Dict[str, Any]) -> Optional[str]:
"""包装函数,调用模块中的函数"""
return fix_cypher_based_on_error(cypher, error_info)
def _fix_common_cypher_errors(self, cypher: str) -> str:
"""包装函数,调用模块中的函数"""
return fix_common_cypher_errors(cypher)
# ========== ========== 规则验证层 ========== ==========
def validate_cypher_syntax(self, cypher: str) -> Tuple[bool, Optional[str]]:
"""包装函数,调用验证器"""
return self.validator.validate_syntax(cypher)
def validate_cypher_schema(self, cypher: str) -> Tuple[bool, Optional[str]]:
"""包装函数调用验证器的schema验证"""
return self.validator.validate_schema(cypher)
def dry_run_cypher(self, cypher: str) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
"""包装函数,调用验证器"""
return self.validator.dry_run(cypher, parse_error_func=self._parse_cypher_error)
# ========== ========== Cypher生成支持错误反馈 ========== ==========
async def generate_cypher(
self,
query: str,
entry_nodes: dict,
previous_cypher: Optional[str] = None,
validation_errors: Optional[List[str]] = None,
is_aggregate: bool = False,
error_type: Optional[str] = None, # 'syntax_error' | 'schema_error' | 'empty_result'
query_classification: Optional[Dict[str, Any]] = None, # 从重写中获得的分类信息(可选)
) -> Optional[str]:
"""
生成Cypher语句支持错误反馈自动修正
"""
from graph_search.query_intent import (
has_time_in_query,
get_current_date_info,
classify_time_aggregate_type
)
is_correction = validation_errors and previous_cypher
# ========== 判断是否包含时间信息(优先使用分类信息) ==========
if query_classification:
has_time = query_classification.get("has_time", False)
# 如果分类信息中有 time_type直接使用否则根据 has_time 和 is_aggregate 判断
if is_aggregate and has_time:
time_aggregate_type = query_classification.get("time_type")
# 如果 time_type 为 None尝试自动识别
if time_aggregate_type is None:
time_aggregate_type = classify_time_aggregate_type(query)
else:
time_aggregate_type = None
logger.info(f"使用分类信息: has_time={has_time}, time_aggregate_type={time_aggregate_type}")
else:
# 如果没有分类信息,使用原有逻辑
has_time = has_time_in_query(query)
time_aggregate_type = None
if is_aggregate and has_time:
time_aggregate_type = classify_time_aggregate_type(query)
logger.info(f"使用自动识别: has_time={has_time}, time_aggregate_type={time_aggregate_type}")
# ========== 获取系统时间信息(如果需要) ==========
current_date_info = ""
current_date_info_section = ""
date_info = {}
current_month_int = None # 整数类型的月份(用于 prompt 中的 {current_month:02d} 格式化)
current_month_formatted = "" # 格式化的月份字符串(如 "01", "12"
if has_time:
date_info = get_current_date_info()
current_date_info = "\n".join([f"- {k}: {v}" for k, v in date_info.items()])
current_date_info_section = f"系统时间信息:\n{current_date_info}\n\n"
# 获取整数类型的月份(用于 prompt 模板中的 {current_month:02d} 格式化)
current_month_int = int(date_info.get('current_month', 1))
# 同时提供格式化好的字符串版本(备用)
current_month_formatted = f"{current_month_int:02d}"
# ========== 过滤和排序入口节点(按标签内排序和限制) ==========
# 注意:如果 entry_nodes 已经在 search 方法中过滤过,这里再次过滤是幂等的(结果相同)
entry_nodes = filter_and_sort_entry_nodes(
entry_nodes,
max_nodes_per_label=5
)
# ========== 格式化入口节点信息 ==========
entry_nodes_str = format_entry_nodes(entry_nodes)
# ========== 路径模板占位说明 ==========
# 注意路径模板生成功能已移除LLM 需要根据 schema 自行选择合理的路径
path_templates_str = "(请根据 schema 和入口节点信息,自行选择合理的查询路径。)"
# ========== 根据统计 / 普通查询,构造查询意图提示 ==========
if is_aggregate:
# 统计类:强制要求使用聚合函数,返回简洁聚合结果
query_intent_hint = (
"【查询类型】统计类问题,请根据用户问题和入口节点,生成包含聚合函数的 Cypher 查询,"
"例如使用 COUNT、SUM、AVG、MAX、MIN 等,返回精简的聚合结果(如总数、平均值、排名等),"
"不要返回大量明细记录。\n"
)
else:
# 明细类:正常检索,用于后续 RAG
query_intent_hint = (
"【查询类型】检索类问题,请根据用户问题和入口节点,生成返回明细节点/关系的 Cypher 查询,"
"用于后续的图谱问答检索。\n"
)
full_query = query_intent_hint + query
# ========== 根据是否有错误选择不同的prompt ==========
if validation_errors and previous_cypher:
# ========== 修正模式包含错误信息和之前的Cypher ==========
errors_str = "\n".join([f" - {err}" for err in validation_errors])
# ========== 根据错误类型选择对应的修正 prompt统计类和明细类都使用相同的分类 ==========
if error_type == 'syntax_error':
# 语法错误:使用专门的语法修正 prompt
prompt = correct_syntax_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
errors=errors_str,
cypher=previous_cypher
)
elif error_type == 'schema_error':
# Schema错误使用专门的 Schema 修正 prompt
prompt = correct_schema_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
errors=errors_str,
cypher=previous_cypher
)
elif error_type == 'empty_result':
# 空结果错误:使用专门的空结果修正 prompt
prompt = correct_empty_result_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
errors=errors_str,
cypher=previous_cypher
)
else:
# 未分类错误:使用通用修正 prompt向后兼容
# 统计类使用 correct_aggregate_cypher_prompt明细类使用 correct_cypher_prompt
if is_aggregate:
prompt = correct_aggregate_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
errors=errors_str,
cypher=previous_cypher
)
else:
prompt = correct_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
errors=errors_str,
cypher=previous_cypher
)
else:
# ========== 初始生成模式 ==========
if is_aggregate:
# 统计类根据是否带时间选择不同的prompt
if has_time:
# 时间统计查询根据时间段类型选择对应的prompt
if time_aggregate_type == "single":
# 单时间段统计
prompt = generate_single_time_aggregate_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
current_date_info=current_date_info,
current_year=date_info.get("current_year", ""),
current_month=current_month_int, # 传递整数,支持 :02d 格式化
last_year=date_info.get("last_year", ""),
next_year=date_info.get("next_year", ""),
next_month_start=date_info.get("next_month_start", ""),
)
elif time_aggregate_type == "dual":
# 双时间段统计(同比/环比)
# 计算上月last_month和前年last_last_year用于多时间段
last_month = ""
if current_month_formatted:
month_int = int(current_month_formatted)
year_int = int(date_info.get("current_year", "2025"))
if month_int == 1:
last_month = f"{year_int - 1}-12"
else:
last_month = f"{year_int}-{month_int - 1:02d}"
prompt = generate_dual_time_aggregate_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
current_date_info=current_date_info,
current_year=date_info.get("current_year", ""),
current_month=current_month_int, # 传递整数,支持 :02d 格式化
last_year=date_info.get("last_year", ""),
last_month=last_month,
)
else: # multi
# 多时间段统计
# 计算前年last_last_year
last_last_year = str(int(date_info.get("current_year", "2025")) - 2) if date_info else ""
prompt = generate_multi_time_aggregate_cypher_prompt.format_prompt(
schema=self.neo4j_schema,
entry_nodes=entry_nodes_str,
query=full_query,
current_date_info=current_date_info,
current_year=date_info.get("current_year", ""),
last_year=date_info.get("last_year", ""),
last_last_year=last_last_year,
)
else:
# 普通统计查询使用原有prompt
prompt = generate_aggregate_cypher_prompt.format_prompt(
schema=self.neo4j_schema, # 使用完整schema
entry_nodes=entry_nodes_str,
query=full_query,
path_templates=path_templates_str,
)
else:
# 普通查询支持时间过滤在prompt中已包含时间处理说明
prompt = generate_cypher_prompt.format_prompt(
schema=self.neo4j_schema, # 使用完整schema
entry_nodes=entry_nodes_str,
query=full_query,
path_templates=path_templates_str,
current_date_info=current_date_info if has_time else "",
current_year=date_info.get("current_year", "") if has_time else "",
current_month=current_month_int if has_time else 1, # 传递整数,支持 :02d 格式化
next_year=date_info.get("next_year", "") if has_time else "",
next_month_start=date_info.get("next_month_start", "") if has_time else "",
current_date_info_section=current_date_info_section,
)
try:
# ========== 调用LLM生成Cypher ==========
# prompt可能是字符串或ChatPromptTemplate对象
if isinstance(prompt, str):
query_text = prompt
elif hasattr(prompt, 'messages'):
# 如果是ChatPromptTemplate格式转换为字符串
messages = prompt.messages
query_text = "\n".join([
msg.content for msg in messages
if hasattr(msg, 'content') and msg.content
])
else:
query_text = str(prompt)
llm_output = await self.llm.ainvoke(query_text)
# ========== 提取Cypher语句 ==========
content = llm_output.content if hasattr(llm_output, 'content') else str(llm_output)
# ========== 【新增】过滤 Qwen 的 <think> 标签 ==========
import re
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
# 优先尝试从标记中提取(如果 LLM 按照格式输出)
cypher = None
if "【Cypher查询语句】" in content or "Cypher查询语句" in content:
# 查找标记后的内容
markers = ["【Cypher查询语句】", "Cypher查询语句", "Cypher:", "Cypher语句"]
for marker in markers:
marker_pos = content.find(marker)
if marker_pos != -1:
# 提取标记后的内容
cypher_text = content[marker_pos + len(marker):].strip()
# 使用 extract_cypher 提取
cypher = extract_cypher(cypher_text)
if cypher:
break
# 如果标记提取失败,使用原有的 extract_cypher会从整个内容中提取
if not cypher:
cypher = extract_cypher(content)
if cypher:
# ========== 后处理:修复常见语法错误 ==========
cypher = self._fix_common_cypher_errors(cypher)
# ========== 强制转换为无方向关系(确保所有关系都是无方向的) ==========
# 即使prompt中已要求无方向关系也要强制转换作为安全措施
cypher = self._force_undirected_relationships(cypher)
# 打印更全面的 Cypher 日志,便于调试(长度做一个上限,避免日志爆炸)
preview = cypher if len(cypher) <= 1000 else cypher[:1000] + "..."
logger.info(f"Cypher生成完整语句:\n{preview}")
else:
logger.warning(f"Cypher生成结果为空LLM原始输出: {str(content)[:500]}...")
return cypher
except Exception as e:
logger.error(f"LLM生成Cypher失败: {e}")
return None
# ========== ========== 多轮生成和验证 ========== ==========
async def generate_and_validate_cypher(
self,
query: str,
entry_nodes: dict,
max_attempts: int = 3,
is_aggregate: bool = False,
query_classification: Optional[Dict[str, Any]] = None, # 从重写中获得的分类信息(可选)
) -> Tuple[Optional[str], List[str]]:
"""
生成和验证Cypher完整流程多轮生成+验证+自动修正)
工作流程:
1. 生成Cypher语句
2. 验证Cypher语句语法+LLM语义验证
3. 如果有错误,将错误反馈给生成函数,重新生成
4. 关系方向校正
5. 返回最终Cypher
"""
errors_log = []
previous_cypher = None
error_type = None # 当前错误类型:'syntax_error' | 'schema_error' | 'empty_result'
for attempt in range(1, max_attempts + 1):
# ========== 1. 生成/修正Cypher ==========
cypher = await self.generate_cypher(
query=query,
entry_nodes=entry_nodes,
previous_cypher=previous_cypher,
validation_errors=errors_log if attempt > 1 else None,
is_aggregate=is_aggregate,
error_type=error_type, # 传递错误类型,用于选择对应的修正 prompt
query_classification=query_classification, # 传递分类信息
)
if not cypher:
errors_log.append(f"{attempt}轮: Cypher生成失败")
continue
# ========== 2. 快速规则验证(必须通过) ==========
syntax_valid, syntax_error = self.validate_cypher_syntax(cypher)
if not syntax_valid:
errors_log.append(f"语法错误: {syntax_error}")
error_type = 'syntax_error' # 标记为语法错误
previous_cypher = cypher
continue
# ========== 3. Schema验证在Dry Run之前执行 ==========
schema_valid, schema_error = self.validate_cypher_schema(cypher)
if not schema_valid:
errors_log.append(f"Schema错误: {schema_error}")
error_type = 'schema_error'
previous_cypher = cypher
continue
# ========== 4. Dry Run验证可选可通过环境变量跳过 ==========
if self.skip_dry_run_validation:
dry_run_valid, dry_run_error, error_info = True, None, None # 跳过验证
else:
dry_run_valid, dry_run_error, error_info = self.dry_run_cypher(cypher)
if not dry_run_valid:
# ========== 从 error_info 中提取错误类型 ==========
if error_info and error_info.get('error_type'):
error_type = error_info['error_type'] # 'syntax_error' 或 'schema_error'
else:
# 如果 error_info 中没有 error_type根据错误信息推断
if 'Schema错误' in dry_run_error or 'schema' in dry_run_error.lower():
error_type = 'schema_error'
else:
error_type = 'syntax_error'
# ========== 对于其他错误,尝试基于实际错误信息进行智能修复 ==========
if error_info:
fixed_cypher = self._fix_cypher_based_on_error(cypher, error_info)
if fixed_cypher and fixed_cypher != cypher:
logger.info(f"[智能修复] 基于Dry Run错误信息自动修复Cypher")
logger.debug(f"[智能修复] 原始: {cypher[:200]}...")
logger.debug(f"[智能修复] 修复后: {fixed_cypher[:200]}...")
# 重新验证修复后的Cypher
cypher = fixed_cypher
error_type = None # 重置错误类型,重新验证
# 重新进行验证(从语法验证开始)
continue
# 如果无法自动修复,记录错误并继续
error_detail = f"执行计划错误: {dry_run_error}"
if error_info and error_info.get("error_position"):
pos = error_info["error_position"]
error_detail += f" (位置: 第{pos.get('line', '?')}行, 第{pos.get('column', '?')}列)"
errors_log.append(error_detail)
previous_cypher = cypher
continue
# ========== 所有验证通过 ==========
# 注意关系方向校正已移除因为prompt中已明确要求使用无方向关系
self.last_cypher = cypher # 保存最后使用的Cypher
return cypher, errors_log
# ========== 所有尝试都失败 ==========
logger.error(f"GraphRetrieval: Cypher生成失败所有{max_attempts}轮尝试均失败")
return None, errors_log
async def search(
self,
query: str,
entry_nodes: dict,
max_attempts: int = None,
query_type: Literal["auto", "aggregate", "detail"] = "auto",
query_classification: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
执行图谱检索(完整流程)
"""
search_start = time.time()
logger.info("GraphRetrieval.search: 开始图谱检索")
# ========== 0. 过滤入口节点 ==========
entry_nodes = filter_and_sort_entry_nodes(entry_nodes, max_nodes_per_label=5)
logger.info("输出过滤后的入口节点")
# logger.info(entry_nodes[0].get("名称"))
# logger.info(entry_nodes)
# ========== 1. 判断是否为聚合查询 ==========
if query_classification:
classification_query_type = query_classification.get("query_type", "detail")
is_aggregate = (classification_query_type == "aggregate")
else:
if query_type == "aggregate":
is_aggregate = True
elif query_type == "detail":
is_aggregate = False
else:
is_aggregate = is_aggregate_query(query)
# ========== 2. 生成 Cypher ==========
if max_attempts is None:
max_attempts = self.default_max_attempts
cypher, errors = await self.generate_and_validate_cypher(
query=query,
entry_nodes=entry_nodes,
max_attempts=max_attempts,
is_aggregate=is_aggregate,
query_classification=query_classification,
)
if not cypher:
return await self._fallback_to_simple_or_entry_nodes(query, entry_nodes, is_aggregate)
# ========== 3. 执行查询(支持拆分)==========
records, path_records = self._execute_cypher_with_split(cypher, is_aggregate)
# ========== 4. 处理空结果或 0 值(仅聚合)==========
if not records:
logger.warning("Cypher查询结果为空尝试重试")
new_cypher, _ = await self.generate_and_validate_cypher(
query=query,
entry_nodes=entry_nodes,
max_attempts=max_attempts,
is_aggregate=is_aggregate,
query_classification=query_classification,
)
if new_cypher and new_cypher != cypher:
new_records, new_path_records = self._execute_cypher_with_split(new_cypher, is_aggregate)
if new_records:
self.last_cypher = new_cypher
return self._process_results(new_records, new_path_records, query, is_aggregate)
elif is_aggregate and is_aggregate_result_zero([dict(r) for r in records], cypher):
logger.warning("统计类查询返回0尝试重试")
new_cypher, _ = await self.generate_and_validate_cypher(
query=query,
entry_nodes=entry_nodes,
max_attempts=max_attempts,
is_aggregate=is_aggregate,
query_classification=query_classification,
)
if new_cypher and new_cypher != cypher:
new_records, new_path_records = self._execute_cypher_with_split(new_cypher, is_aggregate)
if new_records:
self.last_cypher = new_cypher
return self._process_results(new_records, new_path_records, query, is_aggregate)
# ========== 5. 正常返回 ==========
self.last_cypher = cypher
result = self._process_results(records, path_records, query, is_aggregate)
search_elapsed = (time.time() - search_start) * 1000
logger.info(f"[时间统计] GraphRetrieval.search: 完成,总耗时: {search_elapsed:.2f}ms, 结果数: {len(result)}")
return result
def _execute_cypher_with_split(self, cypher: str, is_aggregate: bool):
"""执行 Cypher支持拆分为 path + aggregate"""
path_cypher, aggregate_cypher = self._split_aggregate_cypher(cypher)
if path_cypher and aggregate_cypher:
logger.info("[Cypher拆分] 执行 path 和 aggregate 查询")
try:
path_result = self.driver.execute_query(path_cypher)
path_records = path_result.records
except Exception as e:
logger.warning(f"[Cypher拆分] Path 查询失败: {e}")
path_records = None
try:
aggregate_result = self.driver.execute_query(aggregate_cypher)
records = aggregate_result.records
except Exception as e:
logger.error(f"[Cypher拆分] Aggregate 查询失败: {e},回退到原始 Cypher")
result = self.driver.execute_query(cypher)
return result.records, None
return records, path_records
else:
result = self.driver.execute_query(cypher)
return result.records, None
def _process_results(
self,
records,
path_records,
query: str,
is_aggregate: bool
) -> List[Dict[str, Any]]:
"""
统一处理查询结果:
- 聚合查询:注入 result 到 pathrerank path
- 非聚合:确保每条记录有可序列化的 'result' 字段,并 rerank
"""
if is_aggregate:
aggregate_results = [dict(r) for r in records]
aggregate_value = aggregate_results[0].get('result', aggregate_results[0])
if path_records:
raw_results = [dict(r) for r in path_records]
# 注入聚合值
for res in raw_results:
res['result'] = aggregate_value
if raw_results:
raw_results[0]['_aggregate_value'] = aggregate_value
raw_results = self._rerank_results(raw_results, query, is_aggregate=False)
return raw_results
else:
return aggregate_results
else:
value_results = [dict(r) for r in records]
if path_records:
raw_results = [dict(r) for r in path_records]
# 注入明细查询结果,防止 value_results 索引越界
for i in range(len(raw_results)):
if i < len(value_results):
raw_results[i]['result'] = value_results[i]
else:
raw_results[i]['result'] = None # 或 {}、""、[] 等默认值
raw_results = self._rerank_results(raw_results, query, is_aggregate=False)
return raw_results
else:
return value_results
async def _fallback_to_simple_or_entry_nodes(
self,
query: str,
entry_nodes: dict,
is_aggregate: bool,
) -> List[Dict[str, Any]]:
"""降级:先试简单模板,再试入口节点"""
# 尝试简单模板
simple_cypher = self._generate_simple_template_cypher(entry_nodes, query, is_aggregate)
if simple_cypher:
try:
result = self.driver.execute_query(simple_cypher)
if result.records:
raw_results = [dict(r) for r in result.records]
raw_results = self._rerank_results(raw_results, query, is_aggregate)
logger.info(f"[降级策略] 简单模板查询成功,返回 {len(raw_results)} 条结果")
return raw_results
except Exception as e:
logger.warning(f"[降级策略] 简单模板查询失败: {e}")
# 兜底:入口节点
fallback_results = format_entry_nodes_as_results(entry_nodes)
if fallback_results:
logger.info(f"[降级策略] 返回 {len(fallback_results)} 条入口节点信息(兜底)")
return fallback_results
return []
def _split_aggregate_cypher(self, cypher: str) -> Tuple[Optional[str], Optional[str]]:
if not cypher or not cypher.strip():
return None, None
try:
import re
# 提取 MATCH 模式部分(支持前导任意内容,如 "cypher\n"
match_match = re.search(
r'(?i)\bMATCH\s+(.+?)(?=\s+\b(?:WHERE|WITH|RETURN)\b)',
cypher,
re.DOTALL
)
if not match_match:
logger.warning(f"[Cypher处理] 未找到合法的 MATCH 子句。输入片段: {cypher[:100]}...")
return None, None
full_match_clause = match_match.group(1).strip()
# 提取 WHERE 子句(如果有)
where_match = re.search(
r'(?i)\bWHERE\s+(.+?)(?=\s+\b(?:WITH|RETURN)\b)',
cypher,
re.DOTALL
)
where_clause = " WHERE " + where_match.group(1).strip() if where_match else ""
# 取第一个连通模式(逗号分隔时只取第一个)
patterns = [p.strip() for p in full_match_clause.split(',') if p.strip()]
if not patterns:
logger.warning("[Cypher处理] MATCH 子句中无有效模式")
return None, None
first_pattern = patterns[0]
# 构造 path 查询(自动命名 path 变量)
path_var = "path"
path_pattern = f"{path_var} = {first_pattern}"
# 强制关系无向化(防御性)
path_pattern = re.sub(r'<?-\[', '-[', path_pattern)
path_pattern = re.sub(r'\]->?', ']-', path_pattern)
path_cypher = f"MATCH {path_pattern}{where_clause} RETURN DISTINCT {path_var}"
aggregate_cypher = cypher
logger.debug(f"[Cypher处理] Path查询: {path_cypher}")
return path_cypher, aggregate_cypher
except Exception as e:
logger.exception(f"[Cypher处理] 生成 path 查询失败: {e}")
return None, None
def _generate_simple_template_cypher(
self,
entry_nodes: dict,
query: str,
is_aggregate: bool
) -> Optional[str]:
"""
生成简单的模板Cypher查询降级策略
"""
if not entry_nodes:
return None
try:
# 选择第一个标签和第一个节点作为起点
first_label = list(entry_nodes.keys())[0]
first_nodes = entry_nodes[first_label]
if not first_nodes:
return None
first_node = first_nodes[0]
node_name = first_node.get("name") or first_node.get("_name") or (first_node.get("node", {}).get("名称"))
if not node_name:
return None
# 生成简单的查询:只查询入口节点及其直接邻居
if is_aggregate:
# 统计类:统计邻居数量
cypher = (
f"MATCH path=(n:{first_label} {{名称: '{node_name}'}})-[]-(m)\n"
f"RETURN path, m"
)
else:
# 明细类:返回邻居节点
cypher = (
f"MATCH path=(n:{first_label} {{名称: '{node_name}'}})-[]-(m)\n"
f"RETURN path, m\n"
f"LIMIT 50"
)
logger.debug(f"[降级策略] 生成简单模板查询: {cypher}")
return cypher
except Exception as e:
logger.warning(f"[降级策略] 生成简单模板查询失败: {e}")
return None
def _extract_path_info(self, record: Dict[str, Any]) -> List[str]:
"""
从单个记录中提取路径相关信息用于rerank
"""
path_texts = []
# 遍历记录中的所有值,查找路径信息
for key, value in record.items():
if value is None:
continue
# 如果值是路径对象
if hasattr(value, 'nodes') and hasattr(value, 'relationships'):
# 提取路径中的节点和关系信息
try:
# 处理路径中的节点
for node in value.nodes:
node_info = f"节点: {dict(node).get('name', dict(node).get('名称', '未知节点'))} 类型: {list(node.labels)}"
path_texts.append(node_info)
# 处理路径中的关系
for rel in value.relationships:
rel_info = f"关系: {rel.type}"
path_texts.append(rel_info)
except Exception as e:
logger.warning(f"提取路径信息时出错: {e}")
# 如果值是字典,可能是节点或关系对象
elif isinstance(value, dict):
if 'labels' in value or 'type' in value:
# 这可能是节点或关系
if 'labels' in value: # 节点
node_info = f"节点: {value.get('name', value.get('名称', '未知节点'))} 类型: {value.get('labels', [])}"
path_texts.append(node_info)
elif 'type' in value: # 关系
rel_info = f"关系: {value.get('type')}"
path_texts.append(rel_info)
# 如果值是列表,可能包含多个节点或关系
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
if 'labels' in item: # 节点
node_info = f"节点: {item.get('name', item.get('名称', '未知节点'))} 类型: {item.get('labels', [])}"
path_texts.append(node_info)
elif 'type' in item: # 关系
rel_info = f"关系: {item.get('type')}"
path_texts.append(rel_info)
return path_texts
def _rerank_results(self, results: List[Dict[str, Any]], query: str, is_aggregate: bool = False) -> List[
Dict[str, Any]]:
"""
使用 model_api 中的 rerank 对结果进行重排序。
"""
if not results:
return results
# ✅ 统计类查询:不 rerank直接赋分 1.0
if is_aggregate:
logger.info("检测到统计类查询,跳过 rerank统一设置 rerank_score=1.0")
reranked_results = []
for result in results:
result_copy = result.copy()
result_copy['rerank_score'] = 1.0
reranked_results.append(result_copy)
return reranked_results
# === 以下为原语义 rerank 逻辑 ===
try:
from modelsAPI.model_api import OpenaiAPI
documents = []
for result in results:
path_texts = self._extract_path_info(result)
doc_text = " ".join(path_texts) if path_texts else str(result)
documents.append(doc_text)
rerank_results = OpenaiAPI.rerank_query(query, documents, top_n=len(documents))
reranked_results = []
sorted_rerank_results = sorted(rerank_results, key=lambda x: x.get('scores', 0), reverse=True)
for rerank_result in sorted_rerank_results:
original_index = rerank_result['index']
if original_index < len(results):
result_copy = results[original_index].copy()
result_copy['rerank_score'] = rerank_result['scores']
reranked_results.append(result_copy)
return reranked_results
except Exception as e:
logger.error(f"重排序过程中出错: {e}")
# 失败时也统一加默认分数(保持接口一致)
fallback_results = []
for result in results:
result_copy = result.copy()
result_copy['rerank_score'] = 0.0
fallback_results.append(result_copy)
return fallback_results