Code Graph Graph RAG for agents
soli graph build extracts a graph of your project's source — files, classes,
models, controllers, methods, functions, routes and views, plus the relationships between them —
and stores it in SolidB. Every node's text is embedded, so AI agents (and your own tools) can retrieve
the right code by semantic search and then
traverse relationships from there. It's a graph RAG index of your
own codebase.
Building the graph
Where Model.rag indexes your application data, this points the same vector +
graph machinery at the code itself.
# Build the graph for the app in the current folder
soli graph build
# Or point at a project folder
soli graph build path/to/app
The build is incremental and non-destructive. It hashes every
source file (MD5), so a re-run when nothing changed is a fast no-op
(✓ code graph already up to date). When something changed, it updates SolidB
in place — inserts new nodes/edges, updates changed ones,
prunes removed ones — rather than dropping the collections, so a concurrent reader never sees an
empty graph and unchanged embeddings are reused (only changed text
is re-embedded). Pass --fresh for a full clean rebuild. A progress bar tracks the parse
→ embed → sync phases so a large project doesn't look frozen.
Keeping it fresh automatically (dev)
Run the dev server with an embedding key configured (SOLI_EMBEDDING_API_KEY) and the
graph reindexes itself whenever you save a .sl/.slv file — no flag
needed, it just stays current while you work.
soli serve . --dev # auto-reindex on (embedding key present)
It rides the dev file-watcher (debounced), runs on a background thread (off the request path), and
reuses the live route table instead of re-executing routes.sl. It's
incremental on the expensive part: nodes whose text is unchanged
keep their existing embedding and only what changed is re-embedded, so a one-file save costs a
re-parse and a handful of embeddings — not thousands — and the semantic layer stays intact.
Set SOLI_GRAPH_WATCH=0 to disable, or =1 to force it on even without a key.
Configuration & flags
By default every node is embedded (vector index included). Configure the embedding provider with the
standard variables (OpenAI by default); the graph connects to the
same SolidB the app's Models use
(SOLIDB_HOST, SOLIDB_DATABASE, credentials), loaded from your
.env just like soli db:seed.
| Variable | Default |
|---|---|
SOLI_EMBEDDING_API_KEY | required to embed |
SOLI_EMBEDDING_URL | .../v1/embeddings |
SOLI_EMBEDDING_MODEL | text-embedding-3-small |
SOLI_EMBEDDING_TIMEOUT_SECS | 60 — per-request timeout; bounds a slow/unreachable endpoint |
soli graph build appears to hang?
It embeds by default, so a slow or unreachable SOLI_EMBEDDING_URL is the usual cause.
Each embedding request times out after SOLI_EMBEDDING_TIMEOUT_SECS (default 60s) and
fails with a clear message instead of blocking forever. To rule embeddings out entirely, re-run
with --no-embed.
| Flag | Effect |
|---|---|
--no-embed | Structural graph only — skip embeddings and the vector index (fully offline, no API key). Existing embeddings are preserved. |
--database NAME | Write to a specific database instead of SOLIDB_DATABASE. |
--dry-run | Print the whole graph as JSON to stdout — writes nothing to SolidB and calls no embedding API. Great for inspection and CI. |
--fresh | Force a full clean rebuild (drop + recreate) instead of the default incremental sync — e.g. after changing the embedding model. |
# Inspect what would be built, without a database or API key
soli graph build --dry-run | jq '.nodes | length'
Any codebase (multi-language)
soli graph isn't limited to Soli apps — point it at any
repository and pick which files to index. Storage, embeddings, incremental sync and
soli graph query are identical; only the extractor changes. SolidB settings come from the
project's .env, just like a Soli app — a non-Soli repo won't have these, so add them:
SOLIDB_HOST=http://localhost:6745 # required
SOLIDB_DATABASE=myapp_codegraph # required (any name; created on first write)
SOLIDB_USERNAME=admin # required for auth …
SOLIDB_PASSWORD=secret # … (or SOLIDB_JWT / SOLIDB_API_KEY)
# optional — only to embed for semantic search; else use --no-embed
SOLI_EMBEDDING_API_KEY=sk-...
# index a Rails app: Ruby + templates
soli graph build /path/to/rails-app --ext rb,erb,slim
# or commit a .soligraph.toml and just run:
soli graph build
Structural extraction (tree-sitter): Ruby, Python, JavaScript/JSX,
TypeScript/TSX, Rust and C# get real class/module/method/function
nodes plus inherits, implements and imports edges. C# also gets a call graph
— method-body calls and new → instantiates edges, attributed to
the enclosing method. Every other extension (.erb, .slim, config, …) is
chunk-embedded — split into windows and embedded — so semantic
search still covers it, without structural edges.
extensions = ["rb", "erb", "slim"] # what to index
exclude = ["spec/", "db/migrate/"] # path substrings to skip
chunk_lines = 50 # window for chunk-embedded files
Flags override the file (--ext, --exclude, --config); sensible
directories are skipped by default (.git, node_modules, vendor,
tmp, target, dot-dirs, …).
Call graph
Cross-language resolution is precision-first. For C#, method
bodies are walked for calls and new X() instantiates references;
an instantiates edge lands only on a project class (framework types like
new List<T>() are skipped, never stubbed), and a calls edge lands only
when exactly one project method carries that name — C#'s heavy overloading means shared names
(ToString, Add…) are dropped rather than mis-linked. Treat it as a
high-confidence subset, not exhaustive; the other foreign languages emit structure only
(inherits/implements/imports). --fresh,
--dry-run, --no-embed, incremental MD5 skipping and non-destructive sync all
work the same as for Soli apps.
Schema
Two SolidB collections, namespaced so they never clash with your app data.
soli_graph_nodes holds one document per code entity;
_key is a sanitized, stable id and the human-readable identity is
kind + qualified_name (with file, line,
signature, doc, role, and the embedded text/embedding).
soli_graph_edges holds one document per relationship as a SolidB
edge (_from / _to reference soli_graph_nodes/<key>):
edge_kind | From → To |
|---|---|
defines | file → class/function, class → method (containment) |
inherits | class → superclass (an external stub for framework bases like Model) |
implements | class → interface |
imports | file → local file |
calls | method/function → the function or class-method it calls |
instantiates | method/function → class it constructs (new X()) |
renders | controller action → view (render/partial); view → partial view |
redirects | method/function → route whose path matches redirect("/path") (prefers the GET route when a path is served by several verbs) |
routes_to | route → controller action |
relates | model → model (has_many/belongs_to/edge; DSL name in relation) |
Precision-first call graph
Linked when high-confidence: class-method calls (User.find(...)),
this.method(...), bare super(...)/super.method(...),
unambiguous bare functions, and instance calls on a locally typed variable
(let u = new User(), let u: User = …, or
let u = User.find(...) then u.authenticate(...)). Unbound receivers
(user.save() with no known class) stay unlinked — an edge is never invented.
Reassigning a tracked local to a value with no known class drops its type, and a bare
partial("form") in a view resolves against that view's own directory first.
def create(req: Any) -> Any {
let user = User.find_by("email", req["email"]) # binds user → User
user.authenticate(req["password"]) # calls → User#authenticate
partial("sessions/form") # renders → sessions/_form
return redirect("/dashboard") # redirects → matching route
}
Querying the graph (for agents)
The easy path: soli graph query
One command turns a natural-language task into the most relevant code plus
its immediate relationships — the graph-RAG payoff (semantic seed → graph
expansion) with no AQL to write. It embeds the question, ANN-searches the vector index for seeds, and
expands each one hop (callers, callees, routes, views). Built with --no-embed or no
embedding key? It falls back to a weighted keyword-ranked scan
(name/qualified_name beat body text), so it always works.
soli graph query "where is authentication handled?"
soli graph query "refund flow" --json --limit 5 --hops 1
soli graph query "invoice validation" --path api/ # scope to one side of a mono-repo
soli graph query "login" --kind method,controller # only those node kinds
--json emits a structured result an agent parses directly (each seed with
kind, qualified_name, file/line,
signature, truncated snippet, score, and a
neighbors list of {direction, edge_kind, kind, name, signature?}).
--limit sets seed count (default 6), --hops the expansion depth
(default 1). --path PREFIX keeps only seeds whose file starts with
PREFIX; --kind KINDS keeps only seeds whose kind is in the
comma-separated list (e.g. method,controller,route). Neighbours are unaffected and
ordered with structural edges first (routes_to, calls,
renders, redirects, …). Semantic search over-fetches then filters;
the keyword fallback filters in AQL. The heavy embedding field is never included.
Raw queries
For anything the command doesn't cover, agents query SolidB directly. Two moves: find nodes (by name, kind, or semantic similarity), then traverse from them.
Traversal starts use soli_graph_nodes/<_key>, not n._id
(SolidB's _id carries a db: prefix that won't match edge endpoints).
Build the start with CONCAT("soli_graph_nodes/", n._key). Traversals support the
FOR v, e IN … (vertex + edge) form.
Find code
// All controllers
FOR n IN soli_graph_nodes FILTER n.kind == "controller" RETURN n.name
// A specific route
FOR n IN soli_graph_nodes
FILTER n.kind == "route" AND n.qualified_name == "sessions#create"
RETURN n
Semantic search (needs embeddings) uses the node_vec vector index — model the
collection from Soli and call similar / graph_rag:
class CodeNode < Model
collection "soli_graph_nodes"
vector_index "embedding", dimension: 1536, metric: "cosine"
end
# "Where is authentication handled?" -> the most relevant code nodes
let hits = CodeNode.similar("authentication and login", field: "embedding", k: 8)
Traverse relationships
// What handles this route, and what does it reach? (route -> action -> calls/renders)
FOR v, e IN 1..3 OUTBOUND "soli_graph_nodes/route:GET_:login" soli_graph_edges
RETURN { via: e.edge_kind, kind: v.kind, name: v.qualified_name }
// Which actions render this view? (reverse edges)
FOR v, e IN 1..1 INBOUND "soli_graph_nodes/view:posts:index" soli_graph_edges
FILTER e.edge_kind == "renders"
RETURN v.qualified_name
// What does a controller action call?
FOR v, e IN 1..2 OUTBOUND "soli_graph_nodes/method:PostsController.create" soli_graph_edges
FILTER e.edge_kind IN ["calls", "instantiates"]
RETURN v.qualified_name
Semantic seed → traverse (graph RAG) combines both:
similar() finds the relevant nodes, then an OUTBOUND/INBOUND traversal expands to their
callers, callees, routes and views — exactly the context an agent needs to make a change.
Re-run soli graph build any time to refresh — it's incremental (hashes files, skips
when unchanged, updates in place), and in dev it auto-reindexes on save. See the full reference in
www/docs/graph.md.