423 lines
19 KiB
Python
423 lines
19 KiB
Python
#!/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 --folder /app/files --output_dir /app/kg_output --phase all --workers 4
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import logging
|
||
import argparse
|
||
import asyncio
|
||
import traceback
|
||
import time
|
||
from datetime import datetime
|
||
from multiprocessing import Process, Queue
|
||
from typing import List, Dict, Tuple, Any, Optional
|
||
|
||
EXTRACT_TIMEOUT = 600 # 单文件抽取超时秒数
|
||
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}")
|
||
|
||
# ── 项目内模块 ──────────────────────────────────────────────────────────────
|
||
from extract_text_node_relation import process_file_content, check_filename_ocr
|
||
from kg_build.extract_filtertext import get_filtertext_node_relation
|
||
from filename_proceess_and_kgquery import get_entity
|
||
from kg_build.filter_node_relation import filter_node_relationship
|
||
from kg_build.kg_save import MilitaryKnowledgeGraph
|
||
from modelsAPI.model_api import OpenaiAPI
|
||
import importlib
|
||
import entity_registry as _entity_registry
|
||
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,
|
||
)
|
||
|
||
|
||
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://localhost:7687")
|
||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "zdht123@")
|
||
MINERU_ANALYZE = os.getenv("MINERU_ANALYZE", "http://192.168.1.64:18000/analyze-pdf")
|
||
MINERU_CACHE = os.getenv("MINERU_CACHE", "http://192.168.1.64:18000/get_content_list")
|
||
PREFIX_URL = os.getenv("PREFIX_URL", "http://192.168.1.64:9085")
|
||
|
||
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) -> Dict:
|
||
"""
|
||
对单个文件做实体/关系抽取,结果保存到 output_dir/<filename>.json。
|
||
已存在 JSON 则跳过(断点续跑)。
|
||
返回 {"file": ..., "status": "ok"/"skip"/"error", "entity_count": ..., "rel_count": ...}
|
||
"""
|
||
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 = MINERU_ANALYZE,
|
||
check_cancelled_callback = lambda: False,
|
||
device = lower_entity_device,
|
||
device_type = lower_entity_type,
|
||
get_contentlist_url = MINERU_CACHE,
|
||
)
|
||
|
||
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 = [], []
|
||
|
||
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):
|
||
"""子进程入口:抽取单文件,结果放入队列"""
|
||
try:
|
||
result = extract_single_file(file_path, output_dir)
|
||
result_queue.put(result)
|
||
except Exception as e:
|
||
result_queue.put({"file": os.path.basename(file_path), "status": "error", "error": str(e)})
|
||
|
||
|
||
def phase_extract(folder: str, output_dir: str, workers: int):
|
||
"""阶段1:多进程抽取,每个文件独立进程,超时后真正 kill"""
|
||
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}")
|
||
|
||
results = []
|
||
# 按 workers 大小分批,每批同时跑 workers 个进程
|
||
for i in range(0, len(files), workers):
|
||
batch = files[i:i + workers]
|
||
procs = []
|
||
for fp in batch:
|
||
q = Queue()
|
||
p = Process(target=_extract_worker, args=(fp, output_dir, q))
|
||
p.start()
|
||
procs.append((p, fp, q))
|
||
|
||
for p, fp, q in procs:
|
||
file_name = os.path.basename(fp)
|
||
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)
|
||
results.append({"file": file_name, "status": "timeout"})
|
||
else:
|
||
try:
|
||
results.append(q.get_nowait())
|
||
except Exception:
|
||
results.append({"file": file_name, "status": "error"})
|
||
|
||
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("生成 Embedding...")
|
||
t0 = time.time()
|
||
embedded = 0
|
||
for entity in filter_data["entities"]:
|
||
name = entity.get("properties", {}).get("名称", "")
|
||
if not name or "未知" in name:
|
||
continue
|
||
try:
|
||
entity["properties"]["embedding"] = OpenaiAPI.generate_embedding(name)
|
||
embedded += 1
|
||
except Exception as e:
|
||
logger.warning(f"Embedding 失败 [{name}]: {e}")
|
||
logger.info(f"Embedding 完成 {embedded} 个实体 耗时:{time.time()-t0:.1f}s")
|
||
|
||
# ── 4. 写入 Neo4j ─────────────────────────────────────────────────────────
|
||
logger.info("写入 Neo4j...")
|
||
t0 = time.time()
|
||
kg = MilitaryKnowledgeGraph(
|
||
uri = NEO4J_URI,
|
||
auth = (NEO4J_USER, NEO4J_PASSWORD),
|
||
Ontology = 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 main():
|
||
parser = argparse.ArgumentParser(description="批量知识图谱构建")
|
||
parser.add_argument("--folder", default="", help="待处理文件夹(阶段1必填)")
|
||
parser.add_argument("--output_dir", default="/app/kg_output", help="JSON 输出/读取目录")
|
||
parser.add_argument("--phase", default="all", choices=["extract", "merge", "all"],
|
||
help="extract=只抽取 merge=只入库 all=全流程")
|
||
parser.add_argument("--workers", type=int, default=1, help="阶段1并发线程数")
|
||
args = parser.parse_args()
|
||
|
||
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)
|
||
|
||
|
||
def _fake_worker(file_path: str, output_dir: str, result_queue: Queue):
|
||
"""测试用子进程:file_B 睡 20s 模拟超时,其余 2s"""
|
||
file_name = os.path.basename(file_path)
|
||
sleep_time = 20 if file_name == "file_B" else 2
|
||
time.sleep(sleep_time)
|
||
out_path = os.path.join(output_dir, file_name + ".json")
|
||
with open(out_path, "w", encoding="utf-8") as f:
|
||
json.dump({"file": file_name}, f)
|
||
result_queue.put({"file": file_name, "status": "ok"})
|
||
|
||
|
||
def _test_timeout():
|
||
"""
|
||
模拟超时跳过逻辑,验证完删掉此函数及 _fake_worker、下面的调用。
|
||
file_A 正常(2s),file_B 超时(20s),file_C 正常(2s)
|
||
期望总耗时约 TIMEOUT+2s,file_B 不写 JSON
|
||
"""
|
||
import tempfile
|
||
|
||
global EXTRACT_TIMEOUT
|
||
EXTRACT_TIMEOUT = 5 # 测试用 5 秒
|
||
|
||
output_dir = tempfile.mkdtemp()
|
||
logger.info(f"测试输出目录: {output_dir}")
|
||
|
||
files = ["file_A", "file_B", "file_C"]
|
||
results = []
|
||
t_start = time.time()
|
||
|
||
for i in range(0, len(files), 2):
|
||
batch = files[i:i + 2]
|
||
procs = []
|
||
for f in batch:
|
||
q = Queue()
|
||
p = Process(target=_fake_worker, args=(f, output_dir, q))
|
||
p.start()
|
||
procs.append((p, f, q))
|
||
|
||
for p, f, q in procs:
|
||
p.join(timeout=EXTRACT_TIMEOUT)
|
||
if p.is_alive():
|
||
p.kill()
|
||
p.join()
|
||
logger.warning(f"[TIMEOUT] {f} 超时,进程已 kill,不写 JSON")
|
||
_record_fault(f)
|
||
results.append({"file": f, "status": "timeout"})
|
||
else:
|
||
results.append(q.get_nowait())
|
||
|
||
logger.info(f"总耗时: {time.time()-t_start:.1f}s")
|
||
logger.info(f"结果: {results}")
|
||
logger.info(f"output_dir 中的文件: {os.listdir(output_dir)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_test_timeout()
|
||
# main()
|
||
|
||
|