kgrag/neo4j_indexing.py
2026-07-29 18:10:19 +08:00

808 lines
29 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-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']}")