Back to All Guides
Practical Guide Hands-On Tutorial

How to Build a Cross-Project Semantic Index

One queryable index for all your related codebases

Free Guide Python By Kelly Price
🎧

Listen to this guide

40m · Free to stream and download

Download MP3

When You Need Cross-Project Search

Most code search operates within a single repository. You clone a repo, index it, and query it. This works until your codebase spans multiple repositories.

The situations where you need cross-project search are increasingly common:

  • Microservices architectures — shared types and contracts live in one repository while implementations live in a dozen others. Finding where an interface is implemented requires searching across service boundaries.
  • Monorepos with logical separation — packages within a monorepo that are developed by different teams and rarely share context. The "mono" is organizational, not conceptual.
  • Library and consumer repositories — you maintain a library and want to understand how consumers use specific APIs. The usage patterns live outside the library repo.
  • Platform teams — infrastructure code in one repo, application code that depends on it in many others. Understanding the impact of a platform change requires visibility across all consumers.

In all these cases, searching a single repository gives you incomplete answers. You need an index that spans the boundary.

The Architecture

There are two viable approaches to cross-project indexing:

Single collection with metadata filters. All projects go into one ChromaDB collection. Each chunk carries metadata identifying which project it belongs to. Queries can filter by project or search across all projects.

Multiple collections with cross-collection queries. Each project gets its own collection. Cross-project search queries each collection and merges results.

The single-collection approach is simpler and what we will build here. It handles most use cases and avoids the complexity of cross-collection result merging.

# Single collection architecture
cross_project_collection/
├── chunk_001 (project="api-gateway", file="routes/auth.py", ...)
├── chunk_002 (project="api-gateway", file="routes/users.py", ...)
├── chunk_003 (project="user-service", file="handlers/auth.py", ...)
├── chunk_004 (project="shared-types", file="models/user.py", ...)
└── ...

Setting Up the Environment

Install the required dependencies:

pip install chromadb sentence-transformers pathspec

We use chromadb for vector storage, sentence-transformers for embeddings, and pathspec for respecting gitignore patterns when walking the filesystem.

Indexing Multiple Repositories

The core indexing function walks a project directory, chunks Python files at the function level, and stores embeddings with project metadata.

import chromadb
from sentence_transformers import SentenceTransformer
from pathlib import Path
import ast
import hashlib

def chunk_python_file(path: Path) -> list[dict]:
    """Extract function and class definitions as chunks."""
    chunks = []
    try:
        source = path.read_text(encoding='utf-8', errors='ignore')
        tree = ast.parse(source)
        lines = source.splitlines()

        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                start = node.lineno - 1
                end = getattr(node, 'end_lineno', start + 20)
                chunk_text = '\n'.join(lines[start:end])

                # Skip very short chunks (likely just signatures)
                if len(chunk_text.strip()) < 50:
                    continue

                chunks.append({
                    'text': chunk_text,
                    'name': node.name,
                    'type': 'class' if isinstance(node, ast.ClassDef) else 'function',
                    'line': node.lineno,
                })
    except SyntaxError:
        # Fall back to raw content for unparseable files
        content = path.read_text(encoding='utf-8', errors='ignore')[:3000]
        if content.strip():
            chunks.append({
                'text': content,
                'name': path.stem,
                'type': 'file',
                'line': 1,
            })
    return chunks


def index_project(
    client: chromadb.Client,
    model: SentenceTransformer,
    project_path: str,
    project_name: str,
    collection_name: str = "cross_project"
):
    """Index a single project into the cross-project collection."""
    collection = client.get_or_create_collection(
        name=collection_name,
        metadata={"hnsw:space": "cosine"}
    )

    path = Path(project_path)
    indexed_count = 0

    # Skip common non-code directories
    skip_dirs = {'.venv', 'venv', '__pycache__', '.git', 'node_modules',
                 '.mypy_cache', '.pytest_cache', 'dist', 'build', '.tox'}

    for py_file in path.rglob("*.py"):
        # Skip files in excluded directories
        if any(skip in py_file.parts for skip in skip_dirs):
            continue

        for chunk in chunk_python_file(py_file):
            # Create a stable document ID
            doc_id = hashlib.md5(
                f"{project_name}:{py_file}:{chunk['line']}:{chunk['name']}".encode()
            ).hexdigest()

            # Generate embedding
            embedding = model.encode(chunk['text']).tolist()

            # Upsert to handle re-indexing
            collection.upsert(
                ids=[doc_id],
                embeddings=[embedding],
                documents=[chunk['text']],
                metadatas=[{
                    'project': project_name,
                    'file': str(py_file.relative_to(path)),
                    'name': chunk['name'],
                    'type': chunk['type'],
                    'line': chunk['line'],
                }]
            )
            indexed_count += 1

    return indexed_count

Now you can index multiple projects into the same collection:

client = chromadb.PersistentClient(path="./cross_project_index")
model = SentenceTransformer('all-MiniLM-L6-v2')

projects = [
    ("/path/to/api-gateway", "api-gateway"),
    ("/path/to/user-service", "user-service"),
    ("/path/to/shared-types", "shared-types"),
    ("/path/to/auth-service", "auth-service"),
]

for project_path, project_name in projects:
    count = index_project(client, model, project_path, project_name)
    print(f"Indexed {count} chunks from {project_name}")

Querying Across Projects

Queries hit the single collection and can optionally filter by project:

def search_cross_project(
    client: chromadb.Client,
    model: SentenceTransformer,
    query: str,
    n_results: int = 10,
    project_filter: str | None = None,
    collection_name: str = "cross_project"
) -> dict:
    """Search across all indexed projects, optionally filtering by project."""
    collection = client.get_collection(collection_name)
    query_embedding = model.encode(query).tolist()

    # Build where clause for project filter
    where = {"project": project_filter} if project_filter else None

    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=n_results,
        where=where,
        include=["documents", "metadatas", "distances"]
    )

    return results


# Search all projects
results = search_cross_project(client, model, "authentication middleware")

# Search only the api-gateway project
results = search_cross_project(
    client, model,
    "authentication middleware",
    project_filter="api-gateway"
)

The results include metadata for each match, letting you see which project and file each result comes from:

for i, (doc, meta, dist) in enumerate(zip(
    results['documents'][0],
    results['metadatas'][0],
    results['distances'][0]
)):
    print(f"\n--- Result {i+1} (distance: {dist:.3f}) ---")
    print(f"Project: {meta['project']}")
    print(f"File: {meta['file']}:{meta['line']}")
    print(f"Name: {meta['name']} ({meta['type']})")
    print(doc[:200] + "..." if len(doc) > 200 else doc)

Incremental Updates

Re-indexing an entire project on every change is wasteful. Use content hashing to detect which files actually changed:

import json
from datetime import datetime

def get_file_hash(path: Path) -> str:
    """Get a hash of file contents for change detection."""
    content = path.read_bytes()
    return hashlib.md5(content).hexdigest()


def load_index_state(state_path: Path) -> dict:
    """Load the previous index state."""
    if state_path.exists():
        return json.loads(state_path.read_text())
    return {}


def save_index_state(state_path: Path, state: dict):
    """Save the current index state."""
    state_path.write_text(json.dumps(state, indent=2))


def incremental_index_project(
    client: chromadb.Client,
    model: SentenceTransformer,
    project_path: str,
    project_name: str,
    state_dir: str = "./.index_state"
):
    """Index only changed files since the last run."""
    state_path = Path(state_dir) / f"{project_name}_state.json"
    state_path.parent.mkdir(exist_ok=True)

    previous_state = load_index_state(state_path)
    current_state = {}

    path = Path(project_path)
    collection = client.get_or_create_collection("cross_project")

    skip_dirs = {'.venv', 'venv', '__pycache__', '.git', 'node_modules'}
    updated = 0
    skipped = 0

    for py_file in path.rglob("*.py"):
        if any(skip in py_file.parts for skip in skip_dirs):
            continue

        file_key = str(py_file.relative_to(path))
        file_hash = get_file_hash(py_file)
        current_state[file_key] = file_hash

        # Skip if unchanged
        if previous_state.get(file_key) == file_hash:
            skipped += 1
            continue

        # File is new or changed - reindex it
        for chunk in chunk_python_file(py_file):
            doc_id = hashlib.md5(
                f"{project_name}:{py_file}:{chunk['line']}:{chunk['name']}".encode()
            ).hexdigest()
            embedding = model.encode(chunk['text']).tolist()
            collection.upsert(
                ids=[doc_id],
                embeddings=[embedding],
                documents=[chunk['text']],
                metadatas=[{
                    'project': project_name,
                    'file': file_key,
                    'name': chunk['name'],
                    'type': chunk['type'],
                    'line': chunk['line'],
                }]
            )
        updated += 1

    # Handle deleted files
    deleted_files = set(previous_state.keys()) - set(current_state.keys())
    for file_key in deleted_files:
        # Delete all chunks from this file
        # Note: ChromaDB doesn't support prefix deletion, so we'd need to track IDs
        pass

    save_index_state(state_path, current_state)
    print(f"{project_name}: {updated} files updated, {skipped} unchanged")
    return updated

Serving via MCP

If you want editors to query this index directly, wrap the search function as an MCP tool:

from mcp.server import Server
from mcp.types import Tool, TextContent
import json

server = Server("cross-project-search")

@server.list_tools()
async def list_tools():
    return [
        Tool(
            name="search_cross_project",
            description="Search across all indexed codebases",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Natural language search query"},
                    "project": {"type": "string", "description": "Optional: filter to specific project"},
                    "limit": {"type": "integer", "default": 10},
                },
                "required": ["query"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict):
    if name == "search_cross_project":
        results = search_cross_project(
            client, model,
            query=arguments["query"],
            n_results=arguments.get("limit", 10),
            project_filter=arguments.get("project")
        )

        # Format results for display
        formatted = []
        for doc, meta, dist in zip(
            results['documents'][0],
            results['metadatas'][0],
            results['distances'][0]
        ):
            formatted.append({
                "project": meta["project"],
                "file": f"{meta['file']}:{meta['line']}",
                "name": meta["name"],
                "score": round(1 - dist, 3),
                "preview": doc[:300]
            })

        return [TextContent(type="text", text=json.dumps(formatted, indent=2))]

With this MCP server running, any MCP-compatible editor can query your cross-project index with natural language.

When to Use This Pattern

Cross-project indexing is worth the overhead when:

  • You regularly search for code across repository boundaries — if you find yourself cloning repos just to grep them, a unified index saves time.
  • Shared code lives separately from consuming code — libraries, SDKs, platform code that is used elsewhere.
  • Impact analysis matters — understanding who uses what requires visibility across the boundary.
  • Teams work across repos — onboarding someone means giving them context that spans multiple repositories.

It is probably not worth it when:

  • You work in a single repository most of the time
  • Repositories are truly independent with no shared concepts
  • The total codebase is small enough that grep across cloned repos is fast enough

Complete Setup Script

Here is a complete script that indexes multiple projects and provides a simple CLI for searching:

#!/usr/bin/env python3
"""Cross-project semantic search index."""

import argparse
import chromadb
from sentence_transformers import SentenceTransformer
from pathlib import Path
import ast
import hashlib
import json

def chunk_python_file(path: Path) -> list[dict]:
    chunks = []
    try:
        source = path.read_text(encoding='utf-8', errors='ignore')
        tree = ast.parse(source)
        lines = source.splitlines()
        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                start = node.lineno - 1
                end = getattr(node, 'end_lineno', start + 20)
                chunk_text = '\n'.join(lines[start:end])
                if len(chunk_text.strip()) >= 50:
                    chunks.append({
                        'text': chunk_text,
                        'name': node.name,
                        'type': 'class' if isinstance(node, ast.ClassDef) else 'function',
                        'line': node.lineno,
                    })
    except SyntaxError:
        content = path.read_text(encoding='utf-8', errors='ignore')[:3000]
        if content.strip():
            chunks.append({'text': content, 'name': path.stem, 'type': 'file', 'line': 1})
    return chunks

def index_project(client, model, project_path: str, project_name: str):
    collection = client.get_or_create_collection("cross_project", metadata={"hnsw:space": "cosine"})
    path = Path(project_path)
    skip = {'.venv', 'venv', '__pycache__', '.git', 'node_modules'}
    count = 0
    for py_file in path.rglob("*.py"):
        if any(s in py_file.parts for s in skip):
            continue
        for chunk in chunk_python_file(py_file):
            doc_id = hashlib.md5(f"{project_name}:{py_file}:{chunk['line']}:{chunk['name']}".encode()).hexdigest()
            collection.upsert(
                ids=[doc_id],
                embeddings=[model.encode(chunk['text']).tolist()],
                documents=[chunk['text']],
                metadatas=[{'project': project_name, 'file': str(py_file.relative_to(path)),
                           'name': chunk['name'], 'type': chunk['type'], 'line': chunk['line']}]
            )
            count += 1
    return count

def search(client, model, query: str, project: str = None, limit: int = 10):
    collection = client.get_collection("cross_project")
    where = {"project": project} if project else None
    return collection.query(
        query_embeddings=[model.encode(query).tolist()],
        n_results=limit, where=where,
        include=["documents", "metadatas", "distances"]
    )

def main():
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="cmd")

    idx = sub.add_parser("index")
    idx.add_argument("path")
    idx.add_argument("name")

    srch = sub.add_parser("search")
    srch.add_argument("query")
    srch.add_argument("--project", "-p")
    srch.add_argument("--limit", "-n", type=int, default=10)

    args = parser.parse_args()

    client = chromadb.PersistentClient(path="./.cross_project_index")
    model = SentenceTransformer('all-MiniLM-L6-v2')

    if args.cmd == "index":
        count = index_project(client, model, args.path, args.name)
        print(f"Indexed {count} chunks from {args.name}")
    elif args.cmd == "search":
        results = search(client, model, args.query, args.project, args.limit)
        for doc, meta, dist in zip(results['documents'][0], results['metadatas'][0], results['distances'][0]):
            print(f"\n[{meta['project']}] {meta['file']}:{meta['line']} ({meta['name']})")
            print(f"Score: {1-dist:.3f}")
            print(doc[:200] + "..." if len(doc) > 200 else doc)

if __name__ == "__main__":
    main()

Usage:

# Index projects
python cross_project.py index /path/to/api-gateway api-gateway
python cross_project.py index /path/to/user-service user-service

# Search all projects
python cross_project.py search "authentication handler"

# Search specific project
python cross_project.py search "user validation" -p user-service

Cross-Project Context

Get cross-project search in any editor

Pyckle MCP provides cross-project semantic search that works natively in Claude Code, Cursor, and VS Code.

Use Pyckle MCP