#!/usr/bin/env python3 """ 批量知识图谱构建脚本 —— 三阶段 ETL 阶段1 (extract): 对文件夹内所有文件做实体抽取,每个文件结果存为 JSON 阶段2 (merge): 汇总所有 JSON,去重,生成 embedding,写入 Neo4j 用法: # 只跑抽取(4个容器各跑各的文件夹) python batch_kg_build.py --folder /app/files --output_dir /app/kg_output --phase extract --workers 4 # 只跑入库(所有容器跑完后,跑一次) python batch_kg_build.py --output_dir /app/kg_output --phase merge #抽取好的实体关系直接进行存储 python batch_kg_build.py --phase load_snapshot --snapshot_path /path/to/my_custom_snapshot.json # 全流程一次跑完 python batch_kg_build.py --folder /app/files --output_dir /app/kg_output --phase all --workers 4 """ from default_ontology_config import ( ONTOLOGY as _BASE_ONTOLOGY, ONTOLOGY_DESCRIPTION as _BASE_ONTOLOGY_DESC, NODE_TYPES as _BASE_NODE_TYPES, RELATIONSHIPS as RELATIONSHIPS, RELATIONSHIP_TYPES as RELATIONSHIP_TYPES, ) import entity_registry as _entity_registry import importlib from modelsAPI.model_api import OpenaiAPI from kg_build.kg_save import MilitaryKnowledgeGraph from kg_build.filter_node_relation import filter_node_relationship from filename_proceess_and_kgquery import get_entity from kg_build.extract_filtertext import get_filtertext_node_relation from extract_text_node_relation import process_file_content, check_filename_ocr import os import json import logging import argparse import asyncio import traceback import time from datetime import datetime from multiprocessing import Process, Queue from concurrent.futures import ThreadPoolExecutor from typing import List, Dict, Tuple, Any, Optional EXTRACT_TIMEOUT = 600 # 单文件抽取超时秒数 EMBED_WORKERS = 8 # Embedding 并发线程数 FAULT_DIR = "/app/fault_wenjian" def _record_fault(file_name: str): """将超时文件名追加到 /app/fault_wenjian/timeout_YYYYMMDD.txt""" os.makedirs(FAULT_DIR, exist_ok=True) date_str = datetime.now().strftime("%Y%m%d") fault_path = os.path.join(FAULT_DIR, f"timeout_{date_str}.txt") with open(fault_path, "a", encoding="utf-8") as f: f.write(file_name + "\n") logger.warning(f"[FAULT] 已记录超时文件: {fault_path}") # ── 项目内模块 ────────────────────────────────────────────────────────────── def _build_ontology_config(): """ 与 app.py build_kg_config 保持一致: default_ontology_config 的静态配置 + entity_registry.json 动态注册的实体类型合并。 """ importlib.reload(_entity_registry) reg_list = _entity_registry.get_ontology_list() reg_desc = _entity_registry.get_ontology_description() reg_nodes = _entity_registry.get_node_types() # 合并:静态配置优先(手写配置不被覆盖) ontology = list(dict.fromkeys(_BASE_ONTOLOGY + reg_list)) # 去重保序 ontology_desc = {**reg_desc, **_BASE_ONTOLOGY_DESC} node_types = {**reg_nodes, **_BASE_NODE_TYPES} return ontology, ontology_desc, node_types # 模块加载时构建一次(抽取阶段开始前已有 registry 内容) ONTOLOGY, ONTOLOGY_DESC, NODE_TYPES = _build_ontology_config() # ── 配置(与 app.py 保持一致,按需修改) ──────────────────────────────────── NEO4J_URI = os.getenv("NEO4J_URI", "bolt://192.168.2.12:57687") NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "zdht123@") MINERU_INSTANCES = [ # ("http://192.168.0.64:18000/analyze-pdf"), #("http://192.168.0.111:9977/analyze-pdf/"), # ("http://192.168.0.111:9979/analyze-pdf"), ("http://192.168.2.12:59988/analyze-pdf/"), ] PREFIX_URL = os.getenv("PREFIX_URL", "http://192.168.2.12:59085") SUPPORTED_EXT = {".pdf", ".md", ".docx", ".xlsx"} # ── 日志 ──────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler("batch_kg_build.log", encoding="utf-8"), ], ) logger = logging.getLogger(__name__) # ════════════════════════════════════════════════════════════════════════════ # 阶段 1:单文件抽取 # ════════════════════════════════════════════════════════════════════════════ def extract_single_file(file_path: str, output_dir: str, api_url: str = MINERU_INSTANCES[0]) -> Dict: """ 对单个文件做实体/关系抽取,结果保存到 output_dir/.json。 已存在 JSON 则跳过(断点续跑)。 返回 {"file": ..., "status": "ok"/"skip"/"error", "entity_count": ..., "rel_count": ...} """ logger.info(f'[api_url] {api_url}') file_name = os.path.basename(file_path) ext = os.path.splitext(file_name)[1].lower() out_path = os.path.join(output_dir, file_name + ".json") # ── 断点续跑:已有结果直接跳过 ────────────────────────────────────────── if os.path.exists(out_path): logger.info(f"[SKIP] 已有结果,跳过: {file_name}") return {"file": file_name, "status": "skip"} if ext not in SUPPORTED_EXT: logger.warning(f"[SKIP] 不支持的文件类型: {file_name}") return {"file": file_name, "status": "skip"} t0 = time.time() entities: List[Dict] = [] relations: List[Dict] = [] lower_entity_device: Optional[str] = None lower_entity_type: Optional[str] = None try: # ── 1. 文件名实体提取 ──────────────────────────────────────────────── try: fn_result = get_entity(file_name) fn_entities = fn_result.get("entities", []) fn_relations = fn_result.get("relationships", []) lower_entity = fn_result.get("low_level") for e in fn_entities: if e.get("properties", {}).get("名称") == lower_entity: lower_entity_device = e["properties"].get("名称") lower_entity_type = e.get("type") break except Exception as e: logger.warning(f"[{file_name}] 文件名实体提取失败: {e}") fn_entities, fn_relations = [], [] # ── 2. 正文实体抽取 ────────────────────────────────────────────────── check = check_filename_ocr(file_name) if check == "ocr_true": entities, relations = process_file_content( file_name=file_name, file_path=file_path, task_id="batch", data_dir=os.path.dirname(file_path), ontology=ONTOLOGY, ontology_desc=ONTOLOGY_DESC, relationships=RELATIONSHIPS, node_types=NODE_TYPES, relationship_types=RELATIONSHIP_TYPES, prefix_url=PREFIX_URL, api_url=api_url, check_cancelled_callback=lambda: False, device=lower_entity_device, device_type=lower_entity_type, ) elif check == "ocr_false": async def _run_filtertext(): return await get_filtertext_node_relation( input_pdf_path=file_path, data=[], prefix_url=PREFIX_URL, device=lower_entity_device, ) result = asyncio.run(_run_filtertext()) if isinstance(result, tuple) and len(result) >= 2: entities, relations = result[0], result[1] else: entities, relations = [], [] logger.info("无需进行ocr解析,直接构建实体") else: logger.warning(f"[{file_name}] check_filename_ocr 返回 None,跳过正文抽取") # ── 3. 合并文件名实体 ──────────────────────────────────────────────── all_entities = entities + fn_entities all_relations = relations + fn_relations # 过滤掉没有名称的实体 all_entities = [e for e in all_entities if e.get("properties", {}).get("名称")] # ── 4. 保存 JSON ───────────────────────────────────────────────────── os.makedirs(output_dir, exist_ok=True) payload = {"entities": all_entities, "relationships": all_relations} with open(out_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) cost = time.time() - t0 logger.info(f"[OK] {file_name} 实体:{len(all_entities)} 关系:{len(all_relations)} 耗时:{cost:.1f}s") return { "file": file_name, "status": "ok", "entity_count": len(all_entities), "rel_count": len(all_relations), } except Exception as e: logger.error(f"[ERROR] {file_name}: {e}\n{traceback.format_exc()}") return {"file": file_name, "status": "error", "error": str(e)} def _extract_worker(file_path: str, output_dir: str, result_queue: Queue, api_url: str = MINERU_INSTANCES[0]): """子进程入口:抽取单文件,结果放入队列""" try: result = extract_single_file(file_path, output_dir, api_url) result_queue.put(result) except Exception as e: result_queue.put({"file": os.path.basename(file_path), "status": "error", "error": str(e)}) def _run_file_with_timeout(args: Tuple) -> Dict: """在线程中启动子进程并监控超时,实现滚动窗口并发。""" fp, output_dir, inst = args file_name = os.path.basename(fp) q = Queue() p = Process(target=_extract_worker, args=(fp, output_dir, q, inst)) p.start() p.join(timeout=EXTRACT_TIMEOUT) if p.is_alive(): p.kill() p.join() logger.warning(f"[TIMEOUT] {file_name} 超过 {EXTRACT_TIMEOUT}s,已跳过") _record_fault(file_name) return {"file": file_name, "status": "timeout"} try: return q.get_nowait() except Exception: return {"file": file_name, "status": "error"} def phase_extract(folder: str, output_dir: str, workers: int): """阶段1:滚动窗口多进程抽取,每完成一个立即补充下一个。""" files = [ os.path.join(folder, f) for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f)) and os.path.splitext(f)[1].lower() in SUPPORTED_EXT ] logger.info(f"发现 {len(files)} 个文件,并发数: {workers}") os.makedirs(output_dir, exist_ok=True) tasks = [ (fp, output_dir, MINERU_INSTANCES[i % len(MINERU_INSTANCES)]) for i, fp in enumerate(files) ] with ThreadPoolExecutor(max_workers=workers) as executor: results = list(executor.map(_run_file_with_timeout, tasks)) ok = sum(1 for r in results if r["status"] == "ok") skip = sum(1 for r in results if r["status"] == "skip") error = sum(1 for r in results if r["status"] == "error") timeout = sum(1 for r in results if r["status"] == "timeout") logger.info(f"抽取完成 成功:{ok} 跳过:{skip} 失败:{error} 超时:{timeout}") return results # ════════════════════════════════════════════════════════════════════════════ # 阶段 2:汇总 → 去重 → Embedding → 写 Neo4j # ════════════════════════════════════════════════════════════════════════════ def phase_merge(output_dir: str): """阶段2:读所有 JSON → 去重 → 向量化 → 写 Neo4j""" # ── 1. 读取所有抽取结果 ────────────────────────────────────────────────── all_entities: List[Dict] = [] all_relations: List[Dict] = [] json_files = [f for f in os.listdir(output_dir) if f.endswith(".json")] logger.info(f"读取 {len(json_files)} 个抽取结果文件") for fname in json_files: fpath = os.path.join(output_dir, fname) try: with open(fpath, "r", encoding="utf-8") as f: data = json.load(f) all_entities.extend(data.get("entities", [])) all_relations.extend(data.get("relationships", [])) except Exception as e: logger.warning(f"读取失败 {fname}: {e}") logger.info(f"汇总: 实体 {len(all_entities)} 关系 {len(all_relations)}") # ── 2. 去重(filter_node_relationship 只跑这一次) ─────────────────────── merged_input = {"entities": all_entities, "relationships": all_relations} logger.info("开始全局去重(filter_node_relationship)...") t0 = time.time() try: filter_data = filter_node_relationship( URI=NEO4J_URI, USERNAME=NEO4J_USER, PASSWORD=NEO4J_PASSWORD, merged_output_data=merged_input, ) except Exception as e: logger.error(f"去重失败,使用原始数据: {e}") filter_data = merged_input logger.info(f"去重完成 实体:{len(filter_data['entities'])} 关系:{len(filter_data['relationships'])} 耗时:{time.time() - t0:.1f}s") # ── 3. 生成 Embedding(并行) ──────────────────────────────────────────── logger.info(f"生成 Embedding(并发 {EMBED_WORKERS} 线程)...") t0 = time.time() def _embed_one(entity: Dict) -> int: name = str(entity.get("properties", {}).get("名称", "") or "") if not name or "未知" in name: return 0 try: entity["properties"]["embedding"] = OpenaiAPI.generate_embedding(name) return 1 except Exception as e: logger.warning(f"Embedding 失败 [{name}]: {e}") return 0 with ThreadPoolExecutor(max_workers=EMBED_WORKERS) as executor: embedded = sum(executor.map(_embed_one, filter_data["entities"])) logger.info(f"Embedding 完成 {embedded} 个实体 耗时:{time.time() - t0:.1f}s") # ── 3.5 本地保存带 Embedding 的实体关系 ────────────────────────────────── snapshot_path = os.path.join(os.path.dirname(output_dir) if output_dir != "/" else "/app", "kg_snapshot.json") try: with open(snapshot_path, "w", encoding="utf-8") as f: json.dump(filter_data, f, ensure_ascii=False, indent=2) logger.info(f"本地快照已保存: {snapshot_path} 实体:{len(filter_data['entities'])} 关系:{len(filter_data['relationships'])}") except Exception as e: logger.warning(f"本地快照保存失败: {e}") # ── 4. 写入 Neo4j ───────────────────────────────────────────────────────── logger.info("写入 Neo4j...") # ontology, ontology_desc, node_types = _build_ontology_config() # 从实体数据中提取动态类型,追加到静态 ontology 末尾(静态配置优先,数据类型补充) data_types = list(dict.fromkeys( e["type"] for e in filter_data["entities"] if e.get("type") )) merged_ontology = list(dict.fromkeys( data_types)) # new_types = [t for t in data_types if t not in set(ontology)] # if new_types: # logger.info(f"从实体数据补充新类型 {len(new_types)} 个: {new_types}") t0 = time.time() kg = MilitaryKnowledgeGraph( uri=NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD), Ontology=merged_ontology, ) try: entity_count, rel_count = kg.process_data(filter_data) logger.info(f"Neo4j 写入完成 实体:{entity_count} 关系:{rel_count} 耗时:{time.time() - t0:.1f}s") finally: kg.close() def phase_load_snapshot(snapshot_path: str): """ Directly read kg_snapshot.json and store entities/relations into Neo4j. Skips extraction and embedding generation steps. """ if not os.path.exists(snapshot_path): logger.error(f"Snapshot file not found: {snapshot_path}") return logger.info(f"Loading snapshot from: {snapshot_path}") t0 = time.time() try: with open(snapshot_path, "r", encoding="utf-8") as f: filter_data = json.load(f) entities = filter_data.get("entities", []) relations = filter_data.get("relationships", []) logger.info(f"Loaded snapshot: {len(entities)} entities, {len(relations)} relations") if not entities and not relations: logger.warning("Snapshot contains no data to load.") return # Extract dynamic types from entities to ensure Ontology coverage data_types = list(dict.fromkeys( e["type"] for e in entities if e.get("type") )) merged_ontology = list(dict.fromkeys(data_types)) logger.info(f"Initializing MilitaryKnowledgeGraph with ontology types: {merged_ontology[:5]}...") # Log first few for brevity kg = MilitaryKnowledgeGraph( uri=NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD), Ontology=merged_ontology, ) try: entity_count, rel_count = kg.process_data(filter_data) elapsed = time.time() - t0 logger.info(f"Neo4j write completed from snapshot. Entities: {entity_count}, Relations: {rel_count}. Time: {elapsed:.1f}s") finally: kg.close() except json.JSONDecodeError: logger.error(f"Failed to decode JSON from {snapshot_path}") except Exception as e: logger.error(f"Error loading snapshot: {e}", exc_info=True) # ════════════════════════════════════════════════════════════════════════════ # 入口 # ════════════════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser(description="批量知识图谱构建") parser.add_argument("--folder", default="", help="待处理文件夹(阶段1必填)") parser.add_argument("--output_dir", default="/app/kg_output", help="JSON 输出/读取目录") # Update choices to include 'load_snapshot' parser.add_argument("--phase", default="all", choices=["extract", "merge", "load_snapshot", "all"], help="extract=只抽取 merge=只入库(含抽取结果汇总) load_snapshot=直接加载快照 all=全流程") parser.add_argument("--workers", type=int, default=1, help="阶段1并发线程数") parser.add_argument("--snapshot_path", default="", help="快照文件路径 (用于 load_snapshot 阶段)") args = parser.parse_args() # Determine default snapshot path if not provided and phase is load_snapshot if args.phase == "load_snapshot" and not args.snapshot_path: # Default to the location where phase_merge saves it args.snapshot_path = os.path.join(os.path.dirname(args.output_dir) if args.output_dir != "/" else "/app", "kg_snapshot.json") if args.phase in ("extract", "all"): if not args.folder: parser.error("--phase extract/all 需要指定 --folder") phase_extract(args.folder, args.output_dir, args.workers) if args.phase in ("merge", "all"): phase_merge(args.output_dir) if args.phase == "load_snapshot": phase_load_snapshot(args.snapshot_path) if __name__ == "__main__": main() # ontology, ontology_desc, node_types = _build_ontology_config() # print(ontology)