# Graph Engine 内部设计

Graph Engine 为数据分析提供可计算的实体关系表示与结构化证据：解析数据、查询和算法后端、检索索引、图学习，以及并发任务中的版本一致性。

## 模块分层

Graph Engine 是图驱动分析的表示与计算层。智能体提出具体证据需求，Graph Engine 按工具参数访问当前图或指定数据集，执行查询、算法或学习任务，再返回可定位的对象、数值、结构和状态。构图子智能体在需要时准备或优化这些表示；已有图上的读取、统计和学习不依赖每次重新执行构图。

![Graph Engine 执行路径](/diagrams/engine-zh.svg)

Graph Engine 对外提供 HTTP 与 MCP 两种适配器。Backend 通过 HTTP 访问数据与任务；智能体通过 MCP 调用图工具。graph_engine 是一级模块入口，graph_data_service 承载 HTTP、图数据与计算实现，mcp_service 负责工具协议适配。它们共享工具规格和资源读写策略。

| 层 | 模块 | 责任 |
| --- | --- | --- |
| 协议与目录 | graph_engine/api.py · mcp.py · tool_catalog.py | 统一入口、工具 JSON Schema、执行策略元信息。 |
| 数据与版本 | graph_data_service/graph_data.py · session.py | 当前图、稳定节点/关系标识、版本切换和派生缓存。 |
| 执行调度 | graph_engine/tool_execution.py · graph_data_service/tool_scheduler.py | 资源隔离、读写冲突、排队与调度和生命周期。 |
| 构图子智能体 | graph_engine/construction/ | 源数据解析、查询工作负载、候选转换、成本验收与领域经验。 |
| 图计算与 ML | graph_data_service/compute.py · isolated_compute.py · graph_ml/ | 算法后端、隔离工作进程、训练/推理与模型文件。 |

HTTP 工具转发使用 POST /sessions/{session_id}/tools/{tool_name}，请求体为 {"arguments": {...}}。GraphDataClient 注入用户、会话、Run、generation 和操作身份；这是服务间入口，浏览器使用 Backend 公共 API。构图工具另有数据集作用域适配。

## 图结果如何成为分析证据

图结果需要同时保留数值或对象本身，以及解释它们所需的上下文。节点名称、关系方向、边权含义、图版本和返回范围共同决定一条结果能支持什么说明。HTTP 或 MCP 请求成功不等于业务成功；工具结果中的 success、错误、截断标记和作业终态都参与后续判断。

| 证据层次 | 相关工具 | 解释前需要核对 |
| --- | --- | --- |
| 图与对象身份 | get_graph_info / search_node / get_node_info | 已加载状态、graph_version、真实名称与类型，以及候选是否歧义。 |
| 连接与结构 | shortest_path / graph_connectivity / node_centrality / community_detection | 方向、权重解释、图是否完整、所选算法及多重边投影规则。 |
| 模型结果 | train / infer | model_id、任务、特征来源、输入图或数据集、评估划分及完成状态。 |
| 呈现与报告 | visualize_subgraph / create_analysis_report | 展示节点是否截断，报告 source_results 是否来自已完成操作；视图和报告不改变原始证据含义。 |

版本与资源调度帮助同一批相关操作围绕正确的输入执行，但不能消除数据缺失或分析方法选择不当带来的限制。例如，有界查询范围内未找到路径不能证明全图不可达，社区分组不能直接证明业务类别，链接预测分数不能充当因果关系证据。后续各节分别说明这些数据与算法契约。

create_analysis_report 返回用于界面展示的 analysis_report.v2 报告结构，不会自动生成 Markdown 或 PDF 文件。需要下载文件时，由 Agent Runtime 工作区工具在 Ephemeral 中生成内容，再用 workspace_publish(path=..., name=...) 发布到 Derived；显式指定带扩展名的 name。这些工作区工具不属于 Graph Engine 的 72 项默认工具目录。

## 图表示与关系标识

GraphData 是只读图容器。节点在计算侧使用连续整数索引，_id2node / _node2id 保存业务节点名称与整数索引的双向映射。边以 edge_index 的两行表示源和目标；节点、边的特征、类型与标签保存在独立数组中。写入流程生成新图对象或版本，而不是就地修改分析中的图。

| 字段 | 形状 / 类型 | 含义 |
| --- | --- | --- |
| edge_index | [2, E] · int64 | 关系的源与目标节点索引。 |
| edge_weight | [E] · float32 / float64 | 可选边权；构图属性图保留 float64 数值。 |
| node_feat / edge_feat | [N, …] / [E, …] | 模型输入特征；与语义属性分离。 |
| node_type / edge_type | [N] / [E] | 异构节点与关系类型。 |
| node_label / edge_label | [N] / [E] or multi-column | 分类等监督任务的标签。 |
| graph_metadata.construction_graph | construction_graph.v1 | 保留节点/边 ID、属性及构图语义。 |

属性图可以在相同端点之间保留多条关系。查询边时，端点对与唯一 edge_id 不等价：端点只确定一组候选关系，修改或定位具体关系应使用返回的 ID。通用 append 对已构建属性图返回 UNSUPPORTED_PROPERTY_GRAPH_APPEND，避免把不同关系和属性压缩成普通边表。

构造 GraphData 时先把 edge_index 转成 int64，并要求它是两行数组；节点名称与整数索引的映射必须成对提供且彼此一致。特征默认转成 float32，训练/验证/测试掩码转成布尔数组。node_feat 的第 i 行、node_label 的第 i 项及 id2node[i] 都指向同一节点，不能分别重排。边属性也必须和 edge_index 的列顺序对齐。

```python
import numpy as np
from graph_data_service.graph_data import GraphData

# Three named nodes and three directed edges.
graph = GraphData(
    num_nodes=3,
    edge_index=np.array([[0, 0, 1], [1, 2, 2]], dtype=np.int64),
    id2node={0: "customer:10", 1: "order:100", 2: "region:1"},
    node2id={"customer:10": 0, "order:100": 1, "region:1": 2},
    edge_weight=np.array([2.0, 5.0, 1.0]),
    semantic_type="directed",
)
# Columns represent customer:10 -> order:100,
# customer:10 -> region:1, and order:100 -> region:1.
```

上例说明数组与节点名称如何对齐，不代表电商外键的默认方向。对源表构图时，记录节点与外键方向由构图模块决定；手工导入图则应由调用方明确边的业务含义。

图的业务属性与模型特征分开存储。properties.data 中的订单金额、字符串状态等不会自动变成 node_feat。单图学习缺少 node_feat 时使用归一化的 [入度, 出度, 总度, 1] 结构特征；这不等于模型读取了业务字段。图分类另使用约定数据集中的节点类别编号。构图图对象将原始属性图保存在 graph_metadata.construction_graph 中，转换、切换版本及查询时据此保留属性和关系标识。

## 图的方向与边的存储方式

判断“是否有向”需要读取业务语义，不能只检查邻接数据是否对称。例如，两个账号互相关注是两条有向关系；一条无向关系也可能以正反两条记录保存。Graph Engine 用 semantic_type 表示图语义，用 storage_type 表示边记录的实际组织方式。

| 字段 | 取值 | 解释 |
| --- | --- | --- |
| semantic_type | directed / undirected / unknown | 分别表示有向、无向和缺少足够证据；显式设置优先于数据结构推断。 |
| storage_type | directed / bidirectional / mixed | 表示单向存储、成对双向存储或混合存储；不是第二套业务关系类型。 |
| num_edge_entries / num_unique_edges | 原始记录数 / 逻辑边数 | 前者是 edge_index 列数，后者按方向语义及关系标识统计。 |
| has_self_loops / has_duplicate_entries | 结构检查标志 | 识别自环及重复条目。重复判定使用端点与关系键，不逐项比较权重、特征或属性；构图属性图使用关系 ID，普通图使用关系类型作为关系键。 |

方向解析依次考虑显式 semantic_type、元数据、预定义数据集规则及结构证据。对称结构在没有其他信息时仍可能是 unknown。Cora、CiteSeer、PubMed 使用预定义的无向语义。to_undirected 产生新图，并可能在数组中补齐反向记录；计算逻辑边数或节点度时应先去除这类存储副本。

```text
edge_index = [[0, 1], [1, 0]]

semantic_type = "directed"   -> two directed edges
semantic_type = "undirected" -> one logical undirected edge
no semantic evidence        -> symmetry alone is ambiguous
```

节点度与邻居数也不同。无向图中，一个自环对节点度贡献 2，但只对应一个不同的邻居节点；有向图分别统计入度、出度和总度。多个关系可以连接同一个邻居，因此不能用去重后的邻居数量替代关系条数。

## 图版本与缓存

会话中的加载、无向转换、子图提取和回滚改变当前图版本。get_graph_info 可读取 graph_version；list_graph_versions 列出版本，load_graph_version 切换已保存版本。构图数据集另有 revision：只有显式 load_constructed_graph 后，构图结果才成为会话活动图。

算法需要的 NetworkX 表示和实体检索索引是派生数据。按图版本复用缓存可避免同一图被每个工具重复转换；版本变化后必须使用对应的新表示。缓存重建属于图读取，不应发布图版本。大图第一次转换仍有与节点、边规模相关的成本。

版本按会话隔离，图数据存为 NPZ，graph_versions.json 记录清单。普通 save_snapshot 路径按 GRAPH_VERSION_HISTORY_LIMIT 裁剪历史，默认 50；受控 publish_snapshot 路径目前不执行该裁剪。切回旧版本后，新版本号仍在历史最大值之后递增。隔离工作进程写入暂存目录，Graph Engine 父进程检查 generation 与 graph_version 后原子发布清单，并记录 Run、Job 与 generation 回执，避免过期执行覆盖新的状态。

图对象、算法转换缓存与搜索索引均以版本为边界。GraphCompute 缓存 NetworkX 表示，EntitySearchIndex 保存创建时的 graph_version；版本不一致时重建。只读缓存的创建不会增加图版本号。请求应先读取实际 graph_version，再把该值记录在实验或分析结果中，避免把不同版本的指标直接比较。

构图修订号 revision、会话 graph_version 与运行 execution_generation 各自解决不同问题：revision 防止数据集编辑覆盖较新的修改；graph_version 选择算法输入；execution_generation 防止已停止的旧运行回写结果。只提供其中一个编号，不能替代其他两类校验。

## 多重边与算法语义

| 算法 | 关系投影规则 | 原因 |
| --- | --- | --- |
| 最短路径 / 接近中心性 | minimum_weight_per_pair | 平行边取最小代价，路径结果按节点序列区分。 |
| 最大流 / s-t 最小割 | sum_weights_per_pair | 同一方向平行边的容量累加。 |
| PageRank / 特征向量 / Katz | sum_weights_per_pair | 累积连接强度。 |
| 最小生成树 | minimum_weight_undirected | 按无向端点对保留最小权重。 |
| Louvain / greedy modularity | sum_weights_undirected | 合并无向连接强度。 |
| 未审查的多重图算法 | unsupported | 明确拒绝，避免隐式丢失关系语义。 |

边权必须是有限数值，布尔值和非数值不能作为合法权重。不同算法对权重的意义不同：路径使用代价，流/割使用容量，社区和部分中心性使用强度；调用前应先明确业务解释。

转换到 NetworkX 时，普通图沿用简单图表示；构图属性图若同一端点间存在不同关系标识，则使用 MultiGraph 或 MultiDiGraph。无向图同一业务关系的反向存储副本会去重。业务 edge_id 保存在属性中，NetworkX 的内部多重边键使用边索引，因此二者不能混用。

假设 A→B 有两条平行边，权重分别为 2 和 5。最短路径把它们解释为两个可选代价，投影后使用 2；最大流把它们解释为并行容量，合计为 7。这两种结果都遵循各自的算法定义。调用方必须先确定权重含义，再选择工具，不能把交易金额默认当作距离。

未声明多重图策略的方法返回 UNSUPPORTED_MULTIGRAPH_OPERATION。参数组合也参与检查：社区发现只批准了部分算法，子图提取中的 ego_graph 与 node_subset 可以保留关系，其余方法有各自限制。这个检查发生在算法执行前，避免转换时悄悄合并关系。

## 工具依赖与并发控制

每个工具声明 reads、writes、barrier、background。资源还要加上用户/会话归属：graph、models、documents 等通常按会话隔离，datasets 与 construction:{dataset_id} 按用户或数据集隔离。RAW、WAR、WAW 冲突形成有向依赖，独立读操作可以并行；未知工具按 barrier 保守执行。

```text
load_constructed_graph (writes graph)
  ├─ node_centrality       (reads graph)
  ├─ community_detection  (reads graph)
  └─ shortest_path        (reads graph)
       ↓ all finished
load_graph_version        (writes graph)
```

subgraph_extraction 默认写入新版本，只有 create_version=false 才按只读调度。前置工具失败或等待审批时，依赖项返回 TOOL_DEPENDENCY_BLOCKED。模型必须在得到真实结果后再传入依赖它的 ID 或 revision；依赖调度不会补造参数。

智能体侧的 tool_dag 调度同轮调用，Graph Engine 的 tool_scheduler 控制跨请求资源调度。后台任务提交后，资源租约持续到实际工作结束；仅返回 job_id 不能释放租约。状态查询和取消绕开图写锁等待，防止任务无法停止。

跨请求调度保留冲突请求的到达顺序。假设读取 R1 已进入执行，写入 W2 随后排队，新的读取 R3 不能绕过 W2；否则持续到来的读请求会使写请求长期无法执行。不同用户的资源互不冲突，同一用户的不同会话仍可能争用 datasets 或 construction:{dataset_id} 这类共享资源。

资源依赖和计算配额是两层控制。依赖检查决定是否会读取正在变化的数据；信号量决定最多同时运行多少普通计算或训练任务。即使两个训练任务只读同一张图，也可能因 GPU、随机数状态或用户计算配额而串行执行。

## 计算进程与后台任务

NetworkX 算法的兼容路径使用有界 ProcessPool，其他同步处理器可走线程执行。受控执行在具备 execution host 与 Run 身份时使用可终止的隔离 WorkerProcess，维护停止确认与最终状态持久化。取消观察不等于计算停止；容量与租约在任务真正退出后回收。

| 配置 | 默认值 | 控制对象 |
| --- | --- | --- |
| AGENT_TOOL_MAX_CONCURRENCY | 4 (1–32) | 同轮就绪工具调用。 |
| GRAPH_COMPUTE_CONCURRENCY | min(4, cpu_count) | 普通算法进程池。 |
| GRAPH_COMPUTE_GLOBAL_CONCURRENCY | 默认跟随计算 worker 数 | 全局普通计算并发限制。 |
| GRAPH_COMPUTE_PER_USER_CONCURRENCY | min(4, worker 数) | 单用户普通计算并发限制。 |
| GRAPH_HEAVY_PER_USER_CONCURRENCY | 1 | 训练、推理等重任务。 |
| GRAPH_HEAVY_GLOBAL_CONCURRENCY | max(1, min(2, worker 数)) | 全局重任务并发限制。 |

get_job_status 返回作业状态与进度，普通作业完成结果位于 job.result；也可通过 get_job_result 读取结果。GraphMCPTools 在图工具返回后台句柄时等待最终结果，支持嵌套作业，并拒绝循环引用或缺失结果。监督学习的 infer 加载已训练模型执行前向计算，预训练的 infer 查询保存的节点向量。图分类数据绑定与当前单图绑定分开管理。

```json
{
  "success": true,
  "job": {
    "job_id": "JOB_ID",
    "status": "done",
    "progress": 1,
    "result": {
      "success": true
    }
  },
  "progress_updates": []
}
```

此结构展示 get_job_status 的字段位置；最终业务结果位于 job.result。状态值和 result 的具体字段由实际任务决定，轮询可用 after_seq 增量读取进度。

## 实体检索与索引

search_node 与 search_edge 用于把用户描述解析为可传给图工具的节点或边标识。节点索引包含名称、类型、标签、入度、出度及邻居预览；语义文本优先来自 node_text，没有文本时使用名称、类型和标签。索引不会自动把 properties.data 中全部业务字段转成可检索文本。

实体语义检索依赖 Graph Engine 自己的嵌入服务配置：EMBEDDING_PROVIDER 可显式选择 sentence-transformers、openai 或 lexical。默认先尝试本地 Sentence Transformers（需要依赖与模型权重），失败后在存在 OPENAI_API_KEY 时选择 OpenAI，否则使用词法哈希。当前 OpenAI 分支调用固定的 api.openai.com/v1/embeddings；Agent Runtime 的聊天模型或 EMBEDDING_BASE_URL 配置不会自动改写这个地址。词法模式不为实体查询提供语义相似度排序；索引编码失败时也会禁用语义检索，应检查 semantic_enabled 与检索错误信息。 显式选择 openai 时必须提供 OPENAI_API_KEY；显式提供方初始化失败不会执行上述自动选择。首次实体索引会编码全图节点与边文本，远程服务的可达性、延迟和额度也影响可用性。

| 阶段 | 实现规则 |
| --- | --- |
| 精确匹配 | 先检查业务名称是否命中 node2id，再尝试有效整数节点索引；边可按边名称或明确端点定位。 |
| 结构过滤 | 节点支持 node_type、label 和入度/出度/总度上下界；边支持 edge_type、label 与权重上下界。 |
| 向量评分 | 可用嵌入服务生成 float32 向量，按归一化向量内积排序；默认 top_k=8，最大 50。 |
| 查询缓存 | 查询向量按查询字符串缓存，最多 128 项；向量生成在缓存锁外执行。 |
| 结果歧义 | 除 best 外还返回 candidates、ambiguous 与检索模式；多个相近候选不能只凭排序第一项确定实体。 |

当前置信度是规则分级，不是概率校准结果。精确命中对应 high；非精确查询短于 3 个字符会标记歧义；候选分数差小于 0.05 时也可能标记歧义。semantic_enabled=false 时，普通实体检索回退到精确定位和结构过滤，未匹配项的语义得分为 0，不能解释为仍在做语义相似度排序。

```json
{
  "query": "",
  "filters": {
    "node_type": "customer",
    "min_degree": 2
  },
  "sort": {
    "field": "degree",
    "order": "desc"
  },
  "top_k": 10
}
```

这是按客户类型和度数筛选候选的参数示例。不同数据集的真实 node_type 值应先通过图概览或节点信息确认。rerank 当前用于返回配置状态，没有单独运行交叉编码器重排序模型。遇到 ambiguous=true 时，可补充类型、端点或节点 ID 后重新检索。

平行关系检索还有一项限制：search_edge 按精确端点定位时只取得最后一条关系，索引中的属性读取也通过端点查找，并不完整枚举同端点关系。逐条核对和编辑应以 get_constructed_graph 返回的真实 edge_id 为准，不能仅凭 best 或 ambiguous=false 确认关系唯一。

## 文档分块与上下文检索

文档检索由 LightweightRAG 管理，和图节点检索使用不同索引。upload_document 把会话文档写入文档目录，保存 doc_index.json 与 doc_embeddings.npy；retrieve_context 根据查询向量返回相关文本块。文档命中说明文本相关，不等于图中存在相应关系，分析结论仍需图工具提供证据。

默认分块长度是 512 个按空白划分的片段，相邻块重叠 128 个片段，步长为 384。这不是模型分词器的 token 数；没有空格的长中文段落不会按汉字数量切成 512 字的块。准备中文资料时应保留合理分段，并检查返回 chunks 的实际数量和内容长度。

嵌入提供方可以是本地 Sentence Transformers、远程嵌入接口或词法哈希。文档检索在词法模式下使用 384 维哈希向量；Python 哈希受进程影响，因此旧文本块会在当前进程重新编码，避免把不同进程的哈希空间混合。向量维度改变时，检索返回错误，需重建该会话文档索引。

受控文档写入通过 .rag_versions 保存候选目录，再由 rag_manifest.json 选择当前版本。这样读请求可以定位一套完整的文本索引和向量文件。返回结果中的 doc_id、chunk_id、offset 与 score 用于定位引用片段；offset 是块序号，不是原文字符偏移。

## 存储与扩展边界

内存表示至少包含 [2,E] 的 int64 边索引，即约 16E 字节；普通 float32 边权另需 4E 字节，N 个节点、F 维 float32 特征另需 4NF 字节。这只是数组主体，不含字符串、Python 对象、图属性、NetworkX 对象、向量索引和工作进程副本。评估大图内存时，不能只按源文件大小估算。

结构化大图还可以走独立的分区快照路径：prepare_large_graph 流式准备已上传的边列表或三元组文件，run_large_graph_algorithm 在发布版本上提交持久化作业。该路径不会先创建完整 GraphData 或 NetworkX 图。运行前读取 get_large_graph_capabilities，核对算法的精确、近似或有界语义及资源限制；完成后获取摘要、预览和结果文件引用。分区版本与普通活动图表示有独立的读取入口，不能把大图预览当作已加载的完整会话图。

图服务当前通过 GRAPH_STORAGE_BACKEND 选择 local 或 nebula。NebulaGraph 模式需要显式启用连接；部分查询在数据库端执行，需要 NetworkX 的算法则先按能力和规模限制载入内存。默认载入上限为 50,000 个节点、200,000 条边，分别通过 NEBULA_MAX_MATERIALIZE_NODES 和 NEBULA_MAX_MATERIALIZE_EDGES 配置。materialized_scope、target_in_scope、complete_for_query 用于说明结果范围；有限跳数范围内未找到目标，不能直接解释为全图不可达。

Graph Engine 管理源文件、图版本、构图报告、领域经验和模型文件；Agent Runtime 管理消息、Run、Memory 和 Trace。图服务可以配置 NebulaGraph 存储，但不能假定所有算法都在数据库内计算：远端图查询与有界物化是不同路径。

> **多实例写入**
> 当前 Graph Engine API 的资源调度在单个进程内维护。多个 API 实例写同一存储时，需要额外的跨进程锁或资源所有者路由，不能仅增加副本数。
