# neo4j-admin database load --from-path="neo4j.dump文件所在目录" --overwrite-destination=true neo4j --verbose import os import re import jieba import logging import numpy as np from concurrent.futures import ThreadPoolExecutor from neo4j import GraphDatabase from neo4j_graphrag.indexes import ( create_vector_index, upsert_vectors, create_fulltext_index, ) from modelsAPI.model_api import OpenaiAPI from dotenv import load_dotenv from typing import List, Set, Tuple, Dict, Optional from config import LLM_CONFIG, EMBEDDING_CONFIG, NEO4J_CONFIG, SEARCH_CONFIG # 加载 .env 文件中的环境变量 load_dotenv() # 配置控制台日志 logger = logging.getLogger("indexing") logger.setLevel(logging.INFO) if not logger.handlers: formatter = logging.Formatter("[%(levelname)s]%(asctime)s: %(message)s") handler = logging.StreamHandler() handler.setFormatter(formatter) logger.addHandler(handler) VECTOR_DIMENSION = SEARCH_CONFIG["vector_dimension"] vector_dim = SEARCH_CONFIG["vector_dimension"] # OpenAI bge-m3 模型的嵌入向量维度 embed_batch_size = SEARCH_CONFIG["embed_batch_size"] # 嵌入向量计算批次大小 EMBED_MAX_WORKERS = SEARCH_CONFIG["embed_thread_num"] # embedding 批次并行线程数(受下游 OpenaiAPI 限流约束,勿过大) NODE_FETCH_PAGE = SEARCH_CONFIG["node_fetch_page"] # 单次拉取待生成 embedding 节点的分页大小 # 全局索引名称(覆盖所有节点标签) GLOBAL_VECTOR_INDEX_NAME = SEARCH_CONFIG["global_entity_embedding"] GLOBAL_FULLTEXT_INDEX_NAME = SEARCH_CONFIG["global_entity_content_search"] DEFAULT_URI = NEO4J_CONFIG["uri"] DEFAULT_AUTH = ( NEO4J_CONFIG["username"], NEO4J_CONFIG["password"], ) NAME_PROPERTY = SEARCH_CONFIG["name_property"] FULLTEXT_PROPERTY = SEARCH_CONFIG["fulltext_property"] EMBEDDING_PROPERTY = SEARCH_CONFIG["embedding_property"] SEARCH_LABEL = SEARCH_CONFIG["search_label"] VECTOR_INDEX_NAME = SEARCH_CONFIG["vector_index_name"] FULLTEXT_INDEX_NAME = SEARCH_CONFIG["fulltext_index_name"] FULLTEXT_FIELD_INDEX_NAME = SEARCH_CONFIG["fulltext_field_index_name"] EXCLUDED_BUSINESS_LABELS: Set[str] = set(SEARCH_CONFIG["excluded_business_labels"]) OLD_INDEX_NAMES: List[str] = [ VECTOR_INDEX_NAME, FULLTEXT_INDEX_NAME, FULLTEXT_FIELD_INDEX_NAME, GLOBAL_VECTOR_INDEX_NAME, GLOBAL_FULLTEXT_INDEX_NAME, ] def get_all_labels(driver): """ 获取知识图谱中所有的节点标签 Args: driver: Neo4j driver Returns: list: 所有节点标签列表 """ query = "CALL db.labels()" result = driver.execute_query(query) labels = [record["label"] for record in result.records] return labels def get_label_properties(driver, label): """ 获取指定标签的所有属性键(通过采样多个节点获取所有可能的属性) Args: driver: Neo4j driver label: 节点标签 Returns: list: 属性键列表(排除内部属性如 embedding, fulltext) """ # 采样多个节点来获取所有可能的属性(因为不同节点可能有不同的属性集) query = f""" MATCH (n:{label}) WITH n LIMIT 100 UNWIND keys(n) AS prop RETURN DISTINCT prop AS properties ORDER BY properties """ result = driver.execute_query(query) if result.records: properties = [record["properties"] for record in result.records] # 排除内部索引属性 excluded_props = {"embedding", "fulltext", "elementId", "id"} properties = [prop for prop in properties if prop not in excluded_props] return properties else: logger.warning(f"标签 {label} 没有节点,无法获取属性列表") return [] def build_text_from_all_properties(record_dict): """ 从节点的所有属性构建文本(用于全文索引) Args: record_dict: 节点属性字典(已排除 id) Returns: str: 构建的文本字符串 """ text_parts = [] for key, value in record_dict.items(): if value is not None: # 将值转换为字符串 if isinstance(value, (list, dict)): value_str = str(value) else: value_str = str(value).strip() if value_str: text_parts.append(f"{key}:{value_str}") return " ".join(text_parts) if text_parts else None def _compute_embeddings_parallel(names: List[str]) -> List[list]: """ 并行计算一批名称的 embedding。 将 names 切成多个 batch,用线程池并发调用 OpenaiAPI.batch_embeddings, 再按原始顺序拼回。相比串行可显著缩短大批量节点的 embedding 耗时。 Args: names: 待计算 embedding 的文本列表 Returns: list: 与 names 等长、顺序一致的 embedding 向量列表 Raises: 任一 batch 计算失败时向上抛出异常 """ # 切分批次,保留批次索引以便按序拼回 batches = [ (idx, names[i:i + embed_batch_size]) for idx, i in enumerate(range(0, len(names), embed_batch_size)) ] results: Dict[int, list] = {} def _run_batch(item): idx, batch_names = item batch_result = OpenaiAPI.batch_embeddings(batch_names) # batch_result 返回的是字典列表,每个字典包含 'embedding' 字段 return idx, [row["embedding"] for row in batch_result] workers = min(EMBED_MAX_WORKERS, len(batches)) or 1 with ThreadPoolExecutor(max_workers=workers) as executor: for idx, batch_embeddings in executor.map(_run_batch, batches): results[idx] = batch_embeddings # 按批次顺序拼回,保证与输入 names 对齐 all_embeddings: List[list] = [] for idx in range(len(batches)): all_embeddings.extend(results[idx]) return all_embeddings def drop_constraint(driver): """删除所有约束""" records = driver.execute_query("show constraints").records for record in records: driver.execute_query(f"drop constraint {record['name']} if exists") def drop_index_without_constraint(driver): """删除所有没有约束的索引""" records = driver.execute_query("show index").records for record in records: if not record["owningConstraint"]: driver.execute_query(f"drop index {record['name']} if exists") # --------- 创建全局混合索引(覆盖所有节点标签) --------- def create_global_indexes(driver, force_refresh=False): """ 创建覆盖所有节点标签的全局向量索引和全文索引。 与按标签创建的索引互补,支持跨标签统一检索。 Args: driver: Neo4j driver force_refresh: True=先删除旧索引再重建,False=IF NOT EXISTS 跳过已有索引 """ if force_refresh: for index_name in (GLOBAL_VECTOR_INDEX_NAME, GLOBAL_FULLTEXT_INDEX_NAME): try: driver.execute_query(f"DROP INDEX {index_name} IF EXISTS") logger.info(f"强制刷新:已删除全局索引 {index_name}") except Exception as e: logger.warning(f"删除全局索引 {index_name} 时出错(可能不存在): {e}") # 全局向量索引(作用于所有节点的 embedding 属性) try: driver.execute_query(f""" CREATE VECTOR INDEX {GLOBAL_VECTOR_INDEX_NAME} IF NOT EXISTS FOR (n) ON n.embedding OPTIONS {{indexConfig: {{ `vector.dimensions`: {vector_dim}, `vector.similarity_function`: 'cosine' }}}} """) logger.info(f"全局向量索引 '{GLOBAL_VECTOR_INDEX_NAME}' 已就绪") except Exception as e: logger.error(f"创建全局向量索引失败: {e}") raise # 全局全文索引(作用于所有节点的 名称 和 内容 属性) try: driver.execute_query(f""" CREATE FULLTEXT INDEX {GLOBAL_FULLTEXT_INDEX_NAME} IF NOT EXISTS FOR (n) ON EACH [n.名称, n.内容] """) logger.info(f"全局全文索引 '{GLOBAL_FULLTEXT_INDEX_NAME}' 已就绪") except Exception as e: logger.error(f"创建全局全文索引失败: {e}") raise # --------- 创建向量索引 --------- def vector_indexing(driver, label, force_refresh=False): """ 创建向量索引(如果节点没有 embedding,使用"名称"属性生成 embedding) 说明: - 待生成 embedding 的节点通过分页循环全部处理,不再受单次 LIMIT 限制, 避免节点数超过一万时被静默丢弃。 - embedding 计算采用并行批次以缩短耗时。 Args: driver: Neo4j driver label: 节点标签 force_refresh: 是否强制刷新(如果索引已存在,force_refresh=False 时会跳过,True 时会先删除再创建) """ # === 分页处理所有缺失 embedding 的节点 === # 每轮取一页未生成 embedding 的节点并写回,直到没有剩余节点为止。 # 这样即使待处理节点远超一万也能全部覆盖。 total_generated = 0 while True: query_no_embedding = f""" MATCH (n:{label}) WHERE n.embedding IS NULL AND n.名称 IS NOT NULL RETURN elementId(n) AS id, n.名称 AS 名称 LIMIT {NODE_FETCH_PAGE} """ result_no_embedding = driver.execute_query(query_no_embedding) nodes_to_process = [ (record["id"], record["名称"]) for record in result_no_embedding.records ] if not nodes_to_process: break logger.info(f"标签 {label} 本轮发现 {len(nodes_to_process)} 个节点需要生成 embedding") ids, names = zip(*nodes_to_process) ids = list(ids) names = list(names) # 并行计算 embedding logger.info(f"开始为 {len(names)} 个节点计算 embedding(并行批次)...") try: all_embeddings = _compute_embeddings_parallel(names) except Exception as e: logger.error(f"批量嵌入向量计算失败: {e}") raise # 转换为 numpy 数组并做 L2 归一化 embeddings = np.array(all_embeddings) norms = np.linalg.norm(embeddings, axis=1, keepdims=True) norms = np.where(norms == 0, 1, norms) # 避免除零 embeddings = embeddings / norms # 保存 embedding 到节点 logger.info(f"写入 {len(ids)} 个节点的 embedding...") upsert_vectors( driver, ids=ids, embedding_property="embedding", embeddings=embeddings, ) total_generated += len(ids) logger.info(f"本轮成功为 {len(ids)} 个节点生成并保存 embedding") # 不足一页说明已处理完,提前结束 if len(nodes_to_process) < NODE_FETCH_PAGE: break if total_generated: logger.info(f"标签 {label} 共生成 {total_generated} 个节点的 embedding") # 检查是否有节点存在 embedding(包括刚生成的) check_query = f"MATCH (n:{label}) WHERE n.embedding IS NOT NULL RETURN count(n) AS count" result = driver.execute_query(check_query) node_count = result.records[0]["count"] if result.records else 0 if node_count == 0: logger.warning(f"标签 {label} 没有节点包含 embedding 属性,跳过向量索引创建") return # 创建向量索引 index_name = f"{label.lower()}_vector" # 如果强制刷新且索引已存在,先删除它 if force_refresh: try: driver.execute_query(f"DROP INDEX {index_name} IF EXISTS") logger.info(f"强制刷新模式:已删除旧索引 {index_name}") except Exception as e: logger.warning(f"删除索引 {index_name} 时出错(可能不存在): {e}") try: create_vector_index( driver, name=index_name, # 索引的唯一名称 label=label, # 要索引的节点标签 embedding_property="embedding", # 包含嵌入向量值的节点属性键 dimensions=vector_dim, # 向量嵌入维度,1024与使用的 bge-m3 嵌入模型一致 similarity_fn="cosine", # 向量相似度函数,可选 "euclidean" 或 "cosine" ) logger.info(f"成功为标签 {label} 创建向量索引(共 {node_count} 个节点)") except Exception as e: # 如果是增量更新模式且索引已存在,这是正常的,只记录警告 if not force_refresh and "already exists" in str(e).lower(): logger.info(f"标签 {label} 的向量索引已存在,跳过创建") else: logger.error(f"为标签 {label} 创建向量索引失败: {e}") raise # --------- 创建全文索引 --------- def fulltext_indexing(driver, label, force_refresh=False): """ 创建全文索引,并添加节点属性(自动获取所有属性) 全文索引:词 ---》 id 后续检索,分词后,根据词反向找到节点 Args: driver: Neo4j driver label: 节点标签 force_refresh: 是否强制刷新所有节点的全文索引(True=清空后重新生成,False=只处理缺失的节点) """ # 排除内部属性 excluded_props = {"embedding", "fulltext", "elementId", "id"} # 根据 force_refresh 决定查询条件 if force_refresh: # 强制刷新:处理所有节点(包括已有 fulltext 的节点) query = f"""MATCH (n:{label}) WITH n, elementId(n) AS id, properties(n) AS props RETURN id, props""" else: # 增量更新:只处理没有 fulltext 的节点 query = f"""MATCH (n:{label}) WHERE n.fulltext IS NULL WITH n, elementId(n) AS id, properties(n) AS props RETURN id, props""" # 执行查询 records = driver.execute_query(query).records if not records: if force_refresh: logger.info(f"{label} 没有节点需要处理") else: logger.info(f"{label} 所有节点皆存在全文索引属性") return # 构建文本描述(使用所有属性) record_list = [] for r in records: node_id = r["id"] props = r["props"] # 过滤掉内部属性 filtered_props = {k: v for k, v in props.items() if k not in excluded_props} # 使用所有属性构建文本 text = build_text_from_all_properties(filtered_props) if text: record_list.append({"id": node_id, "text": text}) if not record_list: logger.info(f"{label} 没有可索引的文本内容") return # 创建全文索引(在写入数据之前创建索引) try: create_fulltext_index( driver, name=f"{label.lower()}_fulltext", # 索引的唯一名称 label=label, # 要创建索引的节点标签 node_properties=["fulltext"], # 要创建全文索引的节点属性列表 ) except Exception as e: logger.warning(f"创建全文索引可能已存在(继续处理): {e}") # 文本分词,作为全文索引属性 logger.info(f"计算 {label} ({len(record_list)}) 的全文索引") pattern = re.compile(r"[a-zA-Z0-9\u4e00-\u9fa5]+") # 匹配英文字母、数字和中文字符 fulltext_tuple_list = [] for id_, text in [(r["id"], r["text"]) for r in record_list]: # 使用 jieba 分词 words = jieba.lcut(text) # 过滤并连接 filtered_words = [ word.strip() for word in words if word.strip() and pattern.fullmatch(word.strip()) ] fulltext_value = " ".join(filtered_words) if fulltext_value: fulltext_tuple_list.append((id_, fulltext_value)) if not fulltext_tuple_list: logger.warning(f"{label} 分词后没有有效内容") return ids, fulltexts = zip(*fulltext_tuple_list) ids = list(ids) fulltexts = list(fulltexts) # 按 elementId 添加全文索引属性 logger.info(f"写入 {label} ({len(fulltexts)}) 的全文索引") insert_batch_size = 1000 for i in range(0, len(fulltext_tuple_list), insert_batch_size): batch_rows = [ {"id": id_, "fulltext": ft} for id_, ft in zip( ids[i: i + insert_batch_size], fulltexts[i: i + insert_batch_size], ) ] # UNWIND:将列表数据展开为多行记录 driver.execute_query( "UNWIND $rows AS row " # 将传入的 rows 列表展开,每项作为一行数据,命名为 row "MATCH (n) " "WHERE elementId(n) = row.id " "SET n.fulltext = row.fulltext ", {"rows": batch_rows}, ) def create_all_indexes(neo4j_uri=None, neo4j_username=None, neo4j_password=None, force_refresh=False, driver=None): """ 创建所有索引的主函数(可被外部调用) 注意:Neo4j 连接配置优先从 .env 文件中读取(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 如果 .env 文件中没有配置,则使用参数中的值(如果提供)或默认值 Args: neo4j_uri: Neo4j URI(可选,优先级低于 .env,提供 driver 时忽略) neo4j_username: Neo4j 用户名(可选,优先级低于 .env,提供 driver 时忽略) neo4j_password: Neo4j 密码(可选,优先级低于 .env,提供 driver 时忽略) force_refresh: 是否强制刷新所有索引(True=清空重建,False=增量更新) driver: 可选的 Neo4j driver 实例,提供则复用外部 driver Returns: dict: 包含执行结果的字典 """ use_external_driver = driver is not None if not use_external_driver: # 优先从 .env 文件读取配置,如果 .env 中没有则使用参数或默认值 neo4j_uri = os.getenv("NEO4J_URI") or neo4j_uri or "bolt://192.168.0.46:57687" neo4j_username = os.getenv("NEO4J_USERNAME") or neo4j_username or "neo4j" neo4j_password = os.getenv("NEO4J_PASSWORD") or neo4j_password or "zdht123@" logger.info(f"连接到 Neo4j: {neo4j_uri}") else: logger.info("使用外部提供的 Neo4j driver") # 定义内部函数来执行索引创建逻辑(避免代码重复) def _execute_indexing(driver): # 1、根据 force_refresh 决定是否清空索引和约束 if force_refresh: logger.info("强制刷新模式:清空所有约束...") drop_constraint(driver) logger.info("强制刷新模式:清空所有索引...") drop_index_without_constraint(driver) logger.info("强制刷新模式:清空所有节点的 fulltext 属性...") driver.execute_query("MATCH (n) WHERE n.fulltext IS NOT NULL REMOVE n.fulltext") else: logger.info("增量更新模式:保留现有索引和约束") # 2、自动获取所有节点标签并创建向量索引 logger.info("=" * 50) logger.info("开始创建向量索引...") logger.info("=" * 50) all_labels = get_all_labels(driver) logger.info(f"发现 {len(all_labels)} 个节点标签: {all_labels}") vector_index_errors = [] for label in all_labels: logger.info(f"处理标签: {label}") try: vector_indexing(driver, label, force_refresh=force_refresh) except Exception as e: error_msg = f"为标签 {label} 创建向量索引时出错: {e}" logger.error(error_msg) vector_index_errors.append(error_msg) continue # 3、创建全文索引(自动获取所有属性) logger.info("=" * 50) logger.info("开始创建全文索引...") logger.info("=" * 50) fulltext_index_errors = [] for label in all_labels: logger.info(f"处理标签: {label}") try: fulltext_indexing(driver, label, force_refresh=force_refresh) except Exception as e: error_msg = f"为标签 {label} 创建全文索引时出错: {e}" logger.error(error_msg) fulltext_index_errors.append(error_msg) continue logger.info("=" * 50) logger.info("所有索引创建完成!") logger.info("=" * 50) has_errors = len(vector_index_errors) > 0 or len(fulltext_index_errors) > 0 mode_text = "强制刷新" if force_refresh else "增量更新" return { "success": not has_errors, "message": f"{mode_text}模式:索引创建完成" if not has_errors else f"{mode_text}模式:索引创建完成,但部分标签出现错误", "mode": "force_refresh" if force_refresh else "incremental", "labels": all_labels, "vector_index_errors": vector_index_errors, "fulltext_index_errors": fulltext_index_errors, } try: if use_external_driver: # 使用外部 driver,直接执行,不关闭 driver return _execute_indexing(driver) else: # 使用内部 driver,使用上下文管理器自动关闭 with GraphDatabase.driver(neo4j_uri, auth=(neo4j_username, neo4j_password)) as driver: return _execute_indexing(driver) except Exception as e: error_msg = f"创建索引过程中发生错误: {e}" logger.error(error_msg, exc_info=True) return { "success": False, "message": error_msg, "mode": "error", "labels": [], "vector_index_errors": [], "fulltext_index_errors": [], } def print_neo4j_version(driver) -> None: """打印当前 Neo4j 实例的版本和版本类型。""" with driver.session() as session: rec = session.run( "CALL dbms.components() YIELD name, versions, edition " "WHERE name='Neo4j Kernel' RETURN versions[0] AS v, edition" ).single() if rec: logger.info(f"Neo4j 版本: {rec['v']} ({rec['edition']})") def migrate_remove_entity_label(driver, dry_run: bool = False) -> None: """ 从所有节点上移除 :Entity 标签。 Parameters ---------- driver : Neo4j GraphDatabase driver dry_run : 若为 True,只统计数量,不执行移除 """ with driver.session() as session: cnt = session.run("MATCH (n:Entity) RETURN count(n) AS c").single()["c"] logger.info(f"当前带 :Entity 标签的节点数: {cnt}") if cnt == 0: return if dry_run: logger.info("[dry_run] 未执行移除") return session.run("MATCH (n:Entity) REMOVE n:Entity") logger.info(f"已从 {cnt} 个节点上移除 :Entity") def tag_searchable_nodes(driver, excluded: Set[str]) -> int: """ 为满足以下条件的节点打上 :Searchable 标签: - 拥有 `名称` 属性 - 拥有 `embedding` 属性 - 尚未持有 :Searchable 标签 - 至少有一个非排除业务标签 注意:排除标签列表通过查询参数传入,避免 Cypher 字符串注入。 Returns ------- int : 本次新打标签的节点数 """ # 标签名无法参数化,但排除标签集合的“值”可以参数化,避免拼接注入 query = f""" MATCH (n) WHERE n.`{NAME_PROPERTY}` IS NOT NULL AND n.{EMBEDDING_PROPERTY} IS NOT NULL AND NOT n:{SEARCH_LABEL} AND any(lbl IN labels(n) WHERE NOT lbl IN $excluded) SET n:{SEARCH_LABEL} RETURN count(n) AS added """ excluded_list = list(excluded) with driver.session() as session: added = session.run(query, excluded=excluded_list).single()["added"] logger.info(f"本次新打上 :{SEARCH_LABEL} 的节点数: {added}") with driver.session() as session: total = session.run( f"MATCH (n:{SEARCH_LABEL}) RETURN count(n) AS c" ).single()["c"] logger.info(f"当前带 :{SEARCH_LABEL} 标签的节点总数: {total}") return added def drop_old_indexes(driver, names: List[str]) -> None: """删除指定名称的索引(不存在时忽略)。""" with driver.session() as session: for name in names: try: session.run(f"DROP INDEX {name} IF EXISTS") logger.info(f"已删除旧索引: {name}") except Exception as e: logger.warning(f"删除索引 {name} 失败(可忽略): {e}") def create_indexes(driver) -> None: """ 创建三个索引: 1. 向量索引 —— 用于语义相似度检索 2. 名称全文索引 —— 用于节点名称关键词检索 3. fulltext 字段全文索引 —— 用于节点 fulltext 属性关键词检索 """ with driver.session() as session: # 1. 向量索引 session.run(f""" CREATE VECTOR INDEX {VECTOR_INDEX_NAME} IF NOT EXISTS FOR (n:{SEARCH_LABEL}) ON (n.{EMBEDDING_PROPERTY}) OPTIONS {{ indexConfig: {{ `vector.dimensions`: {VECTOR_DIMENSION}, `vector.similarity_function`: 'cosine' }} }} """) logger.info(f"向量索引 '{VECTOR_INDEX_NAME}' 已就绪") # 2. 名称全文索引 session.run(f""" CREATE FULLTEXT INDEX {FULLTEXT_INDEX_NAME} IF NOT EXISTS FOR (n:{SEARCH_LABEL}) ON EACH [n.`{NAME_PROPERTY}`] """) logger.info(f"全文索引(名称) '{FULLTEXT_INDEX_NAME}' 已就绪") # 3. fulltext 字段全文索引 session.run(f""" CREATE FULLTEXT INDEX {FULLTEXT_FIELD_INDEX_NAME} IF NOT EXISTS FOR (n:{SEARCH_LABEL}) ON EACH [n.`{FULLTEXT_PROPERTY}`] """) logger.info(f"全文索引(fulltext 字段) '{FULLTEXT_FIELD_INDEX_NAME}' 已就绪") # ================== 连接解析工具 ================== def _resolve_connection( uri: Optional[str], user: Optional[str], password: Optional[str], ) -> Tuple[str, Tuple[str, str]]: """ 按优先级解析连接参数:传参 > 环境变量 > 默认常量。 Returns ------- (uri, (user, password)) """ resolved_uri = uri or os.getenv("NEO4J_URI", DEFAULT_URI) resolved_user = user or os.getenv("NEO4J_USER", DEFAULT_AUTH[0]) resolved_pw = password or os.getenv("NEO4J_PASSWORD", DEFAULT_AUTH[1]) return resolved_uri, (resolved_user, resolved_pw) # ================== 主入口函数 ================== def build_hybrid_indexes( driver=None, *, uri: Optional[str] = None, user: Optional[str] = None, password: Optional[str] = None, remove_entity_label: bool = False, dry_run_entity: bool = False, drop_old: bool = True, ) -> dict: """ 完整的混合索引构建流程。 连接参数优先级:传参 > 环境变量(NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD) > 默认常量 Parameters ---------- driver : 已有的 Neo4j driver(传入则直接使用,不再创建新连接) uri : Neo4j 连接地址,如 "bolt://host:7687" user : 数据库用户名 password : 数据库密码 remove_entity_label : 是否执行移除 :Entity 标签的迁移步骤 dry_run_entity : remove_entity_label=True 时,是否仅统计而不实际移除 drop_old : 是否在创建前先删除旧索引(建议保持 True) Returns ------- dict : {"success": bool, "message": str, "searchable_added": int} 失败时 success=False 且 message 含错误信息 """ # 若未传入 driver,则根据参数/环境变量/默认值自动创建 _owns_driver = driver is None if _owns_driver: resolved_uri, resolved_auth = _resolve_connection(uri, user, password) logger.info(f"连接 Neo4j: {resolved_uri} 用户: {resolved_auth[0]}") driver = GraphDatabase.driver(resolved_uri, auth=resolved_auth) try: logger.info("=" * 60) logger.info("开始构建混合索引") logger.info("=" * 60) print_neo4j_version(driver) if remove_entity_label: migrate_remove_entity_label(driver, dry_run=dry_run_entity) searchable_added = tag_searchable_nodes(driver, EXCLUDED_BUSINESS_LABELS) if drop_old: drop_old_indexes(driver, OLD_INDEX_NAMES) create_indexes(driver) return { "success": True, "message": "混合索引构建完成", "searchable_added": searchable_added, } except Exception as e: error_msg = f"构建混合索引过程中发生错误: {e}" logger.error(error_msg, exc_info=True) return { "success": False, "message": error_msg, "searchable_added": 0, } finally: # 仅关闭由本函数自己创建的 driver if _owns_driver: driver.close() # 向后兼容别名:保留旧拼写,避免其他模块仍引用 build_hyrid_indexes 时报错 build_hyrid_indexes = build_hybrid_indexes if __name__ == "__main__": # 可以通过命令行参数或环境变量控制是否强制刷新 import sys force_refresh = "--force" in sys.argv or os.getenv("FORCE_REFRESH", "false").lower() == "true" result = create_all_indexes(force_refresh=force_refresh) if result["success"]: logger.info(f"索引创建成功完成(模式: {result['mode']})") else: logger.error(f"索引创建完成但有错误: {result['message']}")