It's Just Vectors

Moving Meaning: Vector Arithmetic and the embed steer Command

  ·  8 min read

Run this against the enriched index from Part 7:

./embed search "error handling"
1. cli/flags.go   :: getTimeout      sim: 0.7812
2. db/query.go    :: Execute         sim: 0.7654
3. cli/root.go    :: Execute         sim: 0.7541

None of those are wrong. They all handle errors. But they are also three of the most generic function bodies in the corpus — each one is roughly if err != nil { return nil, err } with different variable names. If you wanted structured logging, retry logic, or actual error wrapping, you won’t find it here. The retrieval pipeline is working correctly. The problem is that “error handling” as a query is pulling toward the most common form of error handling, which is the trivial form.

Context enrichment, namespace filtering, and RRF each operate on what gets indexed or which results survive. None of them touch the query. If the query vector is sitting in the wrong place, those fixes can’t help.

This part changes the query.

Directions carry meaning #

In 2013, Mikolov et al. published a result that became the most reproduced finding in NLP: king – man + woman ≈ queen. Not in arithmetic, but in the embedding space their Word2Vec model produced. The vector you get by subtracting the man vector from the king vector and adding the woman vector is closer to the queen vector than to almost anything else in the vocabulary.

It works because the model learned a consistent direction for the royalty concept and a separate direction for the gender concept. Those directions are reusable. You can apply the same displacement to prince and land near princess. To actor and land near actress. The direction man → woman encodes the gender relationship in a way that generalizes.

The concept generalises beyond word analogies. In your code corpus, there is a direction for “boilerplate error handling” — the region of embedding space where most Go functions land because idiomatic Go requires if err != nil everywhere. Functions you actually want for “error handling” queries — the ones with retry logic, structured logging, circuit breakers — sit in a different part of that space. If you know where the boilerplate centroid is, you can move the query away from it.

The geometry #

Every vector in embedding space has two properties: direction and magnitude. Cosine similarity only cares about direction. Two vectors with identical direction but different magnitudes score 1.0.

When you add two vectors, the result points somewhere between them, weighted by their magnitudes. To steer a query away from boilerplate, subtract a scaled version of the boilerplate centroid. The result points somewhere between the original query direction and the anti-boilerplate direction.

There is one problem: after the addition or subtraction, the magnitude changes. If the resulting vector is longer or shorter than the original query vector, the cosine similarity scores from the index won’t be comparable to an un-steered query. More importantly, some implementations of approximate nearest-neighbor search assume unit vectors.

The fix is normalization: after steering, divide the vector by its magnitude to put it back on the unit sphere. Cosine similarity measures angular distance — it’s direction only, magnitude ignored. Normalization enforces that the steered vector lives in the same space as everything else in the index.

steered = Normalize(query + (weight × boost_centroid))
steered = Normalize(query - (weight × suppress_centroid))

The weight controls how far to move. Too high and you lose the original query signal. Too low and the steering does nothing. Values between 0.1 and 0.5 are a reasonable starting range to tune.

Implementation #

The core function:

// SteerEmbedding shifts a query vector toward or away from a concept centroid.
// Vq is the query, Vb is the boost/suppress concept centroid, W is the weight.
// suppress=true subtracts (moves away), suppress=false adds (moves toward).
func SteerEmbedding(Vq, Vb []float32, W float32, suppress bool) ([]float32, error) {
    if len(Vq) != len(Vb) {
        return nil, fmt.Errorf("dimension mismatch: query %d, concept %d", len(Vq), len(Vb))
    }

    result := make([]float32, len(Vq))
    for i := range Vq {
        delta := W * Vb[i]
        if suppress {
            result[i] = Vq[i] - delta
        } else {
            result[i] = Vq[i] + delta
        }
    }

    return normalize(result)
}

func normalize(v []float32) ([]float32, error) {
    var mag float64
    for _, x := range v {
        mag += float64(x) * float64(x)
    }
    mag = math.Sqrt(mag)
    if mag == 0 {
        return nil, fmt.Errorf("zero-magnitude vector after steering")
    }

    result := make([]float32, len(v))
    for i, x := range v {
        result[i] = float32(float64(x) / mag)
    }
    return result, nil
}

float64 for the magnitude accumulation. The same reason as CosineSimilarity in Part 1 — accumulating float32 products introduces rounding error that only becomes visible when you expect a result close to 1.0 and get 0.9998 instead.

Testing the arithmetic before touching the API #

// Hand-crafted 3D vectors. Suppressing "boilerplate" moves the query
// toward the region containing "logging" and "retry logic".
query       := []float32{0.6, 0.3, 0.1} // "error handling" concept
boilerplate := []float32{0.8, 0.1, 0.1} // "if err != nil" centroid

steered, _ := SteerEmbedding(query, boilerplate, 0.3, true)

// steered should point away from boilerplate — smaller dot product with
// boilerplate than original query had.
origSim, _  := CosineSimilarity(query, boilerplate)
steerSim, _ := CosineSimilarity(steered, boilerplate)

// origSim > steerSim confirms the suppression worked.

The hand-crafted test verifies direction before any API call. If suppression increases the similarity to the boilerplate centroid, the sign is wrong somewhere. Catch it here.

Building the concept centroid #

The steering function needs a centroid for the concept you want to boost or suppress. That centroid is built the same way as the category centroids in Part 2: embed several representative examples, then average.

For suppressing boilerplate:

boilerplateSamples := []string{
    "if err != nil { return nil, err }",
    "if err != nil { return err }",
    "if err != nil { log.Fatal(err) }",
    "if err != nil { return nil, fmt.Errorf(\"...: %w\", err) }",
}

For boosting logging:

loggingSamples := []string{
    "log structured fields level info message",
    "logger.With(\"key\", value).Info(\"message\")",
    "slog.Error(\"operation failed\", \"err\", err, \"id\", id)",
}

These samples do not need to be real code from the indexed corpus. They need to represent the concept well enough that the centroid sits in the right region of embedding space. In practice, five to ten examples is enough.

The --boost and --suppress flags #

The flags extend embed search. The query vector is embedded first, then steered before being sent to the index:

./embed search "error handling" --suppress "if err != nil" --top 5
1. db/retry.go      :: withRetry        sim: 0.7923
2. auth/token.go    :: RefreshToken     sim: 0.7711
3. cache/store.go   :: SetWithExpiry    sim: 0.7634
4. cli/flags.go     :: getTimeout       sim: 0.7412
5. db/query.go      :: Execute          sim: 0.6801

The trivial nil-check functions are still in the results — they are not removed from the index — but they have dropped in rank. withRetry and RefreshToken each contain actual retry or refresh logic in addition to error propagation. The steering shifted the query enough that those functions score higher.

The same query with boost:

./embed search "error handling" --boost "structured logging" --top 5
1. auth/token.go  :: RefreshToken       sim: 0.7841
2. db/retry.go    :: withRetry          sim: 0.7803
3. cache/store.go :: SetWithExpiry      sim: 0.7612
4. cli/root.go    :: Execute            sim: 0.7201
5. db/query.go    :: Execute            sim: 0.7198

Both flags can be combined. Suppress the noise you know is there; boost the concept you’re actually after. The steering happens in sequence: suppress first, then boost, then normalize.

What doesn’t work #

Steering is a soft adjustment. It does not guarantee that the concept you want will appear in the top results — only that the query vector moves toward that region. If the corpus has no functions that match “structured logging” well, steering toward it won’t create them.

The weight also degrades gracefully in the wrong direction: too high and the steered vector is dominated by the boost/suppress centroid, effectively replacing the original query. At W=1.0 suppressing boilerplate, you are no longer searching for “error handling” — you are searching for “not if err != nil.” Tune W against results you care about.

A final boundary: steering moves the query. It does not change the index. The enriched chunks and RRF pipeline from Part 7 are still operating underneath. Steering and signal-cleaning are complementary, not alternatives.

Getting started #

cd tutorial/part8/

complete/ has the full implementation. start/ has SteerEmbedding and normalize stubbed out. The unit tests verify the suppression direction (steered vector should have lower cosine similarity to the suppressed centroid than the original query did) and the normalization (magnitude of the output should be 1.0, within floating-point tolerance).

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 "error handling" --suppress "if err != nil" --top 5
./embed search "error handling" --boost "structured logging" --top 5

What’s next #

The vector space is now navigable and steerable. What it still can’t do is explain why a given function answers a specific question. Part 9 adds that: retrieved functions are passed to an LLM with the original query, and the model explains which lines are relevant and why. The retrieval quality from Parts 6–8 determines whether that explanation is useful or a summary of noise.


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