Hybrid Search for Code: BM25, Vectors, and Symbol Indexes Together

Hybrid Search for Code: BM25, Vectors, and Symbol Indexes Together

Embeddings alone are bad at exact identifiers. Ask for InvoiceReconcilerV2 after a rename and vector recall returns “similar billing helpers” while BM25 and a symbol index would have hit the definition in one shot. Production code RAG needs three channels: lexical (BM25), semantic (vectors), and structural (symbols / ctags / SCIP).

⚡ TL;DR: Run BM25 + kNN in parallel, union candidates, then rerank. Always maintain a symbol index keyed by name → file:line. Boost exact identifier matches above semantic neighbors. Evaluate with identifier-heavy queries, not only natural-language ones. See Bedrock Knowledge Bases: Chunking Strategies That Fit Code RAG and RAG Evaluation on AWS.

Three indexes, one query planner

type HybridHit = {
  path: string;
  startLine: number;
  endLine: number;
  score: number;
  channel: "bm25" | "vector" | "symbol";
};

export async function hybridSearch(q: string, opts: { tenantId: string; k: number }) {
  const identifiers = extractIdentifiers(q); // CamelCase, snake_case, dotted paths
  const [bm25, vectors, symbols] = await Promise.all([
    openSearchBm25(q, opts),
    openSearchKnn(embed(q), opts),
    symbolLookup(identifiers, opts),
  ]);

  // ✅ Exact symbol hits get a hard boost — not a soft preference
  const merged = fuseRRF([
    { hits: symbols.map((h) => ({ ...h, score: h.score + 10 })), weight: 1.0 },
    { hits: bm25, weight: 1.0 },
    { hits: vectors, weight: 0.8 },
  ]);

  return merged.slice(0, opts.k);
}

function extractIdentifiers(q: string): string[] {
  return q.match(/\b[A-Z][a-zA-Z0-9]{2,}\b|\b[a-z]+(?:_[a-z0-9]+)+\b/g) ?? [];
}

❌ Single OpenSearch neural query with no lexical branch.
✅ Reciprocal Rank Fusion (RRF) or a learned reranker over the union.

Compare stores in OpenSearch vs Aurora pgvector for Codebase Chat — hybrid works on both if you keep BM25.

OpenSearch query that actually hybridizes

{
  "size": 40,
  "query": {
    "bool": {
      "filter": [{ "term": { "tenantId": "t_9f2a" } }],
      "should": [
        {
          "multi_match": {
            "query": "InvoiceReconcilerV2 settle ledger",
            "fields": ["path^3", "symbol^5", "content"],
            "type": "best_fields"
          }
        },
        {
          "knn": {
            "embedding": {
              "vector": ["/* 1024-d */"],
              "k": 40
            }
          }
        }
      ]
    }
  }
}

Stamp symbol, path, and tenantId at ingest the same way you do for Bedrock Retrieval Filters.

Symbol index that survives renames

# build_symbol_index.py — illustrative SCIP / ctags style
import json, subprocess, pathlib

def export_symbols(repo: pathlib.Path) -> list[dict]:
    # Prefer SCIP or LSIF when available; ctags as fallback
    raw = subprocess.check_output(
        ["ctags", "-R", "--fields=+n", "-f", "-", str(repo)],
        text=True,
    )
    out = []
    for line in raw.splitlines():
        if line.startswith("!"):
            continue
        name, path, *rest = line.split("\t")
        out.append({"name": name, "path": path, "kind": rest[-1] if rest else "unknown"})
    return out

def upsert_symbols(tenant: str, repo: str, symbols: list[dict], client):
    for s in symbols:
        client.index(
            index="code-symbols",
            id=f"{tenant}:{repo}:{s['path']}:{s['name']}",
            body={**s, "tenantId": tenant, "repoId": repo},
        )

Refresh on merge alongside embeddings — see Codebase Embeddings: Refresh Pipelines When Git SHAs Keep Moving.

Evaluation: identifier queries are non-negotiable

Query class Example Must hit
Exact symbol where is InvoiceReconcilerV2 definition file
Alias / rename old SettleInvoice helper new name + callers
Error string E_LEDGER_DRIFT throw site
NL only “how do we settle invoices” service module

If your offline harness only scores NL questions, you will ship a vector-only system that fails daily IDE use. Use the faithfulness/hit-rate harness from RAG Evaluation on AWS.

Closing checklist

  • [ ] BM25 and vector recall run in parallel with tenant filters on both
  • [ ] Symbol index maps name → path:line and boosts exact matches
  • [ ] Fusion via RRF or cross-encoder rerank — not max(score) across incompatible spaces
  • [ ] Identifier-heavy eval set with rename and error-code cases
  • [ ] Ingest stamps symbol, path, tenantId; sync on merge
  • [ ] Latency budget includes the slowest of the three channels + rerank

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply