Code

Semantic Code Search with Two-Stage Retrieval

Python
AI
RAG
MCP
ChromaDB
Code Search
Embeddings
Reranking

Model Context Protocol server for semantic codebase search. Fast bi-encoder retrieval gets top candidates, then cross-encoder reranking returns the most relevant results. Indexes multiple projects, handles dependencies filtering, ready for Claude integration.

MCP server architecture showing fast embedding retrieval followed by precise cross-encoder reranking

Visual Flow



QUERY: "authentication code"

   STAGE 1: Fast Retrieval
        ┌──────────────────────────────┐
        │  Qwen3-Embedding-0.6B        │
        │  (bi-encoder)                │
        │  • 1024 dims                 │       ┌──────────────────┐
        │  • <100ms                    │─────▶│ ChromaDB         │
        └──────────────────────────────┘       │ Vector Search    │
                                               │ • Cosine         │
                                               │ → Top 20         │
                                               └────────┬─────────┘
                                                        │
   STAGE 2: Precise Reranking                           ▼
        ┌────────────────────────────────────────────────────┐
        │  ms-marco-MiniLM-L-12-v2 (cross-encoder)           │
        │  • Full query-document attention                   │
        │  • Processes all 20 candidates                     │
        │  • Returns top N ranked by relevance               │
        └────────────────────┬───────────────────────────────┘
                             │
                             ▼
                    ┌──────────────────┐
                    │ Ranked Results   │
                    │ • File paths     │
                    │ • Code chunks    │
                    │ • Rerank scores  │
                    └──────────────────┘

Overview

MCP server for semantic code search. Two-stage retrieval: Qwen3-Embedding-0.6B gets top-20 candidates via ChromaDB vector search, then cross-encoder/ms-marco-MiniLM-L-12-v2 reranks with full query-document attention for final results.

Bi-encoders are fast but encode query and document independently - no interaction between them. Cross-encoders process both together with transformer attention - more accurate but O(n) complexity makes them too slow for initial retrieval. Split the work: fast model filters to ~20 candidates, slow model ranks those 20. Gets cross-encoder accuracy at bi-encoder speed.

Pre-built index approach. build-index.py reads project paths from projects.txt, recursively scans files while excluding node_modules/venv/.git, chunks into 800-char segments with 100-char overlap, generates embeddings, stores in ChromaDB. Run once. rag-mcp.py loads the database and serves search - no re-indexing on startup. 6 projects, 304 files, 19,600 chunks. Hash IDs include project name to handle duplicate paths across projects.

Claude Code MCP integration

Additional considerations for production use

Reasoning for model selection, auth, rate limiting, monitoring, incremental re-indexing, middleware.

Requirements | freeze after installing


chromadb
sentence-transformers
fastmcp
torch

rag-mcp.py


import hashlib
import os
import socket
import sys
from pathlib import Path

import chromadb
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
from sentence_transformers import CrossEncoder
from fastmcp import FastMCP

chroma_path = os.environ.get("CHROMA_PATH", "./chroma")
client = chromadb.PersistentClient(path=chroma_path)
try:
    import torch

    device = "cuda" if torch.cuda.is_available() else "cpu"
except Exception:
    device = "cpu"
embed = SentenceTransformerEmbeddingFunction(
    model_name="Qwen/Qwen3-Embedding-0.6B", 
    device=device,
)

rerank_model = os.environ.get("RAG_RERANK_MODEL", "cross-encoder/ms-marco-MiniLM-L-12-v2")
reranker = CrossEncoder(rerank_model, device=device)

root = Path(os.environ.get("RAG_ROOT", ".")).resolve()
max_chars = int(os.environ.get("RAG_CHUNK_SIZE", "800"))
overlap = int(os.environ.get("RAG_CHUNK_OVERLAP", "100"))
rerank_candidates = int(os.environ.get("RAG_RERANK_CANDIDATES", "20"))
documents = []
ids = []
metadatas = []


def _chunk_text(text: str, max_chars: int, overlap: int):
    if max_chars <= 0:
        return
    start = 0
    text_len = len(text)
    while start < text_len:
        end = min(start + max_chars, text_len)
        yield text[start:end], start, end
        if end == text_len:
            break
        start = max(0, end - overlap)

collection = client.get_or_create_collection(
    name="codebase",
    embedding_function=embed,
)

existing_count = collection.count()
if existing_count > 0:
    print(f"Using existing index with {existing_count} chunks", file=sys.stderr)
else:
    print("No index found. Run build-index.py first!", file=sys.stderr)
    print(f"  RAG_ROOT={root} python build-index.py", file=sys.stderr)

mcp = FastMCP("RAG")


@mcp.tool()
def search(query: str, n: int = 1) -> dict:
    """Search the semantic index and return results."""
    query = (query or "").strip()
    if not query:
        return {"error": "Missing query"}
    fetch_k = max(n, rerank_candidates)
    result = collection.query(query_texts=[query], n_results=fetch_k)
    documents = (result.get("documents") or [[]])[0]
    metadatas = (result.get("metadatas") or [[]])[0]
    ids = (result.get("ids") or [[]])[0]
    if documents:
        pairs = [(query, doc) for doc in documents]
        scores = reranker.predict(pairs)
        ranked = sorted(range(len(documents)), key=lambda i: scores[i], reverse=True)
        ranked = ranked[:n]
        result = {
            "documents": [[documents[i] for i in ranked]],
            "metadatas": [[metadatas[i] for i in ranked]],
            "ids": [[ids[i] for i in ranked]],
            "rerank_scores": [[float(scores[i]) for i in ranked]],
        }
    return {
        "server": {
            "host": socket.gethostname(),
            "root": str(root),
            "collection": "codebase",
            "model": embed.model_name,
            "reranker": rerank_model,
        },
        "results": result,
    }


if __name__ == "__main__":
    mcp.run()

.mcp.json

{
  "mcpServers": {
    "rag-codebase": {
      "type": "stdio",
      "command": "/{codebasepath}/mcp-rag-rerank/.venv/bin/python",
      "args": [
        "/{codebasepath}/mcp-rag-rerank/rag-mcp.py"
      ],
      "env": {
        "CHROMA_PATH": "/{codebasepath}/mcp-rag-rerank/chroma"
      }
    }
  }
}

projects.txt

project folders to include (codebase)

path
path
path

Build ChromdaDB chunking from codebase


#!/usr/bin/env python3
"""Build ChromaDB index from codebase."""

import hashlib
import os
import sys
from pathlib import Path

import chromadb
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction

try:
    import torch
    device = "cuda" if torch.cuda.is_available() else "cpu"
except Exception:
    device = "cpu"

# Configuration
chroma_path = os.environ.get("CHROMA_PATH", "./chroma")
embed_model = os.environ.get("EMBED_MODEL", "Qwen/Qwen3-Embedding-0.6B")
max_chars = int(os.environ.get("RAG_CHUNK_SIZE", "800"))
overlap = int(os.environ.get("RAG_CHUNK_OVERLAP", "100"))
projects_file = Path(__file__).parent / "projects.txt"

# Load project directories from projects.txt or use RAG_ROOT
project_roots = []
if projects_file.exists():
    print(f"Loading projects from: {projects_file}")
    with open(projects_file) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                project_path = Path(line).expanduser().resolve()
                if project_path.exists():
                    project_roots.append(project_path)
                    print(f"  ✓ {project_path}")
                else:
                    print(f"  ✗ Not found: {project_path}")
else:
    # Fallback to RAG_ROOT if projects.txt doesn't exist
    root = Path(os.environ.get("RAG_ROOT", ".")).resolve()
    project_roots = [root]
    print(f"Building index for: {root}")

if not project_roots:
    print("No valid project directories found!")
    sys.exit(1)

print(f"Using device: {device}")
print(f"Chunk size: {max_chars}, overlap: {overlap}")
print(f"Chroma DB: {chroma_path}")
print()

# Initialize embedding function
embed = SentenceTransformerEmbeddingFunction(
    model_name=embed_model,
    device=device,
)

# Initialize ChromaDB
client = chromadb.PersistentClient(path=chroma_path)
collection = client.get_or_create_collection(
    name="codebase",
    embedding_function=embed,
)

def _chunk_text(text: str, max_chars: int, overlap: int):
    """Chunk text into overlapping segments."""
    if max_chars <= 0:
        return
    start = 0
    text_len = len(text)
    while start < text_len:
        end = min(start + max_chars, text_len)
        yield text[start:end], start, end
        if end == text_len:
            break
        start = max(0, end - overlap)

# Scan and index files
documents = []
ids = []
metadatas = []
file_count = 0
skipped_count = 0

# Directories to exclude (blacklist)
EXCLUDE_DIRS = {
    'node_modules', 'venv', 'env', '.venv', '__pycache__',
    'dist', 'build', 'target', 'out', '.next', '.nuxt',
    'vendor', '.gradle', '.idea', '.vscode',
    'coverage', '.pytest_cache', '.mypy_cache', '.tox',
    'eggs', '.eggs', 'lib', 'lib64', 'parts', 'sdist',
    'var', 'wheels', '*.egg-info', '.installed.cfg', '*.egg',
}

print("Scanning files...")
for root in project_roots:
    print(f"\nScanning: {root}")
    for file_path in root.rglob("*"):
        if not file_path.is_file():
            continue

        # Skip excluded directories and hidden files (blacklist)
        rel_parts = file_path.relative_to(root).parts
        if any(part in EXCLUDE_DIRS or (part.startswith(".") and part != ".") for part in rel_parts):
            if skipped_count < 10:  # Debug: show first 10 skipped
                print(f"  Skipping: {file_path.relative_to(root)}")
            skipped_count += 1
            continue

        # Skip binary files
        try:
            with file_path.open("rb") as handle:
                if b"\x00" in handle.read(2048):
                    skipped_count += 1
                    continue
        except Exception:
            skipped_count += 1
            continue

        # Read file content
        try:
            content = file_path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            skipped_count += 1
            continue

        if not content.strip():
            skipped_count += 1
            continue

        # Chunk and add to index
        rel_path = str(file_path.relative_to(root))
        project_name = root.name
        file_count += 1

        if file_count <= 5:  # Debug: show first 5 files
            print(f"  Indexing: {rel_path}")

        for i, (chunk, start, end) in enumerate(_chunk_text(content, max_chars, overlap)):
            documents.append(chunk)
            # Include project name in ID to avoid duplicates
            ids.append(hashlib.sha1(f"{project_name}:{rel_path}:{i}".encode("utf-8")).hexdigest())
            metadatas.append({"path": rel_path, "project": project_name, "chunk": i, "start": start, "end": end})

        if file_count % 100 == 0:
            print(f"Processed {file_count} files, {len(documents)} chunks...")

        # Safety limit to prevent runaway indexing
        if len(documents) > 100000:
            print(f"\n⚠ Safety limit reached: {len(documents)} chunks")
            print("Consider refining RAG_ROOT or checking for large directories")
            break

print(f"\nScanned: {file_count} files")
print(f"Skipped: {skipped_count} files")
print(f"Total chunks: {len(documents)}")

if documents:
    print("\nAdding to ChromaDB...")
    # Add in batches to avoid max batch size limit
    batch_size = 5000
    for i in range(0, len(documents), batch_size):
        end = min(i + batch_size, len(documents))
        print(f"  Adding batch {i//batch_size + 1}: chunks {i} to {end}")
        collection.add(
            documents=documents[i:end],
            ids=ids[i:end],
            metadatas=metadatas[i:end]
        )
    print("✓ Index built successfully!")
else:
    print("⚠ No documents to index")
    sys.exit(1)