ESC
Type to search...
S
Soli Docs

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.

VariableDefault
SOLI_EMBEDDING_API_KEYrequired to embed
SOLI_EMBEDDING_URL.../v1/embeddings
SOLI_EMBEDDING_MODELtext-embedding-3-small
SOLI_EMBEDDING_TIMEOUT_SECS60 — 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.

FlagEffect
--no-embedStructural graph only — skip embeddings and the vector index (fully offline, no API key). Existing embeddings are preserved.
--database NAMEWrite to a specific database instead of SOLIDB_DATABASE.
--dry-runPrint the whole graph as JSON to stdout — writes nothing to SolidB and calls no embedding API. Great for inspection and CI.
--freshForce 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_kindFrom → To
definesfile → class/function, class → method (containment)
inheritsclass → superclass (an external stub for framework bases like Model)
implementsclass → interface
importsfile → local file
callsmethod/function → the function or class-method it calls
instantiatesmethod/function → class it constructs (new X())
renderscontroller action → view (render/partial); view → partial view
redirectsmethod/function → route whose path matches redirect("/path") (prefers the GET route when a path is served by several verbs)
routes_toroute → controller action
relatesmodel → 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.