DEV Community

Cover image for Post-Mortem: Building a Local MCP Server for Codebase Memory using Ollama and ChromaDB

Post-Mortem: Building a Local MCP Server for Codebase Memory using Ollama and ChromaDB

Enrique Bruzual on July 15, 2026

Developers are pushing back against cloud API billing and the privacy risks of sending proprietary codebases to third-party endpoints. A Hacker New...
Collapse
 
nazar-boyko profile image
Nazar Boyko

Query 5 is the most valuable data point in the whole post and I'm glad you led the conclusion with it. "A confident wrong answer costs more than an honest gap" is exactly right for an IDE assistant, because the wrong Semaphore citation looks authoritative enough that a dev pastes it and moves on. The scary part is that both answers cite real code, so a shallow "does it have sources?" check passes on both. Makes me wonder if that honest-gap habit is stable for ornith across harder queries, or if it also starts making things up once the retrieved context is close but wrong rather than clearly missing. That's the line I'd want to know before trusting it in an editor.

Collapse
 
kike profile image
Enrique Bruzual

Good catch, but the risk is slightly different from what you're describing. The retrieval is nearest-neighbor search against deterministically indexed entities, tree-sitter AST extractions of real code, no summarization, no interpretation. ChromaDB can't return fabricated context because there is no fabricated context to return.

The real failure mode is the LLM synthesis becoming the source of truth instead of the retrieved findings. Query 5 is a clean example: mistral received real semaphore values from the scan pipeline, applied them to a different question, and presented the synthesis as fact. The retrieved entities were accurate; the synthesis betrayed them. zerikai_memory instructs the model to respond factually against what was retrieved, but a less capable model can't hold that boundary when the context is adjacent but not exact. ornith held it.

The last line of defense is the IDE agent itself. When it takes the synthesized answer and goes to verify the cited file and line, the mismatch surfaces immediately. The architecture assumes that verification step happens, which means the synthesis layer needs to be honest about gaps, not fill them. That's the behavior ornith demonstrated and mistral didn't.

Collapse
 
nazar-boyko profile image
Nazar Boyko

thanks for sharing!

Collapse
 
vinimabreu profile image
Vinicius Pereira

"A confident wrong answer costs more than an honest gap" is the whole thing, and picking ornith:9b over mistral:7b for that reason is the right call. The nuance I'd add: right now honesty is a property you selected by choosing a more disciplined model, which means it is only as reliable as that model's mood on any given query. mistral fabricated on Query 5 by applying real semaphore values to the wrong question, and the failure is diagnostic: the retrieval was correct, the synthesis just was not entailed by it. That is a gap the architecture can close independently of which model you run.

Since your tree-sitter chunks already carry deterministic #file:line identity, you can check the generated answer against its own cited spans before returning it: does every claim actually trace to a retrieved chunk, and does the citation resolve to code that supports it. When it does not, degrade to "insufficient context" instead of trusting the model to volunteer that. Then honesty stops being a model you hope stays humble and becomes a gate every answer passes, which also frees you to run the faster model where it is safe. Retrieval quality and synthesis honesty being independent defenses is exactly right, and the second one is enforceable, not just selectable.

Collapse
 
jacksonxly profile image
Jackson Ly

the thread's zeroed in on synthesis honesty, which is right, but there's a second reason retrieval precision matters that's specific to the 8gb box: it's your latency lever too. loose retrieval means a fatter prompt, and on this hardware a fatter prompt is exactly what spills into shared memory (the ornith cold start at 25s is the tell). a reranker that returns 3 tight chunks instead of 8 loose ones cuts both the wrong-context risk mistral tripped on in query 5 and the tokens you pay to synthesize, and it's the one layer that's already model-agnostic so it compounds whichever model you run. did you ever measure retrieval precision separately from the latency runs, or only end to end?

Collapse
 
kike profile image
Enrique Bruzual

That’s a sharp read of the data, and you're spot on that the 25.67s cold start was the model spilling into shared memory. However, tuning chunk counts was an optimization bottleneck we simply didn't need to break through for a few reasons.

First, the shared memory spill was strictly a cold-start pinning issue. Once the model was warm, synthesis settled comfortably into a 9–17 second window. Paired with gating execution via OLLAMA_MAX_CONCURRENCY=1, the VRAM overhead remained perfectly stable without requiring us to truncate our context window.

Second, while Mistral completely choked on the loose background context in Query 5 and hallucinated, Ornith:9b's agentic training natively handled the noise. It recognized the gap, stated what was missing, and stopped.

Because Ornith already achieved synthesis quality comparable to DeepSeek without confidently fabricating answers, over-engineering our chunking and retrieval precision layer wasn't necessary to get a highly reliable, local memory out the door.

It's also worth noting that we treated the RTX 3050 8GB strictly as a hard minimum baseline for our testing.

Collapse
 
jacksonxly profile image
Jackson Ly

fair, and OLLAMA_MAX_CONCURRENCY=1 gating is the right call for keeping the 8gb box stable. where i'd still push back: a model recognizing a gap only works when retrieval hands it something to notice the mismatch against. the dangerous case isn't the loose noise ornith can flag, it's a single nearest-neighbor chunk that's coherent and on-topic but quietly answers a slightly different question. with nothing to contrast it against, even an honest model has no gap to see. so precision and model honesty aren't substitutes, they cover different halves. the 8gb box just tempts you to lean all-in on the model because context is scarce, which is exactly when a bad retrieval has nowhere to hide.

Thread Thread
 
kike profile image
Enrique Bruzual

That is a completely valid concern, but it actually highlights why the pipeline is decoupled. The architecture separates the initial retrieval pool from the final LLM prompt budget to address exactly what you are describing.

The configuration (.env) exposes this directly as a tunable variable:

# Maximum number of documents to fetch from ChromaDB before applying lexical reranking.
# A wider pool allows reranking to pull in keyword-relevant files that might be semantically distant.
# Does NOT control the final answer size — see the fixed top-k cutoff applied after reranking in main.py.
FETCH_CAP=5
Enter fullscreen mode Exit fullscreen mode

It is fully exposed as an environment variable, so anyone can dial it up or down.

Because we pull an initial pool, let the lexical reranker sort it, and then enforce a hard slice (relevant[:k]) before hitting the LLM, we don't have to choose between context isolation and VRAM safety. We protect the 8GB envelope at the prompt layer while letting users tune the retrieval net to their exact comfort level. Beyond exposing that knob for people to tweak, I'm happy with how the current baseline balances the two.

It is all in there; pretty happy with how it is performing at this point. This has been an evolutionary process; I have learned and grown the tool based on those findings. Always with the core goals in mind and practical application.

Thanks

Thread Thread
 
jacksonxly profile image
Jackson Ly

fair, and the decoupling is the right shape for what it targets. the thing is a wider FETCH_CAP plus reranking fixes recall, the case where the right file was keyword-relevant but semantically distant so it'd have been missed. my worry was the other half: when the rank-1 chunk after reranking is already coherent, on-topic, and quietly answering a slightly different question. the hard slice keeps the winner, so a bigger pool just stacks more losers underneath it. reranking reorders, it doesn't notice the top result is confidently wrong. so the knob buys you recall, not precision on the winner. not saying tune it differently, just that those are two different failures.

Thread Thread
 
kike profile image
Enrique Bruzual • Edited

To track what actually happened across this thread: you opened with model honesty being insufficient against a bad rank-1 result, and that precision and model honesty cover different halves. I responded with how the retrieval architecture handles both. You then said reranking reorders but can't catch a confidently wrong winner. That's a different claim than the first one, and it skips over what the rerank weight is actually doing.

LEXICAL_RERANK_WEIGHT=0.05 is calibrated to stay below the L2 semantic spread so a keyword hit can break a tie but cannot override a genuinely closer semantic result. The weight is documented in .env for exactly that reason.

Beyond that, the chunking granularity is the actual precision defense. Tree-sitter emits one entity per function or class, so a chunk's semantic scope is already narrow. The "coherent but quietly wrong" case you're describing has much less surface area when each vector covers one function, not a file.

You went from model honesty being the gap, to reranking can't catch a bad winner. Both are covered. Unfortunately, I can no longer engage in theoretical abstractions only vaguely related to this project.

Collapse
 
hannune profile image
Tae Kim

The Ollama-local path is the right call for codebases that can't leave the machine, and the post-mortem framing is exactly what this space needs more of. The thing that tends to bite in production with local embedding models is embedding model drift: you ship with mistral:7b embeddings, a team member updates to a newer model six months later, and suddenly every existing vector in ChromaDB is from a different embedding space so retrieval silently degrades without an obvious error. Versioning the embedding model name alongside the chunk data in the store and refusing to mix-read across versions is unglamorous but saves a real class of bugs. Curious whether you ran into model-drift issues across the team during development or whether everyone stayed on the same Ollama version.

Collapse
 
kike profile image
Enrique Bruzual

Good question, but zerikai_memory sidesteps the embedding drift problem by design. The indexing layer is deterministic -- tree-sitter parses the codebase and extracts discrete code entities (functions, classes, methods) via AST, not probabilistic LLM summarization. ChromaDB embeds those entities, and that embedding model doesn't change when you swap synthesis models. Swapping mistral:7b for ornith:9b doesn't touch a single vector in the store.

The synthesis LLM only enters at query time -- it reads the retrieved chunks and writes an answer. That's the only layer where model choice affects output, and it affects answer quality, not retrieval fidelity. Which is exactly what we measured in the post. The class of bugs you're describing is real in systems where the LLM drives indexing, but that's not the architecture here.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Nice post-mortem. The part that stood out to me was separating retrieval quality from synthesis quality.

One practical check I would add: log the retrieved entity set and the final cited answer independently. Then when you swap mistral:7b for ornith:9b, you can tell whether a bad answer came from Chroma/reranking or from the model going beyond the retrieved context.

Local mode is not just a privacy story. It also makes evals much more repeatable if you pin the model version, prompt, and index snapshot.

Collapse
 
kike profile image
Enrique Bruzual • Edited

Thanks, and the logging separation is already in place. The server log tracks retrieval and synthesis as independent events. Here's a real example from the query 5 run in the post:

2026-07-16 00:16:01,407 INFO  universal-brain — Ollama model: ornith:9b
2026-07-16 00:16:01,407 INFO  universal-brain — Default mode: local
...
2026-07-16 00:17:02,825 INFO  universal-brain — query_memory | 15/15 results passed threshold for workspace=819f00c0
2026-07-16 00:17:02,825 INFO  universal-brain — query_memory | lexical re-rank applied, top result: Post-Mortem: Running zerikai_memory Fully Local on an RTX 3050 > Query 5: How does asyncio semaphore control Ollama concurrency in local mode?
2026-07-16 00:17:26,693 INFO  httpx — HTTP Request: POST http://127.0.0.1:11434/api/generate "HTTP/1.1 200 OK"
Enter fullscreen mode Exit fullscreen mode

Retrieval completed at 00:17:02, synthesis returned at 00:17:26, 24 seconds of ornith:9b on an RTX 3050. The rerank line shows what ChromaDB surfaced and what scored top. The generate call shows when the model responded. When an answer goes wrong you already have both events timestamped and separated to know where to look. Your pinning point is solid, OLLAMA_MODEL=ornith:9b fixed in .env plus a versioned .brain/ snapshot gives you a reproducible eval loop that cloud APIs can't match the same way.