The most expensive bug in codebase RAG isn't a bad embedding. It's a chunk that starts halfway through a function and ends in the middle of an if statement.
When you index a codebase the same way you index a PDF, you get retrieval results that look relevant — the right file, the right approximate area — but the model receives an incomplete thought. It sees the closing brace of one method and the opening lines of the next, with no signature, no docblock, no return type. It guesses. And because it guesses confidently, the code it generates compiles, passes a shallow test, and fails in production three weeks later.
The fix isn't a better model. It's an architecture that understands what code is: a tree of symbols, a graph of callers and callees, and a text artifact that benefits from both semantic and lexical retrieval. Laravel handles the orchestration and the durable domain logic. PostgreSQL with pgvector handles storage and hybrid search in a single engine. MCP exposes the whole thing as a set of narrow, auditable tools. The result is an AI backend that doesn't just read your codebase — it knows its structure, its history, and the boundaries of what a given agent is allowed to see.
📋 Table of Contents
- 1. Why Indexing Code Like Documents Fails
- 2. Ingestion: Git, Queues, and the Idempotent Pipeline
- 3. AST-Aware Chunking: Split at Symbols, Not at Characters
- 4. The Embedding Layer: What Actually Matters for Code
- 5. PostgreSQL + pgvector: The Schema That Survives a Million Chunks
- 6. Hybrid Retrieval: RRF, Full-Text, and the Symbol Index
- 7. The MCP Tool Layer: From Vector Search to Agent Tools
- 8. Security: Scoping, Secrets, and Tool Poisoning
- 9. Freshness: The Stale Index Problem Nobody Budgets For
- What I'd Build First (and What I'd Skip)
1. Why Indexing Code Like Documents Fails
Scenario: You embed every file in the repository in 512-token windows with a standard text embedding model. A developer asks, "How does the refund eligibility check work?" The retriever returns five chunks. Three of them contain the right function name. Two of them are split across a method boundary, so the model sees the end of calculateRefund and the start of logRefundAttempt stitched together with no indication that they're different symbols.
Why it matters: Code has structure that prose doesn't. A function is a semantic unit. A class is a semantic unit. A line range that cuts across both is not. Fixed-size chunking destroys the boundaries that make code understandable, and it produces embeddings that represent a fragment of a symbol rather than the symbol itself.
The second failure is lexical. Semantic search alone misses exact identifiers. If a developer searches for isEligibleForRefund, a vector index may return a conceptually similar method called canIssueCredit and rank it above the exact match. Code search needs both: embeddings for intent, and full-text or exact-symbol matching for names, routes, and class references.
The third failure is freshness. A codebase is not a static corpus. Files change on every merge. An index built once is wrong within a day, and an index that takes hours to rebuild is wrong for longer than it's right.
The architecture that addresses all three looks like this:
Git repository
│
▼
Laravel queued job (index / reindex / incremental update)
│
▼
Tree-sitter parser → symbols, signatures, call edges
│
├──► Embedding provider → vector
│
└──► PostgreSQL
├── code_chunks (vector, tsvector, metadata)
├── code_symbols (name, kind, signature, file, line range)
└── code_edges (caller, callee, edge_type)
Laravel owns the pipeline. PostgreSQL owns the data. pgvector provides the vector index, and PostgreSQL's built-in full-text search provides the lexical index. No separate vector database, no separate search cluster, no synchronization problem between two stores.
🔍 Why this matters: A separate vector database introduces a second source of truth for freshness. When a file changes, you must update both the relational metadata and the vector store. With pgvector, the chunk, its embedding, its
tsvector, and its Git metadata commit in the same transaction. That single-writer property eliminates a whole class of consistency bugs.
2. Ingestion: Git, Queues, and the Idempotent Pipeline
The ingestion pipeline has one job: turn a repository at a specific commit into a set of rows that the retriever can query. It has two constraints that shape the design.
First, ingestion is slow. Parsing a large PHP or TypeScript codebase with tree-sitter, generating embeddings for thousands of chunks, and writing them to PostgreSQL takes minutes, not milliseconds. It cannot run synchronously in a web request. It belongs in a queued job.
Second, ingestion must be idempotent. If the job crashes halfway through, re-running it must not duplicate chunks or leave orphaned embeddings. The commit SHA is the natural idempotency key.
namespace App\Jobs;
use App\Models\CodeRepository;
use App\Services\CodebaseIndexer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class IndexRepository implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $timeout = 1800;
public int $tries = 2;
public function __construct(
public CodeRepository $repository,
public string $commitSha,
public bool $incremental = false,
) {}
public function handle(CodebaseIndexer $indexer): void
{
$indexer->index(
repository: $this->repository,
commit: $this->commitSha,
incremental: $this->incremental,
);
}
}
The commitSha is stored on every chunk row. A re-run with the same SHA uses upsert on a unique constraint of (repository_id, commit_sha, file_path, symbol_name, start_line). That means a crashed job can be retried without cleanup, and a chunk that hasn't changed is not re-embedded.
The incremental path is the one that matters in production. Re-embedding the entire repository on every push is wasteful and slow. Instead, diff the file list between the last indexed commit and the new one, and only process files that changed.
public function index(CodeRepository $repo, string $commit, bool $incremental): void
{
$changed = $incremental
? $this->git->diffFiles($repo->last_indexed_commit, $commit)
: $this->git->allFiles($commit);
foreach (array_chunk($changed, 50) as $batch) {
foreach ($batch as $file) {
$this->indexFile($repo, $commit, $file);
}
}
$repo->update(['last_indexed_commit' => $commit]);
}
The batch size of 50 is not arbitrary. Embedding providers have rate limits, and PostgreSQL has a practical limit on how many vector rows you want to insert in a single transaction before the WAL becomes the bottleneck. Batching gives you a checkpoint: if the job fails on batch 12, batches 1–11 are already committed, and the idempotent upsert handles the retry.
⚠️ Gotcha: Do not index the
mainbranch by polling it. Use a Git webhook — GitHub, GitLab, and Bitbucket all support push events — and dispatch the job from the webhook handler. Polling introduces a delay between the merge and the index update, and that delay is exactly when a developer is most likely to ask the agent about the code they just merged.
3. AST-Aware Chunking: Split at Symbols, Not at Characters
This is where most codebase RAG implementations quietly fail. The embedding model is fine. The vector index is fine. The chunking strategy is the problem.
Tree-sitter parses source code into a concrete syntax tree. For PHP, it understands function_definition, method_declaration, class_declaration, and namespace_definition. For TypeScript, it understands function_declaration, arrow_function, interface_declaration, and class_declaration. The tree gives you exact byte ranges for each symbol, including its docblock, attributes, and body.
The chunking rule is simple: one chunk per symbol, plus a file-level chunk for the imports and namespace declaration that provides context. If a symbol exceeds the embedding model's context window, split it at nested block boundaries — not at arbitrary token counts. A 2,000-line controller method is a code smell, but if you must index it, split it at the if and foreach boundaries so each chunk is a syntactically valid subtree.
use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
public function chunkPhp(string $source, string $filePath): array
{
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$ast = $parser->parse($source);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver());
$chunks = [];
$traverser->addVisitor(new class($chunks, $source, $filePath) extends NodeVisitorAbstract {
public function __construct(
private array &$chunks,
private string $source,
private string $filePath,
) {}
public function enterNode(Node $node): null
{
if ($node instanceof ClassMethod || $node instanceof Function_) {
$start = $node->getStartLine();
$end = $node->getEndLine();
$this->chunks[] = [
'file_path' => $this->filePath,
'symbol_name' => $node->name->toString(),
'symbol_kind' => $node instanceof ClassMethod ? 'method' : 'function',
'start_line' => $start,
'end_line' => $end,
'content' => implode("\n", array_slice(
explode("\n", $this->source),
$start - 1,
$end - $start + 1,
)),
];
}
return null;
}
});
$traverser->traverse($ast);
return $chunks;
}
The key detail is that content is extracted by line range from the original source, not reconstructed from the AST. Reconstructing from the AST loses comments, formatting, and attributes that the model needs to understand the code. The AST is used for boundaries, not for rendering.
For JavaScript and TypeScript, use tree-sitter instead of a PHP parser, because the language is not PHP. The principle is the same: parse, find declaration nodes, extract by byte range.
Why this works: Each chunk is a complete, self-contained symbol. The embedding represents the function's actual purpose, not a fragment of it. When the retriever returns a chunk, the model receives a signature, a docblock, and a body — everything it needs to reason about the code without guessing.
💡 Practical note: Always store the
start_lineandend_linealongside the chunk. When the model cites a result, it can citeapp/Services/RefundService.php:42-87, and a developer can jump directly to the symbol. A chunk without line numbers is a chunk the model cannot attribute, and an unattributed answer is an answer a developer cannot verify.
4. The Embedding Layer: What Actually Matters for Code
The embedding model is the second-most-important decision after chunking. It determines the vector dimension, the index size, the query latency, and the retrieval quality.
As of 2026, the practical choices for code fall into three categories:
| Model family | Dimensions | Strength | Trade-off |
|---|---|---|---|
OpenAI text-embedding-3-large | 3072 (truncatable to 1536) | Strong general semantic quality; widely available | Largest storage footprint; per-token cost |
Voyage voyage-code-3 | 1024 (int8 supported) | Trained specifically on code; strong on identifier and API retrieval | Vendor-specific; requires Voyage API |
Local jina-code-v2 / CodeSage | 768–1536 | No source code leaves your infrastructure | Lower recall on rare languages; GPU or CPU cost |
The dimension choice has a direct storage consequence. A vector(3072) column with 100,000 chunks consumes roughly 1.2 GB for the vectors alone before any index. The same corpus at halfvec(3072) — 16-bit floats — consumes roughly half that, and pgvector 0.8 supports halfvec with a measurable recall cost typically under 1%.
For most Laravel codebases, I'd default to a 1024- or 1536-dimensional model and halfvec storage. The retrieval quality difference between 1536 and 3072 dimensions is small on a corpus of 50,000 chunks, and the storage and index-build savings are substantial.
The embedding job itself is boring, which is the point:
public function embed(string $content): array
{
$response = Embeddings::for([$content])
->dimensions(1536)
->generate();
return $response->embeddings[0];
}
Laravel AI SDK 1.0 routes this through the configured provider. The important part is not the SDK call. It's that the embedding model version is stored on every chunk. When you upgrade the model, you cannot mix old and new vectors in the same index, because they live in different vector spaces. A model upgrade is a full re-index, and the schema needs a embedding_model column to make that migration safe.
🚨 Production warning: Never re-embed a repository while the old index is still serving traffic, unless the old and new vectors are in separate tables or partitioned by model version. Mixing vector spaces produces retrieval results that are syntactically valid and semantically incoherent, and the failure is silent. It looks like the model "got worse," not like a data corruption bug.
5. PostgreSQL + pgvector: The Schema That Survives a Million Chunks
The schema is where most RAG tutorials stop being useful. They show a single table with id, content, and embedding. That schema works for a demo and breaks under incremental indexing, multi-repository scoping, and hybrid retrieval.
Here's the schema I'd actually run:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE code_chunks (
id BIGSERIAL PRIMARY KEY,
repository_id BIGINT NOT NULL REFERENCES code_repositories(id),
commit_sha CHAR(40) NOT NULL,
file_path TEXT NOT NULL,
symbol_name TEXT,
symbol_kind TEXT, -- function, method, class, file
start_line INT NOT NULL,
end_line INT NOT NULL,
content TEXT NOT NULL,
content_hash CHAR(64) NOT NULL,
embedding halfvec(1536),
embedding_model TEXT NOT NULL,
tsv TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(symbol_name, '') || ' ' || content)
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (repository_id, commit_sha, file_path, symbol_name, start_line)
);
CREATE INDEX code_chunks_embedding_idx ON code_chunks
USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX code_chunks_tsv_idx ON code_chunks USING GIN (tsv);
CREATE INDEX code_chunks_symbol_trgm_idx ON code_chunks USING GIN (symbol_name gin_trgm_ops);
CREATE INDEX code_chunks_repo_file_idx ON code_chunks (repository_id, file_path);
Several details are load-bearing.
halfvec(1536) instead of vector(1536) halves storage and index size. pgvector 0.8 supports HNSW indexing on halfvec up to 4,000 dimensions, so 1536 is comfortably within bounds.
tsv is a generated column using the simple text search configuration, not english. Code is not English. Stemming authenticate to authent is unhelpful when a developer searches for the exact method name. The simple configuration preserves tokens, and the pg_trgm index on symbol_name catches substring matches and typos.
The unique constraint on (repository_id, commit_sha, file_path, symbol_name, start_line) is the idempotency guarantee from section 2. It's also the index that makes upsert fast.
content_hash allows the incremental indexer to skip re-embedding a symbol whose body hasn't changed, even if the file's line numbers shifted due to edits elsewhere. Without it, a change at the top of a file re-embeds every symbol below it.
The HNSW parameters — m = 16, ef_construction = 64 — are the pgvector defaults, and they're a reasonable starting point. Raising ef_construction improves recall at the cost of index build time. For a codebase under 100,000 chunks, the defaults are fine. Above that, measure recall against a labelled query set before tuning.
🧠 The important part: The
embedding_modelcolumn is not optional bookkeeping. When you upgrade fromtext-embedding-3-smalltovoyage-code-3, the old vectors are not comparable to the new ones. The column lets you filterWHERE embedding_model = ?during the migration window, so the retriever never accidentally mixes spaces. Without it, a model upgrade is a silent quality regression.
6. Hybrid Retrieval: RRF, Full-Text, and the Symbol Index
A single retrieval strategy is always wrong for code. Semantic search misses exact identifiers. Lexical search misses intent. The answer is to run both and fuse the rankings.
Reciprocal Rank Fusion (RRF) is the standard fusion method, and it's simpler than it looks. Each result gets a score of 1 / (k + rank) from each retriever, where k is a smoothing constant (60 is the common default). The scores are summed, and the combined list is sorted by total score. RRF is robust to score-scale differences between retrievers, which matters because cosine similarity and ts_rank_cd are not on the same scale.
WITH semantic AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM code_chunks
WHERE repository_id = $2
AND embedding_model = $3
ORDER BY embedding <=> $1
LIMIT 40
),
lexical AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(tsv, plainto_tsquery('simple', $4)) DESC
) AS rank
FROM code_chunks
WHERE repository_id = $2
AND tsv @@ plainto_tsquery('simple', $4)
LIMIT 40
),
fused AS (
SELECT
COALESCE(s.id, l.id) AS id,
COALESCE(1.0 / (60 + s.rank), 0) +
COALESCE(1.0 / (60 + l.rank), 0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON s.id = l.id
)
SELECT c.*, f.score
FROM fused f
JOIN code_chunks c ON c.id = f.id
ORDER BY f.score DESC
LIMIT 8;
The FULL OUTER JOIN is the part that matters. A chunk that appears only in the semantic results still gets a score. A chunk that appears only in the lexical results still gets a score. A chunk that appears in both gets the sum of both reciprocal ranks, which correctly promotes it above chunks that are strong in only one dimension.
The third retriever is the symbol index. Exact-symbol lookup is not a ranking problem; it's a lookup. If the query contains an identifier that matches a row in code_symbols, that result should be injected at the top of the fused list, not ranked alongside semantic results.
public function retrieve(string $query, CodeRepository $repo, int $limit = 8): Collection
{
$embedding = $this->embed($query);
$semantic = DB::select($this->semanticSql(), [
$embedding, $repo->id, $this->model, $query,
]);
$symbols = CodeSymbol::query()
->where('repository_id', $repo->id)
->where('name', 'ILIKE', "%{$this->extractIdentifier($query)}%")
->limit(3)
->get();
return $this->fuse($semantic, $symbols)->take($limit);
}
Why this works: Each retriever covers the others' blind spots. The semantic retriever handles "how does refund eligibility work" even when the developer doesn't know the method name. The lexical retriever handles "where is isEligibleForRefund defined" even when the query has no semantic content beyond the identifier. The symbol index handles the case where the developer pastes a method name and expects the definition, not a discussion of similar methods.
💡 Practical note: Return line numbers and file paths with every result. A retrieval result without attribution is a dead end for the developer. The model can summarize the code, but the developer needs to click through to the exact symbol, and that requires
file_path,start_line, andend_linein the payload.
7. The MCP Tool Layer: From Vector Search to Agent Tools
The retrieval pipeline is useful on its own. It becomes an agent backend when it is exposed through MCP as a set of narrow tools that any MCP-compatible client can call.
Laravel MCP 1.0 provides the server primitives. A tool has a schema() that defines its input and a handle() that runs the action. The important design decision is not the code — it's the surface area.
Here's the wrong way: one tool called search_code that takes a free-text query and returns raw chunks. The model gets a wall of code and no structure. Here's the right way: three tools with distinct contracts.
Tool 1 — search_symbols: Find a symbol by name or substring. Returns structured metadata, not raw code.
namespace App\Mcp\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
class SearchSymbols extends Tool
{
public string $name = 'search_symbols';
public string $description = 'Find functions, methods, or classes by name in the indexed codebase.';
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Symbol name or substring'],
'kind' => ['type' => 'string', 'enum' => ['function', 'method', 'class']],
],
'required' => ['query'],
];
}
public function handle(Request $request): Response
{
$results = CodeSymbol::query()
->where('repository_id', $request->user()->current_repository_id)
->where('name', 'ILIKE', '%' . $request->input('query') . '%')
->when($request->input('kind'), fn ($q, $kind) => $q->where('kind', $kind))
->limit(10)
->get(['name', 'kind', 'file_path', 'start_line', 'signature']);
return Response::json($results);
}
}
Tool 2 — read_symbol: Return the full content of a specific symbol, with its docblock and body.
class ReadSymbol extends Tool
{
public string $name = 'read_symbol';
public string $description = 'Read the full source of a symbol by file path and line range.';
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'file_path' => ['type' => 'string'],
'start_line' => ['type' => 'integer'],
'end_line' => ['type' => 'integer'],
],
'required' => ['file_path', 'start_line', 'end_line'],
];
}
public function handle(Request $request): Response
{
$chunk = CodeChunk::query()
->where('repository_id', $request->user()->current_repository_id)
->where('file_path', $request->input('file_path'))
->where('start_line', '<=', $request->input('start_line'))
->where('end_line', '>=', $request->input('end_line'))
->first();
if (! $chunk) {
return Response::error('Symbol not found in the indexed codebase.');
}
return Response::json([
'file_path' => $chunk->file_path,
'symbol' => $chunk->symbol_name,
'start_line' => $chunk->start_line,
'end_line' => $chunk->end_line,
'content' => $chunk->content,
]);
}
}
Tool 3 — find_callers: Query the edge table for callers of a symbol.
class FindCallers extends Tool
{
public string $name = 'find_callers';
public string $description = 'Find the symbols that call a given function or method.';
public function schema(): array
{
return [
'type' => 'object',
'properties' => [
'symbol' => ['type' => 'string'],
'file_path' => ['type' => 'string'],
],
'required' => ['symbol'],
];
}
public function handle(Request $request): Response
{
$callers = CodeEdge::query()
->where('repository_id', $request->user()->current_repository_id)
->where('callee_name', $request->input('symbol'))
->when($request->input('file_path'), fn ($q, $path) => $q->where('file_path', $path))
->limit(20)
->get(['caller_name', 'file_path', 'line']);
return Response::json($callers);
}
}
The separation is deliberate. search_symbols answers "what exists." read_symbol answers "what does it do." find_callers answers "who depends on it." An agent that needs to understand a change can call all three in sequence, and each call returns a small, structured payload that fits comfortably in the context window.
This is also where Laravel MCP 1.0's searchable tool catalogs earn their keep. If you expose 30 tools — search, read, callers, tests, migrations, routes, config — the agent does not need every tool definition in context on every turn. The catalog stays searchable, and the agent loads the specific tools it needs.
⚠️ Gotcha: Do not expose a
read_filetool that returns an entire file. A 2,000-line file consumes more context than the model needs, and it buries the relevant symbol in noise. Exposeread_symbolwith a line range, and let the agent request exactly the symbol it needs. Narrow tools are not just a security boundary; they are a context-window optimization.
8. Security: Scoping, Secrets, and Tool Poisoning
An MCP server with access to an entire codebase is one of the highest-value targets in an AI stack. Source code contains credentials, internal API endpoints, architectural decisions, and unreleased product plans. The security layer is not optional.
Scoping is the first control. Every tool call must be scoped to the authenticated principal's repositories. The repository_id predicate in every query is the enforcement point. A developer on the marketing site should not be able to ask the agent to read the payments service, even if both repositories are indexed in the same PostgreSQL database.
$results = CodeChunk::query()
->whereIn('repository_id', $request->user()->accessibleRepositoryIds())
->where('embedding_model', $this->model)
// ...
The accessibleRepositoryIds() method is the authorization boundary. It should be backed by a policy, not by a request parameter. If the model can supply a repository_id in the tool arguments, it can request a repository the user should not see.
Secret redaction is the second control. Code contains API keys, database URLs, and tokens in .env files, config files, and occasionally hardcoded in source. The indexer should scan for high-entropy strings and known credential patterns before embedding, and replace them with a placeholder. The original value should never enter the embedding pipeline or the retrieval payload.
use App\Support\SecretRedactor;
$content = SecretRedactor::redact($chunk['content']);
// "DB_PASSWORD=super_secret_123" becomes "DB_PASSWORD=[REDACTED]"
This is not a substitute for keeping secrets out of the repository. It is a defense-in-depth measure for the cases where a secret is already there and you cannot rotate it immediately.
Tool poisoning is the third control. The MCP threat model in 2026 is explicit: tool descriptions are attacker-controlled input. A malicious or compromised MCP server can embed hidden instructions in a tool description, and the model will follow them. If your codebase agent connects to a third-party MCP server for, say, dependency analysis, that server can instruct the agent to read private repositories and exfiltrate the contents.
The defense is to treat the tool catalog as a supply chain artifact. Hash every tool definition, pin the hash, and refuse to load a changed tool until a human has reviewed the diff. This is the same control you apply to a dependency lockfile, applied to the tool surface.
$hash = hash('sha256', json_encode([
'name' => $tool->name,
'description' => $tool->description,
'schema' => $tool->schema(),
]));
if ($hash !== $approvedHashes[$tool->name]) {
throw new SecurityException("tool definition changed: {$tool->name}");
}
🚨 Production warning: Do not connect a codebase MCP server to a public MCP registry without an allowlist. The 2026 ecosystem includes over 20,000 public MCP servers, and a significant fraction have excessive permissions or weak authentication. A codebase agent should connect to a small, reviewed set of servers, not to whatever the registry suggests.
9. Freshness: The Stale Index Problem Nobody Budgets For
An index is a cache of the codebase at a point in time. The moment a developer merges a change, the cache is stale. The question is how stale, and whether the agent knows it.
There are three freshness strategies, and they have different trade-offs:
| Strategy | Index lag | Cost | Best for |
|---|---|---|---|
| Webhook-triggered incremental | Seconds to minutes | Low per push | Active development branches |
| Scheduled full reindex | Hours | High (re-embeds everything) | Nightly safety net; model upgrades |
| On-demand indexing | Seconds | Medium (indexes a single file) | Agent-driven "index this file" tool |
The production pattern is a combination. Webhooks handle the common case: a push to main triggers an incremental index of the changed files. A nightly job runs a full reindex if the embedding model changed or if the incremental pipeline reported errors. An on-demand tool lets the agent index a file that was just edited in the working tree, before it has been committed.
The on-demand tool is the one that surprises people. A developer asks the agent about a change they haven't committed yet. The index doesn't have it. The agent either says "I don't know" or, worse, answers from the stale version. An index_file tool that parses and embeds a single file on request closes that gap, and it's cheap because it operates on one file, not the repository.
class IndexFile extends Tool
{
public string $name = 'index_file';
public string $description = 'Index a single uncommitted file so it can be searched immediately.';
public function handle(Request $request): Response
{
$path = $request->input('file_path');
$content = File::get(base_path($path));
$chunks = $this->chunker->chunk($path, $content);
$this->indexer->upsertChunks(
repositoryId: $request->user()->current_repository_id,
commit: 'working-tree',
chunks: $chunks,
);
return Response::json(['indexed' => count($chunks), 'file' => $path]);
}
}
The commit value of working-tree is a sentinel. It tells the retriever that these chunks are not from a committed revision, and the UI can label them accordingly. A developer should know whether the agent is reasoning about main or about the file they just saved.
🔍 Why this matters: The most common complaint about codebase RAG is not "the retrieval is bad." It's "the agent doesn't know about the code I just wrote." Freshness is a product problem disguised as an infrastructure problem, and the on-demand tool is the cheapest fix.
What I'd Build First (and What I'd Skip)
The stack is deep, and it's easy to spend three weeks on infrastructure before the agent answers a single useful question. Here's the order I'd build in, and the things I'd deliberately leave out of v1.
Build first:
- AST-aware chunking for one language. Pick the language most of your codebase is written in. Get the symbol boundaries and line ranges right before you add a second language.
- The
code_chunkstable withhalfvecand a GIN index. One table, one HNSW index, one full-text index. No separate vector database. - Incremental indexing triggered by a Git webhook. The webhook is five lines. The incremental diff is the important part.
- Three MCP tools:
search_symbols,read_symbol,find_callers. Three narrow tools are enough to answer most "how does this work" and "who uses this" questions. - Repository-scoped authorization. Before you expose the MCP server to anyone, make sure every query filters by the authenticated user's accessible repositories.
Skip in v1:
- The call graph.
find_callerscan start as aLIKEquery against the chunk content. A proper edge table with tree-sitter call extraction is better, but it's a v2 feature. - Multi-language support. Get PHP right before you add TypeScript. The chunking strategy is language-specific, and a generic strategy is worse than no strategy.
- A dedicated reranker. RRF with two retrievers is good enough for a first version. A cross-encoder reranker improves precision, but it adds latency and a model to operate.
- A separate vector database. pgvector with HNSW is sufficient for codebases up to several million chunks. The operational simplicity of one database is worth more than the marginal recall gain from a specialized engine.
The architecture is not the hard part. The hard part is resisting the urge to index everything before you've indexed one language correctly. A codebase agent that answers five questions well is more useful than one that answers fifty questions with fragments and guesses.
The boundary that matters is the one between retrieval and generation. Retrieval should return complete symbols with attribution. Generation should reason over those symbols and cite them. Everything else — the embedding model, the fusion weights, the MCP transport — is implementation detail. Get the symbol boundary right, and the rest follows.