The Problem
Ask five teams building AI coding tools how they represent a codebase for an LLM, and you'll get two answers: embeddings, or a dependency graph. Both camps are right — for different questions. Picking the wrong one for your use case is why some "semantic code search" tools return technically-similar-but-practically-useless results.
Architecture: What Each Model Actually Captures
Vectors: Semantic Similarity
embed("parse a JWT token") ≈ embed(function verifyToken(token) {...})
Embeddings place semantically similar functions close together in vector space. The query doesn't need exact keyword matches — "parse a JWT token" finds the right utility even if the function is named decodeAuthPayload.
Best for: finding unfamiliar code by intent, code completion across different styles, spotting duplicate logic, semantic clustering of docs.
Limitation: vectors capture meaning, not structure. Two semantically similar functions might be years apart in age, one deprecated and one live in production — the embedding doesn't know the difference.
Graphs: Structural Truth
callers(auth/middleware.js) → [23 modules]
communities() → [{name: "billing", files: [...]}, {name: "auth", files: [...]}]
Traversing imports, calls, and inheritance answers exact structural questions: reachability, transitive dependencies, ownership boundaries.
Best for: finding what breaks if you change a function, mapping module dependencies, identifying hot-path slices, detecting team boundaries via community detection.
Limitation: graphs find everything structurally reachable, but can't rank by relevance or catch intent-based similarity across unrelated components. A graph traversal will happily return 40 structurally-connected files with no sense of which 3 actually matter for your question.
Implementation: The Combined Pattern
Neither model wins outright — the canonical pattern chains them:
# Step 1: graph-first neighborhood
neighborhood = graph.get_neighborhood(
target_file,
include=["imports", "callers", "community"]
)
# Step 2: vector re-ranking inside that neighborhood
results = vector_search(
query=user_query,
candidates=neighborhood, # constrained, not global
top_k=10
)
This gives you results that are both connected (graph-verified as structurally relevant) and semantically relevant (vector-ranked within that constrained set) — instead of either a noisy global vector search or an unranked pile of everything structurally reachable.
Failure Modes to Watch For
| Failure | Cause |
|---|---|
| Vector search returns deprecated/dead code | No graph-position awareness — embedding similarity ignores whether code is even reachable from production |
| Graph traversal returns too much | No relevance ranking — "structurally reachable" ≠ "relevant to this question" |
Learnings
The decision framework that generalizes: ask what your engineers actually query for.
- Mostly "find code that does X" → vectors alone are probably sufficient
- Mostly "what breaks if I change X" or "where are the team boundaries" → you need a graph
- Most mature codebases eventually need both, chained as above
Resources
- Spiderbrain uses this exact pattern: a pre-built structural graph with semantic re-ranking applied at query time.
- Related: Every AI Coding Assistant Sees Your Files, Not Your Architecture (the graph side of this argument in more depth)
🚀 Free on Local · ☁️ Hosted on Pro & Cortex
Link : https://spiderbrain.ai/
Linkedin : https://www.linkedin.com/company/spiderbrain
Discuss: if you're running vector search over a codebase today, are you constraining candidates by any structural signal first, or searching the whole embedding space every time?
Top comments (0)