It's Just Vectors

When Embeddings Lie: Fixing Collisions and Noise with Context and Rank Fusion

  ·  11 min read

Part 6 left specific failures on the table. db.Execute() and cli.Execute() scored within 0.005 of each other for the query “run a command.” getTimeout() ranked correctly, but cache.Set() followed at 0.72. And if you already knew which package you wanted, the search had no way to constrain itself there.

Each failure has a cause. This part isolates them and fixes them one at a time.

Fix one: enriched chunks #

Part 6 embedded the function body alone:

docs = append(docs, Document{
    ID:      fmt.Sprintf("%s::%s", path, fn.Name.Name),
    Content: body,   // ← just the body
    ...
})

The body of db.Execute() looks roughly like:

func Execute(query string, args ...interface{}) (sql.Result, error) {
    stmt, err := db.Prepare(query)
    if err != nil {
        return nil, err
    }
    defer stmt.Close()
    return stmt.Exec(args...)
}

The body of cli.Execute() looks roughly like:

func Execute() error {
    if err := rootCmd.Execute(); err != nil {
        return err
    }
    return nil
}

Both are short. Both handle errors the idiomatic Go way. The name Execute is just a string — it tells the model nothing about which package it belongs to. One runs SQL, the other is the Cobra entry point. The text fed to the model was similar enough that the vectors landed close together.

Part 4 solved the same structural problem for transaction data: a raw amount like 54.20 teaches the model about a number, but a synthetic string like "Transaction: 54.20 USD at Whole Foods at 11:00 AM" teaches it about a transaction. The same idea applies here. Strip the context and you strip the signal.

The fix is to construct a synthetic string before embedding — one that names the file, package, and function before showing the body:

func enrichContent(file, pkg, receiver, name, body string) string {
    if receiver != "" {
        return fmt.Sprintf(
            "File: %s | Package: %s | Function: (%s) %s\n\n%s",
            file, pkg, receiver, name, body,
        )
    }
    return fmt.Sprintf(
        "File: %s | Package: %s | Function: %s\n\n%s",
        file, pkg, name, body,
    )
}

Now db.Execute() gets embedded as:

File: db/query.go | Package: db | Function: Execute

func Execute(query string, args ...interface{}) (sql.Result, error) {
    stmt, err := db.Prepare(query)
    ...
}

And cli.Execute() gets embedded as:

File: cli/root.go | Package: cli | Function: Execute

func Execute() error {
    if err := rootCmd.Execute(); err != nil {

db and cli land in different parts of embedding space. The model has seen enough text that those package names carry domain signal. Prepending them before embedding shifts the vectors apart.

The receiver extraction from the AST:

var receiver string
if fn.Recv != nil && len(fn.Recv.List) > 0 {
    field := fn.Recv.List[0]
    switch t := field.Type.(type) {
    case *ast.StarExpr:
        if ident, ok := t.X.(*ast.Ident); ok {
            receiver = "*" + ident.Name
        }
    case *ast.Ident:
        receiver = t.Name
    }
}

This handles value and pointer receivers. It breaks on generic receivers like func (s *Store[K, V]), but the synthetic codebase does not use generics, so it covers everything in internal/.

Re-index with enrichment and run the collision query:

./embed search --index --enrich --provider openai --model text-embedding-3-small
./embed search "run a command" --top 3
1. cli/root.go  :: Execute    sim: 0.8654
2. cli/flags.go :: ParseFlags sim: 0.7821
3. db/query.go  :: Execute    sim: 0.6103

db.Execute() dropped from 0.8287 to 0.6103. The gap is now 0.25 instead of 0.005.

Fix two: namespace filtering #

Some queries are ambiguous without context. Some are not. If you know you want a function from the db package, a cosine search across the entire index is doing unnecessary work — and it can lose to noise from unrelated packages.

The --package flag adds hard filtering before scoring. Only documents whose file path matches the given package directory are considered:

func filterByPackage(docs []Document, pkg string) []Document {
    if pkg == "" {
        return docs
    }
    var filtered []Document
    for _, d := range docs {
        if strings.HasPrefix(d.File, pkg+"/") || strings.HasPrefix(d.File, pkg) {
            filtered = append(filtered, d)
        }
    }
    return filtered
}

Run the collision query with a package constraint:

./embed search "run a command" --package cli --top 3
1. cli/root.go  :: Execute    sim: 0.8654
2. cli/flags.go :: ParseFlags sim: 0.7821
3. cli/flags.go :: getTimeout sim: 0.7103

db.Execute() is not in the results because it was never scored. Hard filtering by directory is more effective than soft scoring when the domain is already known. The embedding model does not need to distinguish db from cli if the db package is never in the candidate set.

This is not always applicable — it only helps when the caller knows which package they want. But when they do, it is the simplest and most reliable fix in this part. No re-indexing required. No additional retrieval step. The flag filters before embedding the query.

Fix three: boilerplate noise and RRF #

Context enrichment does not help getTimeout(). Run that query on the enriched index:

./embed search "how long before a request times out" --top 3
1. cli/flags.go :: getTimeout    sim: 0.7841
2. cache/store.go :: Set         sim: 0.7203
3. db/connection.go :: Ping      sim: 0.7104

getTimeout() is correct, but cache.Set() is still second. The problem is not the embedding. Short functions do not contain much distinguishing signal. A five-line function that sets a cache value and a five-line function that reads a timeout share more text (variable declarations, return statements, if err != nil) than either shares with its actual purpose. The boilerplate dimensions outvote the semantic ones.

Keyword search has the opposite profile. It ignores meaning and looks for token overlap. A query containing “timeout” scores functions containing that token highly. cache.Set() contains no timeout-related tokens, so it scores low. The correct answer rises without any semantic reasoning.

Dense search and keyword search fail in different directions. That is the situation Reciprocal Rank Fusion was designed for.

The tokenizer #

Before the keyword scoring can work on Go code, the tokenizer needs to handle camelCase. A standard tokenizer that splits only on punctuation and whitespace will turn getTimeout into the single token gettimeout — it won’t match a query containing “timeout” as a separate word.

The fix is to also split on transitions from a lowercase letter to an uppercase one:

func tokenize(s string) []string {
    // First split on non-letter/non-digit boundaries (whitespace, punctuation)
    var parts []string
    current := strings.Builder{}
    runes := []rune(strings.ToLower(s))

    for i, r := range []rune(s) {
        if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
            if current.Len() > 0 {
                parts = append(parts, strings.ToLower(current.String()))
                current.Reset()
            }
            continue
        }
        // Split on camelCase boundary: lowercase followed by uppercase
        if i > 0 && unicode.IsUpper(r) && unicode.IsLower([]rune(s)[i-1]) {
            if current.Len() > 0 {
                parts = append(parts, strings.ToLower(current.String()))
                current.Reset()
            }
        }
        current.WriteRune(runes[i])
    }
    if current.Len() > 0 {
        parts = append(parts, current.String())
    }
    return parts
}

With this tokenizer, getTimeout splits into ["get", "timeout"] and ValidateToken splits into ["validate", "token"]. A query for “timeout” now matches getTimeout on the token "timeout".

The keyword scorer counts matched query tokens, normalised by query length:

func tokenizeSet(s string) map[string]bool {
    tokens := tokenize(s)
    set := make(map[string]bool, len(tokens))
    for _, t := range tokens {
        set[t] = true
    }
    return set
}

func keywordScore(query, content string) float32 {
    queryTokens := tokenize(query)
    docTokens := tokenizeSet(content)

    var matches int
    for _, qt := range queryTokens {
        if docTokens[qt] {
            matches++
        }
    }

    if len(queryTokens) == 0 {
        return 0
    }
    return float32(matches) / float32(len(queryTokens))
}

Note that queryTokens is a slice, so repeated query tokens count multiple times in both numerator and denominator. For short natural-language queries this rarely matters, but it is worth knowing the scorer is not deduplicating.

RRF #

RRF does not average scores. It averages ranks.

Each retriever produces an ordered list. RRF assigns a score based on position in that list, then sums the contributions across retrievers. A document that ranks first in both systems scores well. A document that ranks first in one and fortieth in the other scores worse.

The formula:

RRF(d) = Σ 1 / (k + rank_r(d))

The sum runs over each retriever r. rank_r(d) is the 1-indexed position of document d in retriever r’s results. k is a constant. The canonical value is 60.

The purpose of k=60 is rank-based smoothing. It ensures that being ranked first is worth more than being ranked second, but not dramatically so — the formula rewards consistent performance across retrievers more than it rewards a single top ranking. A document that ranks 1st in one list and 5th in the other will generally beat a document that ranks 1st in one list and 20th in the other. Changing k shifts how much weight the top positions carry. Leaving it at 60 matches the original paper and is a safe default.

A concrete example with two retrievers and three documents:

Dense results:    getTimeout(1), cache.Set(2), db.Ping(3)
Keyword results:  getTimeout(1), db.Ping(2),   cache.Set(3)

RRF scores:
getTimeout: 1/(60+1) + 1/(60+1) = 0.01639 + 0.01639 = 0.03279
db.Ping:    1/(60+3) + 1/(60+2) = 0.01587 + 0.01613 = 0.03200
cache.Set:  1/(60+2) + 1/(60+3) = 0.01613 + 0.01587 = 0.03200

getTimeout() wins because it ranked first in both. db.Ping() and cache.Set() tie because they swapped positions — neither had a consistent advantage. Equal RRF scores are broken by document ID as a secondary sort key, which is stable:

const rrfK = 60

func fuseRRF(denseRanks, keywordRanks []string) []RankedResult {
    scores := make(map[string]float64)

    for i, id := range denseRanks {
        scores[id] += 1.0 / float64(rrfK+i+1)
    }
    for i, id := range keywordRanks {
        scores[id] += 1.0 / float64(rrfK+i+1)
    }

    results := make([]RankedResult, 0, len(scores))
    for id, score := range scores {
        results = append(results, RankedResult{ID: id, Score: score})
    }

    // Primary: score descending. Secondary: ID ascending for stable tie-breaking.
    sort.Slice(results, func(i, j int) bool {
        if results[i].Score != results[j].Score {
            return results[i].Score > results[j].Score
        }
        return results[i].ID < results[j].ID
    })

    return results
}

fuseRRF takes ranked lists of document IDs, not scores. The dense similarity values and keyword overlap ratios are both discarded. Only rank position feeds into the result. A dense score of 0.83 and a keyword score of 0.61 cannot be compared directly. Rank 1 and rank 1 are the same thing regardless of how either retriever produced them. This is what makes RRF practical: the two retrievers do not need to be calibrated against each other.

Run the boilerplate query with --hybrid:

./embed search "how long before a request times out" --hybrid --top 3
1. cli/flags.go :: getTimeout    rrf: 0.03279
2. db/connection.go :: Ping      rrf: 0.01942
3. auth/token.go :: RefreshToken rrf: 0.01587

cache.Set() is gone. It scored reasonably on dense similarity but ranked low on keyword overlap. RRF combined those two signals and dropped it out of the top three.

The collision query with --hybrid:

./embed search "run a command" --hybrid --top 3
1. cli/root.go  :: Execute    rrf: 0.03112
2. cli/flags.go :: ParseFlags rrf: 0.02941
3. db/query.go  :: Execute    rrf: 0.01587

Context enrichment did most of that work. The keyword retriever also helps here because the CLI code contains more query-aligned lexical tokens than the db package code does. The two fixes do not interfere with each other.

Benchmark #

./embed search --benchmark
Benchmark: 18 queries, top-1 accuracy

                       Queries correct   Accuracy
Baseline               11 / 18           61%
+ Enriched chunks      13 / 18           72%
+ Namespace filtering  14 / 18           78%
+ RRF hybrid           15 / 18           83%

Each technique addresses a different failure mode:

  • Enrichment fixes the name-collision cases (11 → 13 correct)
  • Namespace filtering fixes the cases where the domain is already known (13 → 14 correct)
  • RRF fixes the boilerplate-noise cases where keyword overlap rescues short functions (14 → 15 correct)

They stack because they operate at different layers: what gets embedded, which candidates are considered, and how two ranked lists are combined. Removing any one of them degrades a different slice of queries.

83% on 18 queries is not a production number. The query set is small, the corpus is synthetic, and top-1 is a strict criterion. Run --benchmark on your own codebase and you will get a different number. The delta across techniques is what to pay attention to.

Getting started #

cd tutorial/part7/

complete/ has the working implementation. start/ has enrichContent(), filterByPackage(), tokenize(), and fuseRRF() stubbed out. The unit tests cover the RRF formula against hand-crafted ranked lists and the camelCase tokenizer against known inputs. Verify those before touching the API.

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 "how do I connect to the database" --hybrid --top 3
./embed search "run a command" --package cli --top 3
./embed search --benchmark

What’s next #

All three fixes in this part operate on the retrieval pipeline — what gets indexed, which documents are candidates, how ranked lists are combined. The query itself is treated as a given.

Part 8 changes that. The --boost and --suppress flags take the query vector and move it mathematically before retrieval runs, pushing it toward concepts that matter and away from ones that don’t. The enriched index from this part carries forward unchanged.


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