首页
/ graphify 知识图谱查询完整指南:query / path / explain 的遍历模式、词汇约束扩展与自改进闭环

graphify 知识图谱查询完整指南:query / path / explain 的遍历模式、词汇约束扩展与自改进闭环

2026-09-06 18:28:12作者:裴麒琰

本指南围绕 graphify 的图查询工作流展开:当用户对一份已建好的 graph.json 提问、或显式执行 /graphify path/graphify explain 时,应如何完成「约束化查询扩展 → BFS/DFS 图遍历 → 基于图的作答 → save-result 反馈落盘 → reflect 经验回流」的完整链路。读完你将掌握三种查询模式的 CLI 命令与 NetworkX 内联回退方案、图谱词汇表驱动的查询扩展纪律,以及让每次问答沉淀为图节点与工作记忆的自学习闭环。本文依据 graphify/skills/windows/references/query.md(该参考被 claude、windows、agents 等各平台技能目录以同源方式复用),并对照 graphify/cli.pygraphify/serve.pygraphify/ingest.py 等源码逐条核实。

触发场景与整体架构

该参考文档的加载条件是:用户针对已存在的图谱提问,或运行 /graphify path/graphify explain。graphify 核心技能文档的 query 存根明确指向此参考以获取完整遍历流程(见 skill.md 中对 vocab 扩展、BFS/DFS 模式、--budget 上限、NetworkX 回退、save-result 反馈与 path/explain 流程的指引)。

整套流程遵循一条双通道执行策略

  1. CLI 优先:若环境中已安装 graphify 命令,直接调用 graphify querygraphify pathgraphify explain 三个子命令(实现集中在 graphify/cli.pyquery 分支、path 分支explain 分支);
  2. 内联回退:若 CLI 不可用,则由技能侧使用 $(cat graphify-out/.graphify_python) 解析出的 Python 解释器,在 -c 字符串内以 NetworkX 加载 graphify-out/graph.json 并执行等价遍历。

其中 graphify-out/.graphify_python 是构建图谱时随输出目录生成的解释器路径指示文件,用于确保后续内联脚本复用与建图时一致的 Python 环境(这是从源码结构与输出目录约定中可以确认的使用事实)。

第一步:确认图谱存在

任何遍历开始前必须验证 graphify-out/graph.json 已存在,避免对空语料作答:

$(cat graphify-out/.graphify_python) -c "
from pathlib import Path
if not Path('graphify-out/graph.json').exists():
    print('ERROR: No graph found. Run /graphify <path> first to build the graph.')
    raise SystemExit(1)
"

若检查失败,立即停止并向用户说明需要先执行 /graphify <path> 构建图谱。这与 CLI 侧的行为一致:graphify querypathexplain 三个子命令在启动时都会先解析图谱文件路径并检查其存在性,文件缺失时直接报错退出(例如 query 分支 中的 error: graph file not found)。

两种遍历模式:BFS 与 DFS

参考文档给出二选一的遍历模式决策表,选择依据是问题意图:

模式 旗标 适用场景
BFS(默认) (无) "X 连接了哪些东西?"——获取宽广上下文,从最近邻开始逐层展开
DFS --dfs "X 如何到达 Y?"——追踪一条具体的调用链或依赖路径

在 CLI 实现中,graphify query 是否启用 DFS 由命令行是否出现 --dfs 决定,并以此设置遍历模式后交给 _query_graph_text 处理(cli.pyL1167-L1176);底层对应 serve.py 中的 _bfs_dfs 两个遍历函数。需要留意的一个实现差异是:CLI 侧调用遍历时固定使用 depth=2,而内联回退脚本中 BFS 默认展开 3 层、DFS 深度限制为 6(见下文的脚本注释)。两者对同一概念返回的上下文范围并不完全相同,这是选择执行路径时可以参考的事实。

Step 0(必需):基于图谱词汇表的约束化查询扩展

这是整套流程中最关键、也最容易被跳过的纪律。graphify 的 query CLI 通过大小写折叠后的子串匹配 + IDF 加权来选择种子节点——二进制的匹配逻辑中没有词干还原(stemming)、没有同义词、没有跨语言匹配。源码给出了同样的证据:serve.py_query_terms 只做小写化、分词与停用词剔除;节点打分则是 _EXACT_MATCH_BONUS = 1000.0_PREFIX_MATCH_BONUS = 100.0_SUBSTRING_MATCH_BONUS = 1.0_SOURCE_MATCH_BONUS = 0.5 的字面量得分体系(serve.py),而 serve.py_compute_idf 用逆文档频率压制 errorexception 这类在图中命中数百节点的常见词。

这意味着:当用户的用词与图谱标签词汇不一致时——例如用户说俄语 "обработчик" 而图里写的是 "handler",用户说 "authentication" 而项目里对应符号叫 "Guardian"——字面匹配会返回 0 命中,答案随之坍缩成噪音。修复办法是先对照真实图谱词汇做查询扩展,且绝不自造 token

1. 从节点标签中抽取 token 词汇表:

$(cat graphify-out/.graphify_python) -c "
import json, re
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
vocab = set()
for n in data['nodes']:
    for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
        parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
        for p in parts:
            t = p.lower()
            if 3 <= len(t) <= 30:
                vocab.add(t)
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)), encoding='utf-8')
print(f'vocab: {len(vocab)} tokens')
"

该脚本遍历 data['nodes'] 中每个节点的 label,先用 Unicode 正则取出单词,再用驼峰拆分正则(如 XMLParserXMLParser)把大小写边界切开,最后只保留长度 3–30 的小写 token 写入 graphify-out/.vocab.txt。3 字符下限与内联遍历脚本中 len(t) >= 3 的过滤阈值一致,目的是保留 apijwtios 这类有检索价值的三字母词。

2. 从 vocab 中挑选扩展 token。 读取 graphify-out/.vocab.txt 后,针对用户问题从中挑选至多 12 个在语义上匹配查询意图的 token,硬约束如下:

  • 只能选取词汇表文件中真实存在的 token,不得自造
  • 若某个查询概念在词汇表中找不到合理对应,直接跳过该概念——不能用训练记忆中的近义词顶替;
  • 一个匹配的 token 都没有,输出空列表,并如实告知用户当前语料对该问题没有相关词汇,绝不虚构一次搜索;
  • 跨语言翻译:俄语 "аутентификация" → 仅当词汇表中存在 authcredentialtokensecurity 时才选用;
  • 词形还原:仅当词汇表存在 handler 时,"handlers" 才映射到 handlertodos 同理映射到 todo

3. 把选择显式打印给用户,保证可审计:

Query expanded to (from graph vocab, N tokens): [token1, token2, ...]

若列表为空,直接明说并停止,不进入遍历阶段。

Step 1:遍历执行

将选出的 token 用空格拼接成扩展后的查询串,作为下文 QUESTION 的值——注意这里不再使用用户的原始问句(原始问句仅保留到结尾 save-result 环节使用)。

CLI 优先路径

graphify query "QUESTION"
# or: graphify query "QUESTION" --dfs --budget 3000

对照源码,graphify query 的完整用法还支持更多选项:graphify query "<question>" [--dfs] [--context C] [--budget N] [--graph path]cli.py)。其中:

  • --dfs:切换为 DFS 遍历,否则默认 BFS;
  • --budget N:输出 token 预算,默认 2000,可用 --budget N--budget=N 两种写法传入;
  • --context C:可重复传入,用于把遍历限定在某类上下文(context filter);
  • --graph path:显式指定要查询的 .json 图谱文件,默认取当前目录 graphify-out/graph.json(源码同时会校验后缀必须是 .json,并执行图大小上限检查)。

CLI 在完成遍历后还会把本次查询记录进查询日志,并触碰图谱文件的时间戳,供增量/缓存机制判断图谱是否陈旧(cli.py)。真正的检索与渲染发生在 serve._query_graph_textserve.py):它一次打分同时产出综合排序与按 term 的独立冠军节点,还会把 "calls"、"uses" 这类关系意图动词从逐 term 种子保证中剔除,防止一次偶然的动词命中把 BFS 起点带偏;输出头部会包含图谱文件路径与节点总数(Graph: ... (N nodes)),避免跨项目时错读别处语料还浑然不知。

NetworkX 内联回退

若 CLI 不可用,加载 graphify-out/graph.json 并以内联脚本遍历:

  1. 找出标签与扩展 token 最匹配的 1–3 个节点;
  2. 从每个起点执行相应的遍历;
  3. 阅读子图——节点标签、边关系、置信度标签、源码位置;
  4. 只依据图内实际存在的内容作答,引用具体事实时给出 source_location
  5. 若图信息不足,如实说明——不得臆造边。
$(cat graphify-out/.graphify_python) -c "
import sys, json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path

data = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
G = json_graph.node_link_graph(data, edges='links')

question = 'QUESTION'
mode = 'MODE'  # 'bfs' or 'dfs'
terms = [t.lower() for t in question.split() if len(t) >= 3]  # match the vocab threshold; keeps api/jwt/ios (#1392)

# Find best-matching start nodes
scored = []
for nid, ndata in G.nodes(data=True):
    label = ndata.get('label', '').lower()
    score = sum(1 for t in terms if t in label)
    if score > 0:
        scored.append((score, nid))
scored.sort(reverse=True)
start_nodes = [nid for _, nid in scored[:3]]

if not start_nodes:
    print('No matching nodes found for query terms:', terms)
    sys.exit(0)

subgraph_nodes = set()
subgraph_edges = []

if mode == 'dfs':
    # DFS: follow one path as deep as possible before backtracking.
    # Depth-limited to 6 to avoid traversing the whole graph.
    visited = set()
    stack = [(n, 0) for n in reversed(start_nodes)]
    while stack:
        node, depth = stack.pop()
        if node in visited or depth > 6:
            continue
        visited.add(node)
        subgraph_nodes.add(node)
        for neighbor in G.neighbors(node):
            if neighbor not in visited:
                stack.append((neighbor, depth + 1))
                subgraph_edges.append((node, neighbor))
else:
    # BFS: explore all neighbors layer by layer up to depth 3.
    frontier = set(start_nodes)
    subgraph_nodes = set(start_nodes)
    for _ in range(3):
        next_frontier = set()
        for n in frontier:
            for neighbor in G.neighbors(n):
                if neighbor not in subgraph_nodes:
                    next_frontier.add(neighbor)
                    subgraph_edges.append((n, neighbor))
        subgraph_nodes.update(next_frontier)
        frontier = next_frontier

# Token-budget aware output: rank by relevance, cut at budget (~4 chars/token)
token_budget = BUDGET  # default 2000
char_budget = token_budget * 4

# Score each node by term overlap for ranked output
def relevance(nid):
    label = G.nodes[nid].get('label', '').lower()
    return sum(1 for t in terms if t in label)

ranked_nodes = sorted(subgraph_nodes, key=relevance, reverse=True)

lines = [f'Traversal: {mode.upper()} | Start: {[G.nodes[n].get(\"label\",n) for n in start_nodes]} | {len(subgraph_nodes)} nodes']
for nid in ranked_nodes:
    d = G.nodes[nid]
    lines.append(f'  NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
for u, v in subgraph_edges:
    if u in subgraph_nodes and v in subgraph_nodes:
        _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
        lines.append(f'  EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')

output = '\n'.join(lines)
if len(output) > char_budget:
    output = output[:char_budget] + f'\n... (truncated at ~{token_budget} token budget - use --budget N for more)'
print(output)
"

替换三个占位符:QUESTION 换成扩展后的查询串,MODE 换成 bfsdfsBUDGET 换成 token 预算(默认 2000,或与 CLI 的 --budget N 保持一致)。随后仅依据上面的子图输出作答,只用图内真实内容。

值得注意的实现差异:内联回退脚本以子串包含计分(t in label)并取 top3 起点,输出按 ~4 chars/token 估算裁剪;而 CLI 侧 serve.py_subgraph_to_text 按约 3 chars/token 估算预算、对命中节点做相关度排序并保证种子节点优先渲染、被裁剪的多余节点还会在截断提示中给出计数。无论走哪条路径,token 预算都是防止大图上输出失控的关键旋钮——命中节点不足但边很多时,预算会截断输出而不是无限铺开。

答案的举证纪律

参考文档对内联回退给出五条作答纪律,这些约束同样适用于 CLI 结果的解读:

  • 只引用图中真实存在的节点、边、关系与置信度;
  • 陈述具体事实时附上 source_location 作为引用位置;
  • 当图内信息不足以回答时直接说明,不编造不存在的边来"补全"答案。

这也是 graphify "every edge explained" 设计理念在查询侧的反向约束——图里每条边都携带 relation、confidence、source 信息,作答就该建立在证据之上而非模型记忆之上。

反馈闭环:save-result

作答完成后,把本次问答存回图谱,让后续查询受益。为了便于下一次 --update 把扩展历史作为图节点抽取,--answer 文本里要包含扩展 token 的踪迹(例如 "Expanded from original query via vocab: [tokens]. Then traversed..."):

$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2

替换规则:ORIGINAL_QUESTION 为用户逐字原问句,ANSWER 为完整答案文本(内含扩展 token 踪迹),NODE1 NODE2 为作答时引用过的节点标签列表。这闭合了反馈环——下一次 --update 会把该 Q&A 抽取为图中的一个节点。

源码层看,save-result 子命令解析这些参数后调用 graphify.ingest.save_query_resultcli.py),其落盘实现位于 ingest.py:在默认的 graphify-out/memory/ 目录写入一个带 YAML frontmatter 的 Markdown 文件,文件名由 query_<UTC时间戳>_<问题slug>.md 组成;frontmatter 记录 typedatequestioncontributor: "graphify",以及可选的 outcomecorrectionsource_nodes(最多 10 个)字段;正文则包含 # Q:## Answer## Outcome## Source Nodes 小节。正是因为落盘格式是 graphify 抽取器认识的 Markdown,这些记忆才会在下次增量更新时被当作可解析文本进入图谱。CLI 也支持 --answer-file 从文件读取长答案,以及 --memory-dir 自定义记忆目录。

工作记忆:outcome 信号与 reflect 经验回流

为让未来的会话从本次问答中学习,可在 save-result 上追加 --outcome 信号(cli.py 限制其取值只能是以下三者之一),修正场景再配 --correction "正确的答案"

  • useful —— 被引用节点很好地回答了问题(这些节点会升级为 preferred sources,即优先来源);
  • dead_end —— 该问题/路径没有导出有效结论,下次不必重新推导;
  • corrected —— 已保存的答案有误,--correction 记录正确版本。

这些信号经 ingest.py 校验后同时写入 frontmatter(供 reflect 确定性聚合)与 ## Outcome 正文小节(供下次语义再抽取时把信号回流到图内)。

在每次开始图相关工作前,先刷新并阅读经验

graphify reflect --if-stale

reflect 是廉价、确定性的纯本地聚合(不调用 LLM)。--if-stale 使其在 LESSONS.md 已经比所有输入都新时直接空转(例如 git post-commit hook 刚刚刷新过它),因此会话开头的这次运行几乎零成本。之后阅读 graphify-out/reflections/LESSONS.md——它列出优先来源(从那里开始)、已知死胡同(跳过它们)与既往修正记录。即使没有安装 git hook,亲自运行 reflect 也能保持经验新鲜;若 post-commit hook 已装,--if-stale 让会话启动开销趋近于零。

源码佐证:graphify reflect 子命令支持 --memory-dir(默认 graphify-out/memory)、--out(默认 graphify-out/reflections/LESSONS.md)、--graph--analysis--labels--half-life-days(默认 30,信号权重每 N 天减半)与 --min-corroboration(默认 2,即需要 2 条不同的 useful 结果才把某节点提升为 preferred),并在 --if-stale 判定为已最新时输出 skip 提示、否则聚合后报告 useful/dead_end/corrected 三类计数(cli.py)。这些经验还会以 learning overlay 的形式叠加到 graphify explain 的输出中,详见下文。

面向 /graphify path:两点最短路径

在图中找出两个具名概念间的最短路径。优先使用 CLI:

graphify path "NODE_A" "NODE_B"

CLI 不可用时以内联脚本执行:

$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path

data = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
G = json_graph.node_link_graph(data, edges='links')

a_term = 'NODE_A'
b_term = 'NODE_B'

def find_node(term):
    term = term.lower()
    scored = sorted(
        [(sum(1 for w in term.split() if w in G.nodes[n].get('label','').lower()), n)
         for n in G.nodes()],
        reverse=True
    )
    return scored[0][1] if scored and scored[0][0] > 0 else None

src = find_node(a_term)
tgt = find_node(b_term)

if not src or not tgt:
    print(f'Could not find nodes matching: {a_term!r} or {b_term!r}')
    sys.exit(0)

try:
    path = nx.shortest_path(G, src, tgt)
    print(f'Shortest path ({len(path)-1} hops):')
    for i, nid in enumerate(path):
        label = G.nodes[nid].get('label', nid)
        if i < len(path) - 1:
            _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
            rel = edge.get('relation', '')
            conf = edge.get('confidence', '')
            print(f'  {label} --{rel}--> [{conf}]')
        else:
            print(f'  {label}')
except nx.NetworkXNoPath:
    print(f'No path found between {a_term!r} and {b_term!r}')
except nx.NodeNotFound as e:
    print(f'Node not found: {e}')
"

NODE_ANODE_B 替换为用户实际关心的概念名,然后把路径用通俗语言向用户解释——每一跳的含义是什么、为何重要。

路径解释完毕后,同样存回:

$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B

从源码看,CLI 版 graphify path 的实现比内联脚本更严谨(cli.py),这些行为可以作为选择 CLI 的理由:

  • 默认有向--directed/--undirected 二选一):默认尊重调用者→被调者的真实方向,找不到有向路径时提示可加 --undirected 忽略方向重试;
  • 同节点保护:起终点解析到同一节点时直接报错(该路径恒为 0 跳,几乎不可能是用户想要的),提示改用更具体的标签或节点 ID;
  • 歧义告警:当冠亚军得分差距小于 10% 时输出 warning: ... match was ambiguous,提示解析存在竞争;
  • 确定性最短路径:用排序后的物化图计算,保证同一 graph.json 每次都得到同一条规范化路径,而不是随进程变化的任意等长路径;
  • 诚实渲染关系:打印每一跳时报告该节点对实际存储的关系(可能同时存在多条平行关系则合并展示,无关系则如实写 related),方向依据 _src/_tgt 标记恢复,绝不把边伪装成 calls

面向 /graphify explain:单节点全连接解释

对单个节点给出通俗解释——它连接的一切。优先使用 CLI:

graphify explain "NODE_NAME"

CLI 不可用时以内联脚本执行:

$(cat graphify-out/.graphify_python) -c "
import json, sys
import networkx as nx
from networkx.readwrite import json_graph
from pathlib import Path

data = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
G = json_graph.node_link_graph(data, edges='links')

term = 'NODE_NAME'
term_lower = term.lower()

# Find best matching node
scored = sorted(
    [(sum(1 for w in term_lower.split() if w in G.nodes[n].get('label','').lower()), n)
     for n in G.nodes()],
    reverse=True
)
if not scored or scored[0][0] == 0:
    print(f'No node matching {term!r}')
    sys.exit(0)

nid = scored[0][1]
data_n = G.nodes[nid]
print(f'NODE: {data_n.get(\"label\", nid)}')
print(f'  source: {data_n.get(\"source_file\",\"unknown\")}')
print(f'  type: {data_n.get(\"file_type\",\"unknown\")}')
print(f'  degree: {G.degree(nid)}')
print()
print('CONNECTIONS:')
for neighbor in G.neighbors(nid):
    _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
    nlabel = G.nodes[neighbor].get('label', neighbor)
    rel = edge.get('relation', '')
    conf = edge.get('confidence', '')
    src_file = G.nodes[neighbor].get('source_file', '')
    print(f'  --{rel}--> {nlabel} [{conf}] ({src_file})')
"

NODE_NAME 替换为用户询问的概念,随后写出 3–5 句解释:该节点是什么、它连接了什么、这些连接为何重要,并用源码位置作为引用。

解释完成后存回:

$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME

CLI 版 graphify explaincli.py)在同样的职责之上叠加了三层增强,可作为解释输出的补充信息源:

  • 分层匹配与歧义拦截:节点解析按 source 精确/精确/前缀/子串四级打分(serve.py_find_node_tiers),当同名节点散落在不同文件时(find_node_ambiguity)会列出所有候选文件并要求用仓库相对路径或完整节点 ID 重试,而不是武断地猜一个;
  • 工作记忆叠加(learning overlay):若该节点在 graphify reflect 的经验 sidecar 中有条目,输出会追加一行 Lesson: 标注其状态——preferred source (start here)contestedtentative,并附 useful/negative 计数与得分;若源码在上次经验记录后已变化,还会追加 [code changed since — re-verify] 提醒复核;
  • 连接的分层呈现:连接按真实方向(-->/<--)与存储关系渲染,超过 20 条时按"方向 + 文件"分组统计并继续折叠展示,兼顾高扇出节点(如"谁在调用这个公共函数")的形状可见性与输出可控。

三种查询与自改进链路一览

把整条工作流收束起来,graphify 的查询不是一次性只读操作,而是一个可持续积累的循环:

  1. 准备:校验 graphify-out/graph.json 存在 → graphify reflect --if-stale 刷新并读取 LESSONS.md(优先来源/死胡同/修正记录);
  2. 扩展:从图谱标签提取词汇表 → 有纪律地挑选 ≤12 个真实 token(可审计打印、可跨语言/词形映射、空则停止);
  3. 遍历query(默认 BFS,或 --dfs)→ path(有向最短路径)→ explain(单节点连接画像),CLI 优先、NetworkX 内联回退;
  4. 作答:只用图内证据,引用 source_location,信息不足如实说明;
  5. 沉淀save-resultquery/path_query/explain 类型把 Q&A 连同 outcome 信号写入 graphify-out/memory/
  6. 回流:下次 --update 把记忆文件重新抽取进图,reflect 把多次问答聚合成经验(preferred sources / dead ends / corrections),而 explain 与后续查询又消费这些经验——如此循环,图谱会随团队的使用逐渐"记住"哪些来源可信、哪些路径是死胡同。

这一闭环的每一环都能在仓库中找到可验证的实现落点:CLI 参数解析见 graphify/cli.pyquery L1068、save-result L1312、reflect L1343、path L1400、explain L1567),检索与渲染核心见 graphify/serve.py_query_terms L272、IDF 计算 L300、_query_graph_text L1197),记忆落盘见 graphify/ingest.py。对应行为的回归测试可参考 tests/test_query_cli.pytests/test_querylog.pytests/test_reflect.py,它们覆盖了查询输出、日志记录与经验聚合的边界行为;若要亲手实验,可在任意建好图的目录依次执行上文各条命令,观察扩展 token、子图输出与 memory/reflections/ 目录的变化。

登录后查看全文
热门项目推荐
相关项目推荐