feat: add NeuG as parallel graph storage engine with Cypher query support - #1056
feat: add NeuG as parallel graph storage engine with Cypher query support#1056BingqingLyu wants to merge 14 commits into
Conversation
…port Integrate NeuG as an optional storage backend alongside NetworkX. During extract, graph data is written to both graph.json (NetworkX) and graph.db (NeuG) when neug is installed. Adds `graphify cypher` CLI command and `cypher_query` MCP tool for direct Cypher queries. - New graphify/storage.py: NeuG adapter (init, ingest, query, close) - __main__.py: NeuG write in extract flow + `cypher` CLI command - serve.py: NeuG connection init + `cypher_query` MCP tool - pyproject.toml: neug optional dependency - Tests: unit tests, CLI tests, E2E integration script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Edge tables are now named edge_{src}_{tgt}_{relation} instead of
edge_{src}_{tgt}. This keeps each table well under NeuG 0.1.0s
4096-row-per-table limit (max single table ~2475 rows for calls).
Removes EXTRACTED/INFERRED routing distinction -- all edges are
uniformly routed by their relation field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…table file node IDs When extracting a single file incrementally, the auto-inferred root (paths[0].parent) differs from the project root used during full extraction, causing file node IDs to mismatch (e.g. "build_py" vs "graphify_build_py"). This created duplicate file nodes on each incremental update (+2 instead of +1). Fix: use cache_root (the project target directory passed by __main__.py) for relative_to() in the id_remap step, ensuring file node IDs are consistent regardless of whether extraction is full or incremental. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ation Separates database opening (init_db) from schema DDL (ensure_schema) so read-only consumers (CLI cypher, MCP server) can open an existing graph.db without re-running CREATE TABLE statements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace MATCH+check+SET workaround with standard Cypher MERGE ON CREATE/ON MATCH syntax (now supported in NeuG 0.1.2) - ensure_schema(create_tables=False) skips DDL on incremental runs, avoiding "table already exists" noise from the C++ layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Great idea and architecturally sound — soft-import pattern is correct, the
Fix those and this is ready to land. |
…ort, per-conn registry - Replace all _cesc() string interpolation with NeuG native $param syntax to prevent Cypher injection (community SET uses int literal due to NeuG limitation on parameterised SET) - Move `import neug` into init_db() for lazy loading - Make rel table registry per-connection via ensure_schema() return value - ingest_extraction() returns node_types dict for O(n) community writes - Require neug>=0.1.2,<0.2 for MERGE support - Remove tests/test_neug_e2e.sh (manual script, not automated) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…adopt faster-whisper version guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks for the detailed review! All 6 items are addressed in the latest push:
Looking ahead, we're happy to keep contributing on the NeuG integration. A few directions we have in mind:
We're very excited about this collaboration and would love to keep working on it together. |
…storage.py module to ARCHITECTURE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…n__, extract - pyproject.toml: keep neug extra, adopt dm extra and tree-sitter-dm in all - README: adopt uv tool install format, keep neug row, add dm row - __main__.py: keep both upstream --no-label/label help and our cypher help - extract.py: adopt upstream remap logic (already handles cache_root via root) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Hi @safishamsi , All 6 review items are addressed and I've merged the latest v8 to resolve conflicts. Let me know if there's anything else you'd like us to adjust — happy to iterate. Otherwise this should be ready to merge whenever you get a chance to take another look. |
…a + upstream additions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace per-row Cypher CREATE with CSV-based COPY FROM for full builds, achieving 27-69x speedup on node ingestion. Split ingest_extraction into _bulk_ingest (COPY FROM) and _incremental_ingest (per-row MERGE). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace per-row MERGE with batch DELETE affected source_files + COPY FROM for incremental ingest. Preserves incoming cross-file edges by saving them before deletion and restoring afterwards. Resolves non-delta edge endpoint types from the database before deletion. Benchmark: 1.7-9.4x faster than NetworkX across all repo scales. Correctness verified: node/edge counts match NetworkX after full build, single-file update, multi-file update, and prune operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Benchmark: NeuG vs NetworkX end-to-end performanceRan an end-to-end comparison of the two storage backends (NetworkX
Key takeaways:
@safishamsi Updated with performance improvements — benchmark data above. Happy to discuss any questions. Benchmark script"""Benchmark: NetworkX (graph.json) vs NeuG (graph.db) on real repos."""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import networkx as nx
from graphify.build import build_from_json
from graphify.export import to_json
from graphify.storage import (
init_db, ensure_schema, ingest_extraction, ingest_communities,
execute_cypher, close_db,
)
REPOS = [
("graphify", Path(__file__).resolve().parent.parent / "graphify-out" / "graph.json"),
]
# Add extra repos from BENCH_REPOS env var (comma-separated paths).
# Example: BENCH_REPOS=/path/to/transformers,/path/to/kubernetes
for _p in os.environ.get("BENCH_REPOS", "").split(","):
_p = _p.strip()
if _p:
_gj = Path(_p) / "graphify-out" / "graph.json"
REPOS.append((Path(_p).name, _gj))
ROUNDS = 3
def _load_extraction(graph_json_path: Path) -> dict:
data = json.loads(graph_json_path.read_text(encoding="utf-8"))
links_key = "edges" if "edges" in data else "links"
return {
"nodes": data.get("nodes", []),
"edges": data.get(links_key, []),
}
def _timer(fn):
t0 = time.perf_counter()
result = fn()
elapsed = time.perf_counter() - t0
return elapsed, result
def _avg_timer(fn, rounds: int = ROUNDS):
times = []
result = None
for _ in range(rounds):
t, result = _timer(fn)
times.append(t)
return sum(times) / len(times), result
def bench_full_build(extraction: dict, tmp: Path):
"""Full build: extraction dict -> storage."""
nx_times = []
nx_nodes = 0
for i in range(ROUNDS):
def nx_build():
G = build_from_json(extraction, directed=True)
out = tmp / f"nx_graph_{i}.json"
to_json(G, {}, str(out))
return G.number_of_nodes()
t, nx_nodes = _timer(nx_build)
nx_times.append(t)
neug_times = []
for i in range(ROUNDS):
db_path = str(tmp / f"full_{i}.db")
def neug_build(p=db_path):
db, conn = init_db(p)
known = ensure_schema(conn)
ingest_extraction(conn, extraction, incremental=False, known_tables=known)
close_db(db, conn)
t, _ = _timer(neug_build)
neug_times.append(t)
return sum(nx_times) / ROUNDS, sum(neug_times) / ROUNDS, nx_nodes
def bench_incremental(extraction: dict, tmp: Path):
"""Incremental update: simulate re-extracting ~1% of source files."""
from collections import defaultdict
nodes_by_sf = defaultdict(list)
for n in extraction["nodes"]:
sf = n.get("source_file", "")
if sf:
nodes_by_sf[sf].append(n)
target = max(1, len(extraction["nodes"]) // 100)
delta_nodes = []
picked_sfs = []
for sf in sorted(nodes_by_sf, key=lambda k: len(nodes_by_sf[k]), reverse=True):
delta_nodes.extend(nodes_by_sf[sf])
picked_sfs.append(sf)
if len(delta_nodes) >= target:
break
delta_ids = {n["id"] for n in delta_nodes}
delta_edges = [
e for e in extraction["edges"]
if e.get("source", e.get("from", "")) in delta_ids
]
delta = {"nodes": delta_nodes, "edges": delta_edges}
from graphify.build import build_merge
nx_times = []
for i in range(ROUNDS):
base_json = tmp / f"inc_base_{i}.json"
to_json(build_from_json(extraction, directed=True), {}, str(base_json))
def nx_incremental(p=base_json):
G = build_merge([delta], graph_path=p, dedup=False, directed=True)
to_json(G, {}, str(p))
t, _ = _timer(nx_incremental)
nx_times.append(t)
neug_times = []
for i in range(ROUNDS):
db_path = str(tmp / f"inc_{i}.db")
db, conn = init_db(db_path)
known = ensure_schema(conn)
ingest_extraction(conn, extraction, incremental=False, known_tables=known)
close_db(db, conn)
def neug_incremental(p=db_path):
db2, conn2 = init_db(p)
known2 = ensure_schema(conn2, create_tables=False)
ingest_extraction(conn2, delta, incremental=True, known_tables=known2)
close_db(db2, conn2)
t, _ = _timer(neug_incremental)
neug_times.append(t)
return sum(nx_times) / ROUNDS, sum(neug_times) / ROUNDS, len(delta_nodes)
def bench_query(extraction: dict, tmp: Path):
"""Query: find all callers of a node."""
base_json = tmp / "q_base.json"
G = build_from_json(extraction, directed=True)
to_json(G, {}, str(base_json))
def nx_query():
data = json.loads(base_json.read_text(encoding="utf-8"))
links_key = "edges" if "edges" in data else "links"
results = []
for e in data.get(links_key, []):
if e.get("relation") == "calls":
results.append((e.get("source", e.get("from")), e.get("target", e.get("to"))))
return len(results)
nx_time, nx_count = _avg_timer(nx_query)
db_path = str(tmp / "q.db")
db, conn = init_db(db_path)
known = ensure_schema(conn)
ingest_extraction(conn, extraction, incremental=False, known_tables=known)
def neug_query():
rows = execute_cypher(
conn,
"MATCH (a:code)-[e:edge_code_code_calls]->(b:code) RETURN a.id, b.id",
)
return len(rows)
neug_time, neug_count = _avg_timer(neug_query)
close_db(db, conn)
return nx_time, neug_time, nx_count
def fmt_time(t: float) -> str:
if t < 1:
return f"{t*1000:.0f}ms"
return f"{t:.2f}s"
def fmt_speedup(nx_t: float, neug_t: float) -> str:
if neug_t == 0:
return "-"
ratio = nx_t / neug_t
return f"{ratio:.1f}x"
def main():
print(f"Rounds per test: {ROUNDS}", file=sys.stderr)
rows = []
for repo_name, graph_path in REPOS:
if not graph_path.exists():
print(f"[skip] {repo_name}: {graph_path} not found", file=sys.stderr)
continue
print(f"\n{'='*60}", file=sys.stderr)
print(f"Benchmarking: {repo_name}", file=sys.stderr)
print(f"{'='*60}", file=sys.stderr)
extraction = _load_extraction(graph_path)
n_nodes = len(extraction["nodes"])
n_edges = len(extraction["edges"])
print(f" {n_nodes:,} nodes, {n_edges:,} edges", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
print(" [1/3] Full build ...", file=sys.stderr)
fb_nx, fb_neug, _ = bench_full_build(extraction, tmp)
rows.append((repo_name, n_nodes, n_edges, "Full build",
fmt_time(fb_nx), fmt_time(fb_neug), fmt_speedup(fb_nx, fb_neug)))
print(" [2/3] Incremental update ...", file=sys.stderr)
inc_nx, inc_neug, delta_n = bench_incremental(extraction, tmp)
rows.append((repo_name, n_nodes, n_edges,
f"Incremental ({delta_n} nodes)",
fmt_time(inc_nx), fmt_time(inc_neug), fmt_speedup(inc_nx, inc_neug)))
print(" [3/3] Query (all calls edges) ...", file=sys.stderr)
q_nx, q_neug, _ = bench_query(extraction, tmp)
rows.append((repo_name, n_nodes, n_edges, "Query (calls)",
fmt_time(q_nx), fmt_time(q_neug), fmt_speedup(q_nx, q_neug)))
print()
print("| Repo | Nodes | Edges | Operation | NetworkX | NeuG | Speedup |")
print("|------|-------|-------|-----------|----------|------|---------|")
for repo, nodes, edges, op, nx_t, neug_t, speedup in rows:
print(f"| {repo} | {nodes:,} | {edges:,} | {op} | {nx_t} | {neug_t} | **{speedup}** |")
if __name__ == "__main__":
main() |
…ort, per-conn registry - Replace all _cesc() string interpolation with NeuG native $param syntax to prevent Cypher injection (community SET uses int literal due to NeuG limitation on parameterised SET) - Move `import neug` into init_db() for lazy loading - Make rel table registry per-connection via ensure_schema() return value - ingest_extraction() returns node_types dict for O(n) community writes - Require neug>=0.1.2,<0.2 for MERGE support - Remove tests/test_neug_e2e.sh (manual script, not automated) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Superseded by #2895, which adds GDS extension support (native + incremental community detection) on top of the core NeuG integration proposed here. |
Summary
graph.dbduring extraction, enabling Cypher queries via CLI (graphify cypher) and MCP server (cypher_querytool)id_remapbug in incremental extraction that caused unstable file node IDsMotivation
Graphify currently uses NetworkX + graph.json as its core graph storage. This architecture has bottlenecks:
Why NeuG?
NeuG is a lightweight embedded graph database (C++ core, Python bindings):
pip install neugis all it takes)Architecture
Dual-engine coexistence, each independently consuming extraction data:
Changes
graphify/storage.pygraphify/__main__.pygraphify cypherCLI commandgraphify/serve.pycypher_queryMCP tool for AI agentsgraphify/extract.pypyproject.tomlneug>=0.1.2optional dependencytests/Usage
Bugfix: incremental extraction id_remap
The
id_remapstep uses an auto-inferredroot(resolves topath.parentfor single-file extraction), inconsistent with the project root used during full extraction. This causes file node IDs to be unstable, producing duplicate nodes on each incremental update.Fix: use
cache_root(the project target directory) forrelative_to()in the id_remap step.Note:
deduplicate_entities()incorrectly merges AST nodesDuring testing, we found that
deduplicate_entities()merges functions from different files that share similar names (e.g.,hooks.py:install()and__main__.py:install()). These functions have distinct IDs and different source_files — only their labels are similar. For pure AST extraction, node IDs are inherently unique, making fuzzy dedup harmful.The NeuG engine writes raw extraction data directly (skipping dedup), preserving full precision. We suggest discussing dedup strategy optimization separately (e.g., applying fuzzy dedup only to LLM-extracted concept nodes).
Test Plan
pytest tests/test_storage.py tests/test_cypher_cli.py -v— 13 tests passedcypher_querytool end-to-end verifiedgraphify extract .runs normally (silent skip)🤖 Generated with Claude Code