Skip to content

Commit bb160d2

Browse files
ShreeBoharaclaude
andcommitted
Add an optional Neo4j graph read model behind a default-off flag
Enables the traversals the SQL path structurally cannot do, without making a new datastore load-bearing. WHY The graph questions that matter for understanding a codebase are transitive: what breaks if I change this file, how does auth reach the database, are there import cycles. The Python implementation cannot answer them -- hops is hard-capped at 2 in the API because each hop rescans the whole edge list, so depth is quadratic in Python and a single variable-length pattern in Cypher. DESIGN: PROJECTION, NOT SOURCE OF TRUTH code_dependencies (SQL) stays authoritative. Neo4j is projected from it during indexing and can be rebuilt by re-indexing. Consequences, all deliberate: - neo4j_enabled defaults to False, so nothing changes unless it is turned on. - get_graph_store() returns None when disabled OR misconfigured (missing password) rather than raising, so every caller treats "no graph store" as an ordinary path. - Startup verifies connectivity and applies schema, but a failure only logs -- an unreachable graph database must not stop the API booting. - A sync failure during indexing is caught and logged; the SQL edges are unaffected. - Repo deletion removes the subgraph before the SQL rows, so a failure leaves the authoritative data intact and retryable rather than orphaning a subgraph. - core/graph/neo4j_store.py: schema (uniqueness on (repo_id, path), which is also the index that keeps MERGE off a label scan), batched UNWIND+MERGE ingest at 500 rows, and reads. sync_repository deletes the subgraph first rather than merging, because a MERGE-only sync leaves edges for deleted files and drifts into a union of every commit ever indexed. - Traversals: reachable_from, blast_radius, shortest_path, import_cycles. Depth is clamped (1..10) -- an unbounded variable-length pattern is a trivial way to hang the server. - Degree and centrality use COUNT {} subqueries, NOT Graph Data Science: the in-database GDS plugin requires AuraDB Professional or above, and Aura Graph Analytics sessions are an offline batch shape (2GB, one concurrent session, 30-minute TTL) that does not fit a synchronous request. - Async driver held as a single long-lived instance (it owns the pool) and closed in the lifespan. - docker-compose.yml: neo4j:5-community as a fourth service at 512m heap + 512m pagecache, plus the five settings forwarded to the API. Docs record the AuraDB Free trap: a Free instance auto-pauses after 72h idle and a paused instance's hostname stops resolving, so the graph would silently fall back to SQL until someone resumed it by hand. Also fixes stale drift in .env.example that still advertised LOCAL_EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5 -- the HuggingFace id removed from config.py earlier, which would have walked a self-hoster straight back into the all-zero-vector index. VERIFIED, AND WHAT IS NOT 15 new tests, 132 total (was 117), ruff clean, app boots with the store returning None by default. The tests use a fake driver, so they cover the queries we send and every fallback path -- including that indexing still persists SQL edges when the graph sync fails -- but NOT that the Cypher returns correct results. No query in this commit has executed against a Neo4j server. docker/README.md documents the two count queries that confirm the projection matches SQL once an instance is available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0cf2937 commit bb160d2

12 files changed

Lines changed: 682 additions & 3 deletions

File tree

.env.example

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@
4949
# insert: text-embedding-3-small is 1536, text-embedding-3-large is 3072.
5050
# OPENAI_EMBEDDING_DIMENSIONS=1536
5151

52+
# -----------------------
53+
# Neo4j graph read model (optional)
54+
# -----------------------
55+
# Off by default. SQL (code_dependencies) stays authoritative; Neo4j is a projection
56+
# rebuilt at index time, and reads fall back to SQL when it is unreachable.
57+
# Enables transitive traversals the SQL path cannot do: blast radius, shortest path
58+
# between two files, and import-cycle detection.
59+
#
60+
# Do NOT use AuraDB Free for a public demo: it auto-pauses after 72h idle and a paused
61+
# instance's hostname stops resolving, so the graph silently degrades to SQL.
62+
# NEO4J_ENABLED=false
63+
# NEO4J_URI=bolt://localhost:7687
64+
# NEO4J_USER=neo4j
65+
# NEO4J_PASSWORD=
66+
# NEO4J_DATABASE=
67+
5268
# -----------------------
5369
# Embedding Providers
5470
# -----------------------
@@ -62,7 +78,10 @@
6278
# OPENAI_EMBEDDING_RATE_LIMIT_MAX_RETRIES=6
6379
# OPENAI_EMBEDDING_RATE_LIMIT_BASE_BACKOFF_SECONDS=1.0
6480
# OPENAI_EMBEDDING_RATE_LIMIT_MAX_BACKOFF_SECONDS=30.0
65-
# LOCAL_EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5
81+
# Ollama TAG (as in `ollama pull <tag>`), never a HuggingFace repo id. A HF id such as
82+
# nomic-ai/nomic-embed-text-v1.5 404s on every request, and fail-open then lands every
83+
# chunk in the index as a zero vector -- which scores every chunk identically.
84+
# LOCAL_EMBEDDING_MODEL=nomic-embed-text
6685

6786
# -----------------------
6887
# GitHub (optional, for private repos)

apps/api/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ tree-sitter-ruby>=0.21.0
4646
# HTTP Client
4747
httpx>=0.27.0
4848

49+
# Graph read model (optional; only imported when NEO4J_ENABLED=true)
50+
neo4j>=5.28
51+
4952
# Utilities
5053
python-dotenv>=1.0.0
5154
cachetools>=5.3.0

apps/api/src/api/routes/repos.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
is_demo_mode,
1818
)
1919
from src.core.github.repo_manager import RepoManager
20-
from src.dependencies import get_db, get_session_factory, get_vector_store
20+
from src.dependencies import get_db, get_graph_store, get_session_factory, get_vector_store
2121
from src.models.database import IndexingStatus, Repository
2222
from src.models.schemas import RepoCreate, RepoListResponse, RepoResponse
2323
from src.services.indexing_service import IndexingService
@@ -202,7 +202,16 @@ async def delete_repository(
202202
# Delete from vector store
203203
await vector_store.delete_collection(repo_id)
204204

205-
# Delete from database
205+
# Remove the Neo4j projection before the SQL rows go, so a failure here leaves the
206+
# authoritative data intact and retryable rather than orphaning a subgraph.
207+
graph_store = get_graph_store()
208+
if graph_store is not None:
209+
try:
210+
await graph_store.delete_repository(repo_id)
211+
except Exception as exc:
212+
logger.warning("Failed to delete Neo4j subgraph for %s: %s", repo_id, exc)
213+
214+
# Delete from database (cascades to files, chunks, dependencies and chat sessions)
206215
db.delete(repo)
207216
db.commit()
208217

apps/api/src/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"repos_dir",
3131
"vector_db_type",
3232
"azure_openai_tokenizer_model",
33+
"neo4j_uri",
34+
"neo4j_user",
3335
)
3436

3537

@@ -231,6 +233,22 @@ class Settings(BaseSettings):
231233
demo_quiz_requests: int = 8
232234
demo_quiz_window_seconds: int = 60
233235

236+
# Neo4j graph read model (optional)
237+
#
238+
# Off by default. code_dependencies (SQL) stays authoritative; this is a projection
239+
# rebuilt at index time, and every read falls back to SQL when Neo4j is unreachable,
240+
# so enabling it cannot take the graph endpoint down.
241+
#
242+
# Do not point this at AuraDB Free for anything public: a Free instance auto-pauses
243+
# after 72 hours idle and a paused instance's hostname stops resolving, so the graph
244+
# silently falls back to SQL until someone resumes it by hand.
245+
neo4j_enabled: bool = False
246+
neo4j_uri: str = "bolt://localhost:7687"
247+
neo4j_user: str = "neo4j"
248+
neo4j_password: Optional[str] = None
249+
neo4j_database: Optional[str] = None # None uses the server default
250+
neo4j_max_traversal_hops: int = 5
251+
234252
# Learning V2 controls
235253
learning_v2_enabled: bool = False
236254
learning_cache_ttl_days: int = 7
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Graph read model (Neo4j). Optional: disabled unless neo4j_enabled is set."""
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""
2+
Neo4j read model for the repository dependency graph.
3+
4+
WHY THIS EXISTS
5+
The graph questions that matter for understanding a codebase are traversals:
6+
"what breaks if I change this file", "how does auth reach the database", "are there
7+
import cycles". Those are transitive, and the Python implementation cannot answer them
8+
-- hops is hard-capped at 2 in the API because each hop rescans the entire edge list, so
9+
depth is quadratic in Python and a single variable-length pattern in Cypher.
10+
11+
WHAT IT DOES NOT DO
12+
This is a read model, not a source of truth. code_dependencies (SQL) remains
13+
authoritative; this is projected from it at index time and can be rebuilt at any point
14+
by re-indexing. Every read falls back to the SQL path when Neo4j is unavailable, so
15+
enabling this cannot take the graph endpoint down.
16+
17+
Degree and centrality come from COUNT {} subqueries rather than Graph Data Science
18+
deliberately: the in-database GDS plugin is AuraDB Professional and above, and Aura
19+
Graph Analytics sessions are an offline batch shape (2GB, one concurrent session,
20+
30-minute TTL) that does not fit a synchronous request.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import logging
26+
from typing import Any, Dict, List, Optional, Sequence
27+
28+
logger = logging.getLogger(__name__)
29+
30+
# Applied once at startup. MERGE on (repo_id, path) is the hot write pattern, so the
31+
# uniqueness constraints are also the indexes that make it fast -- without them every
32+
# MERGE degrades to a label scan.
33+
SCHEMA_STATEMENTS: Sequence[str] = (
34+
"CREATE CONSTRAINT file_unique IF NOT EXISTS "
35+
"FOR (f:File) REQUIRE (f.repo_id, f.path) IS UNIQUE",
36+
"CREATE CONSTRAINT module_unique IF NOT EXISTS "
37+
"FOR (m:Module) REQUIRE (m.repo_id, m.key) IS UNIQUE",
38+
"CREATE INDEX file_repo IF NOT EXISTS FOR (f:File) ON (f.repo_id)",
39+
"CREATE INDEX file_module IF NOT EXISTS FOR (f:File) ON (f.repo_id, f.module_key)",
40+
)
41+
42+
# Batched so a large repository does not build one enormous transaction.
43+
_INGEST_BATCH = 500
44+
45+
_MERGE_FILES = """
46+
UNWIND $rows AS row
47+
MERGE (f:File {repo_id: $repo_id, path: row.path})
48+
SET f.filename = row.filename,
49+
f.extension = row.extension,
50+
f.language = row.language,
51+
f.loc = row.loc,
52+
f.module_key = row.module_key
53+
"""
54+
55+
_MERGE_EDGES = """
56+
UNWIND $rows AS row
57+
MATCH (s:File {repo_id: $repo_id, path: row.source})
58+
MATCH (t:File {repo_id: $repo_id, path: row.target})
59+
MERGE (s)-[r:IMPORTS {relation: row.relation}]->(t)
60+
SET r.weight = row.weight, r.confidence = row.confidence
61+
"""
62+
63+
# COUNT {} rather than GDS degreeCentrality -- see the module docstring.
64+
_READ_NODES_WITH_DEGREE = """
65+
MATCH (f:File {repo_id: $repo_id})
66+
RETURN f.path AS path,
67+
f.language AS language,
68+
f.loc AS loc,
69+
f.module_key AS module_key,
70+
COUNT { (f)-[:IMPORTS]->(:File) } AS out_degree,
71+
COUNT { (f)<-[:IMPORTS]-(:File) } AS in_degree
72+
"""
73+
74+
_READ_EDGES = """
75+
MATCH (s:File {repo_id: $repo_id})-[r:IMPORTS]->(t:File {repo_id: $repo_id})
76+
RETURN s.path AS source, t.path AS target, r.relation AS relation,
77+
r.weight AS weight, r.confidence AS confidence
78+
"""
79+
80+
# The capability the Python path structurally cannot provide: arbitrary-depth
81+
# traversal. $hops is interpolated rather than parameterised because Cypher does not
82+
# allow a parameter inside a variable-length pattern bound; it is coerced to a bounded
83+
# int by the caller before it reaches here.
84+
_REACHABILITY = """
85+
MATCH path = (s:File {repo_id: $repo_id, path: $path})-[:IMPORTS*1..%(hops)d]->(t:File)
86+
RETURN DISTINCT t.path AS path, length(path) AS distance
87+
ORDER BY distance, path
88+
"""
89+
90+
_BLAST_RADIUS = """
91+
MATCH path = (s:File {repo_id: $repo_id, path: $path})<-[:IMPORTS*1..%(hops)d]-(t:File)
92+
RETURN DISTINCT t.path AS path, length(path) AS distance
93+
ORDER BY distance, path
94+
"""
95+
96+
_SHORTEST_PATH = """
97+
MATCH (a:File {repo_id: $repo_id, path: $from_path}),
98+
(b:File {repo_id: $repo_id, path: $to_path}),
99+
p = shortestPath((a)-[:IMPORTS*..%(max_hops)d]->(b))
100+
RETURN [n IN nodes(p) | n.path] AS path_nodes, length(p) AS distance
101+
"""
102+
103+
# Import cycles: a real code-health signal the SQL path cannot express at all.
104+
_CYCLES = """
105+
MATCH (f:File {repo_id: $repo_id})
106+
MATCH p = (f)-[:IMPORTS*2..%(max_len)d]->(f)
107+
RETURN [n IN nodes(p) | n.path] AS cycle, length(p) AS size
108+
ORDER BY size, cycle
109+
LIMIT $limit
110+
"""
111+
112+
_DELETE_REPO = """
113+
MATCH (n {repo_id: $repo_id})
114+
CALL (n) { DETACH DELETE n } IN TRANSACTIONS OF 1000 ROWS
115+
"""
116+
117+
118+
class Neo4jGraphStore:
119+
"""
120+
Thin async wrapper around the Neo4j driver.
121+
122+
Holds no state beyond the driver, so it is safe to construct per request; the driver
123+
itself is a long-lived singleton (see dependencies.get_graph_driver) because it owns
124+
the connection pool.
125+
"""
126+
127+
def __init__(self, driver, database: Optional[str] = None):
128+
self._driver = driver
129+
self._database = database
130+
131+
async def _run(self, query: str, **params) -> List[Dict[str, Any]]:
132+
records, _, _ = await self._driver.execute_query(
133+
query, database_=self._database, **params
134+
)
135+
return [dict(r) for r in records]
136+
137+
# --- lifecycle ---------------------------------------------------------------
138+
139+
async def verify(self) -> bool:
140+
"""True when the server is reachable and authenticated."""
141+
try:
142+
await self._driver.verify_connectivity()
143+
return True
144+
except Exception as exc:
145+
logger.warning("Neo4j connectivity check failed: %s", exc)
146+
return False
147+
148+
async def ensure_schema(self) -> None:
149+
"""Idempotent; every statement is IF NOT EXISTS."""
150+
for statement in SCHEMA_STATEMENTS:
151+
await self._run(statement)
152+
153+
# --- ingest ------------------------------------------------------------------
154+
155+
async def sync_repository(
156+
self,
157+
repo_id: str,
158+
files: Sequence[Dict[str, Any]],
159+
edges: Sequence[Dict[str, Any]],
160+
) -> Dict[str, int]:
161+
"""
162+
Project a repository's files and edges into the graph.
163+
164+
Replaces rather than merges: the previous subgraph is deleted first, because a
165+
MERGE-only sync would leave edges for files that no longer exist and silently
166+
accumulate a graph that no commit ever had.
167+
"""
168+
await self.delete_repository(repo_id)
169+
170+
for start in range(0, len(files), _INGEST_BATCH):
171+
await self._run(
172+
_MERGE_FILES, repo_id=repo_id, rows=list(files[start:start + _INGEST_BATCH])
173+
)
174+
for start in range(0, len(edges), _INGEST_BATCH):
175+
await self._run(
176+
_MERGE_EDGES, repo_id=repo_id, rows=list(edges[start:start + _INGEST_BATCH])
177+
)
178+
179+
logger.info(
180+
"Synced %d files and %d edges into Neo4j for repo %s",
181+
len(files), len(edges), repo_id,
182+
)
183+
return {"files": len(files), "edges": len(edges)}
184+
185+
async def delete_repository(self, repo_id: str) -> None:
186+
"""Remove a repository's subgraph. Called on re-sync and on repo deletion."""
187+
await self._run(_DELETE_REPO, repo_id=repo_id)
188+
189+
# --- reads -------------------------------------------------------------------
190+
191+
async def nodes_with_degree(self, repo_id: str) -> List[Dict[str, Any]]:
192+
return await self._run(_READ_NODES_WITH_DEGREE, repo_id=repo_id)
193+
194+
async def edges(self, repo_id: str) -> List[Dict[str, Any]]:
195+
return await self._run(_READ_EDGES, repo_id=repo_id)
196+
197+
# --- traversals the SQL path cannot answer -----------------------------------
198+
199+
@staticmethod
200+
def _bounded(value: int, lo: int, hi: int) -> int:
201+
return max(lo, min(hi, int(value)))
202+
203+
async def reachable_from(self, repo_id: str, path: str, hops: int = 3) -> List[Dict[str, Any]]:
204+
"""What this file transitively imports."""
205+
q = _REACHABILITY % {"hops": self._bounded(hops, 1, 10)}
206+
return await self._run(q, repo_id=repo_id, path=path)
207+
208+
async def blast_radius(self, repo_id: str, path: str, hops: int = 3) -> List[Dict[str, Any]]:
209+
"""What transitively imports this file -- i.e. what a change here can affect."""
210+
q = _BLAST_RADIUS % {"hops": self._bounded(hops, 1, 10)}
211+
return await self._run(q, repo_id=repo_id, path=path)
212+
213+
async def shortest_path(
214+
self, repo_id: str, from_path: str, to_path: str, max_hops: int = 10
215+
) -> Optional[Dict[str, Any]]:
216+
q = _SHORTEST_PATH % {"max_hops": self._bounded(max_hops, 1, 15)}
217+
rows = await self._run(q, repo_id=repo_id, from_path=from_path, to_path=to_path)
218+
return rows[0] if rows else None
219+
220+
async def import_cycles(
221+
self, repo_id: str, max_length: int = 6, limit: int = 20
222+
) -> List[Dict[str, Any]]:
223+
q = _CYCLES % {"max_len": self._bounded(max_length, 2, 10)}
224+
return await self._run(q, repo_id=repo_id, limit=max(1, int(limit)))

apps/api/src/dependencies.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,40 @@ def get_redis_client():
122122
def get_chat_cache() -> ChatCache:
123123
"""Get chat cache service with Redis+memory fallback."""
124124
return ChatCache(redis_client=get_redis_client())
125+
126+
127+
@lru_cache()
128+
def get_graph_driver():
129+
"""
130+
Long-lived Neo4j async driver, or None when the graph read model is disabled.
131+
132+
The driver owns a connection pool, so it must be a singleton and must be closed on
133+
shutdown (see main.py lifespan). Returns None rather than raising so that every
134+
caller can treat "no graph store" as an ordinary fallback path.
135+
"""
136+
if not settings.neo4j_enabled:
137+
return None
138+
if not settings.neo4j_password:
139+
logger.warning("NEO4J_ENABLED is set but NEO4J_PASSWORD is empty; graph store disabled")
140+
return None
141+
142+
try:
143+
from neo4j import AsyncGraphDatabase
144+
145+
return AsyncGraphDatabase.driver(
146+
settings.neo4j_uri,
147+
auth=(settings.neo4j_user, settings.neo4j_password),
148+
)
149+
except Exception as exc:
150+
logger.warning("Neo4j driver unavailable, falling back to SQL graph: %s", exc)
151+
return None
152+
153+
154+
def get_graph_store():
155+
"""Neo4jGraphStore bound to the shared driver, or None when disabled."""
156+
driver = get_graph_driver()
157+
if driver is None:
158+
return None
159+
from src.core.graph.neo4j_store import Neo4jGraphStore
160+
161+
return Neo4jGraphStore(driver, database=settings.neo4j_database)

0 commit comments

Comments
 (0)