It's Just Vectors

Call Graphs and the embed graph Command

  ·  8 min read

Search for “database connection pooling”:

./embed search "database connection pooling" --top 3
1. db/connection.go :: Connect      sim: 0.8341
2. db/connection.go :: Ping         sim: 0.7812
3. db/query.go      :: Execute      sim: 0.7234

Connect is correct. Now read it:

func Connect(ctx context.Context, cfg Config) (*sql.DB, error) {
    return withRetry(ctx, cfg.MaxRetries, func() (*sql.DB, error) {
        db, err := sql.Open(cfg.Driver, cfg.DSN)
        // ...
    })
}

Connect calls withRetry. You cannot understand the retry behavior of the connection pool without reading withRetry. But withRetry scored 0.41 on this query — it talks about retries and backoff, not databases or pooling. Embeddings measure textual similarity. The relationship between Connect and withRetry is structural: one calls the other. That relationship is invisible to the vector index.

A call graph records it.

What a call graph adds #

The vector index answers “which functions are about this concept?” A call graph answers “which functions are structurally connected to this function?” Both questions matter. A function retrieved by vector search is rarely self-contained — it calls helpers, delegates to utilities, wraps lower-level operations. The retrieval hit is the entry point. The call graph is the context.

One hop in the call graph means: for each retrieved function, also surface the functions it calls (callees) and the functions that call it (callers). Re-rank the combined set by vector similarity so the most relevant results still come first, but the structurally connected ones appear in the list even if their text scored low.

Building the call graph #

The call graph is built once, at index time, alongside the embeddings. The result is a CallGraph type — a map from function ID to the list of called function IDs:

type CallGraph map[string][]string

IDs use the same format as the indexer: "path/to/file.go::FunctionName". One map entry per function that has at least one call edge in the corpus.

Walking the AST for call sites:

func Build(dir string, docs []indexer.Document) (CallGraph, error) {
    // Build a name → ID lookup from the already-indexed documents.
    // The indexer's AST walk extracted function names correctly,
    // including methods with multi-line signatures.
    nameToID := make(map[string]string, len(docs))
    for _, d := range docs {
        nameToID[d.Func] = d.ID
    }

    graph := make(CallGraph)

    filepath.WalkDir(dir, func(path string, ...) error {
        // parse each .go file...
        for _, decl := range f.Decls {
            fn, ok := decl.(*ast.FuncDecl)
            if !ok || fn.Body == nil {
                continue
            }
            callerID := fmt.Sprintf("%s::%s", relPath, fn.Name.Name)

            ast.Inspect(fn.Body, func(n ast.Node) bool {
                call, ok := n.(*ast.CallExpr)
                if !ok {
                    return true
                }

                var calleeName string
                switch expr := call.Fun.(type) {
                case *ast.Ident:
                    calleeName = expr.Name          // direct call: withRetry(...)
                case *ast.SelectorExpr:
                    calleeName = expr.Sel.Name      // method call: s.Set(...)
                }

                if calleeID, ok := nameToID[calleeName]; ok && calleeID != callerID {
                    graph[callerID] = appendUnique(graph[callerID], calleeID)
                }
                return true
            })
        }
        return nil
    })

    return graph, err
}

Two things to note. First, name-based matching: if the corpus has exactly one withRetry, the match is unambiguous. Multiple functions with the same name in different packages cannot be disambiguated without type information — that requires go/types and is outside the scope of this tutorial. For most single-codebase scenarios, name-based resolution finds most of the edges that matter.

Second, *ast.SelectorExpr handles both method calls (s.Set(key)) and package-qualified calls (fmt.Println). The receiver or package name is ignored; only the selector name is matched. Standard library calls like fmt.Println simply won’t appear in nameToID, so they’re skipped silently.

The broken-signature problem #

There is a specific trap worth examining. The Build function uses nameToID to map function names to document IDs. That lookup is built from the indexed documents, which in turn came from the indexer’s AST walk. This is the correct approach.

The trap appears if you consider building a parallel name lookup by scanning source text for function declarations instead:

// BROKEN: line-by-line scan
for _, line := range strings.Split(string(src), "\n") {
    trimmed := strings.TrimSpace(line)
    if strings.HasPrefix(trimmed, "func ") {
        name := extractFuncName(trimmed)
        nameToID[name] = relPath + "::" + name
    }
}

This fails on any function with a multi-line signature. A function like:

func withRetry(
    ctx context.Context,
    maxAttempts int,
    fn func() error,
) error {
    // ...
}

gives the first line func withRetry( to the scanner. Depending on extractFuncName, you might extract withRetry correctly here. But consider a method receiver:

func (s *Store) SetWithExpiry(
    key string,
    value any,
    ttl time.Duration,
) error {
    // ...
}

A line-scanner sees func (s *Store) SetWithExpiry( and must parse the receiver before reaching the name. Getting this right — for all valid Go type syntax that can appear in a receiver or parameter list — is effectively reimplementing the Go parser.

The TestBuild_MultiLineSignature unit test demonstrates this concretely. It passes a function with a multi-line signature through Build and asserts that the call edge is correctly recorded. Running that test against a line-scanning implementation exposes the failure. Running it against the AST implementation confirms the fix.

func TestBuild_MultiLineSignature(t *testing.T) {
    src := `package example

func MultiLine(
    a int,
    b int,
) int {
    return helper(a, b)
}

func helper(a, b int) int { return a + b }
`
    // ...
    if !contains(g["example.go::MultiLine"], "example.go::helper") {
        t.Errorf("MultiLine should call helper even with multi-line signature")
    }
}

The fix, already in Build: don’t scan lines. Use the indexed documents.

nameToID := make(map[string]string, len(docs))
for _, d := range docs {
    nameToID[d.Func] = d.ID  // d.Func came from fn.Name.Name in the indexer
}

d.Func was extracted by fn.Name.Name in the indexer’s AST walk. The parser got it right. Trust the parser.

Storing and loading the call graph #

The call graph is stored as JSON alongside the vector index, written at --index time:

func Save(indexDir string, g CallGraph) error {
    data, _ := json.MarshalIndent(g, "", "  ")
    return os.WriteFile(filepath.Join(indexDir, "callgraph.json"), data, 0644)
}

func Load(indexDir string) (CallGraph, error) {
    data, err := os.ReadFile(filepath.Join(indexDir, "callgraph.json"))
    // ...
    var g CallGraph
    return g, json.Unmarshal(data, &g)
}

At search time, the reverse graph is built in memory from the stored forward graph:

func Reverse(g CallGraph) CallGraph {
    rev := make(CallGraph, len(g))
    for caller, callees := range g {
        for _, callee := range callees {
            rev[callee] = appendUnique(rev[callee], caller)
        }
    }
    return rev
}

One-hop expansion #

ScoredResult wraps a chromem.Result with a provenance annotation:

type ScoredResult struct {
    chromem.Result
    Annotation string // "[match]", "[callee of Func]", "[caller of Func]"
}

The expansion function takes the top-K primary results and adds their neighbours:

func ExpandOneHop(
    primary []chromem.Result,
    topN int,
    forward CallGraph,
    reverse CallGraph,
    allByID map[string]chromem.Result,
) []ScoredResult {
    seen := make(map[string]bool, topN*4)
    expanded := make([]ScoredResult, 0, topN*4)

    for _, r := range primary {
        seen[r.ID] = true
        expanded = append(expanded, ScoredResult{Result: r, Annotation: "[match]"})
    }

    for _, r := range primary {
        funcName := r.Metadata["func"]

        for _, calleeID := range forward[r.ID] {
            if !seen[calleeID] {
                if neighbour, ok := allByID[calleeID]; ok {
                    seen[calleeID] = true
                    expanded = append(expanded, ScoredResult{
                        Result:     neighbour,
                        Annotation: fmt.Sprintf("[callee of %s]", funcName),
                    })
                }
            }
        }

        for _, callerID := range reverse[r.ID] {
            if !seen[callerID] {
                if neighbour, ok := allByID[callerID]; ok {
                    seen[callerID] = true
                    expanded = append(expanded, ScoredResult{
                        Result:     neighbour,
                        Annotation: fmt.Sprintf("[caller of %s]", funcName),
                    })
                }
            }
        }
    }

    sort.SliceStable(expanded, func(i, j int) bool {
        return expanded[i].Similarity > expanded[j].Similarity
    })
    if len(expanded) > topN {
        expanded = expanded[:topN]
    }
    return expanded
}

allByID is built from the full col.QueryEmbedding response — all documents scored against the query in one call. chromem.QueryEmbedding requires nResults <= col.Count(); passing n = col.Count() returns the complete ranked list. The expansion reuses those pre-computed scores without any additional API calls.

The --graph flag #

--graph is added to the existing search command. At --index time, the call graph is built and saved alongside the embedding index. At search time, it’s loaded and the reverse graph is built in memory.

./embed search "database connection pooling" --graph --top 5
1. db/connection.go :: Connect           sim: 0.8341  [match]
2. db/connection.go :: Ping              sim: 0.7812  [match]
3. db/query.go      :: Execute           sim: 0.7234  [match]
4. db/retry.go      :: withRetry         sim: 0.4102  [callee of Connect]
5. db/query.go      :: Transaction       sim: 0.6891  [caller of Execute]

withRetry at rank 4 with similarity 0.41. Without --graph it doesn’t appear in the top 5 at all. Transaction at rank 5 also calls Execute, so it appears both as a vector match and is now annotated to confirm the structural relationship.

Combine with --explain from Part 9:

./embed search "database connection pooling" --graph --explain --top 5

withRetry’s explanation will note that the function is not directly about connection pooling but implements the retry logic that Connect depends on. That’s the retrieval context that vector search alone can’t surface.

What the graph cannot do #

Name-based call resolution misses any call that goes through an interface. If Connect calls pool.Acquire() and pool is a ConnectionPool interface, the AST sees a SelectorExpr whose receiver is the variable pool. Without type information, we don’t know which concrete type pool is at runtime.

This is the boundary between static analysis and type-aware analysis. For direct function calls and method calls on concrete types, the graph is accurate. For interface dispatch, it’s blind. A production tool would use golang.org/x/tools/go/callgraph with pointer analysis to resolve this, at substantially higher setup and analysis cost.

For a single indexed codebase, name-based resolution finds most of the structurally important edges. The gap is worth knowing about, not papering over.

Getting started #

cd tutorial/part10/

complete/ has the full implementation. start/ has Build, Reverse, and ExpandOneHop stubbed out in graph/graph.go. The call graph wiring in cmd/search.go — indexing, loading, and passing the graph to the query path — is intact so the command compiles. Run go test ./... to see the failing graph tests; they will pass once the three functions are implemented.

export OPENAI_API_KEY="sk-your-key-here"

cd start/
go mod tidy
go build -o embed .
./embed search --index --enrich --provider openai --model text-embedding-3-small
./embed search "database connection pooling" --graph --top 5
./embed search "database connection pooling" --graph --explain --top 5

The tutorial repository and all code: rikdc/semantic-search-experiments