4 min read
Private Code Search: The Parameter Nobody Wrote
Semantic search over company code, with nothing leaving the company network and the answers written by a 120B model on company hardware. Four tools, two models, two gotchas, and one field in every request that nobody on my side had written.
The stack
Repowise indexes the repository: parses files, builds the symbol graph, cuts chunks, and exposes the result over MCP so any agent can call search_codebase and get_answer instead of reading half the repository. LanceDB, bundled with it, stores the vectors on disk inside the repo. Ollama runs the open weights, in two places. On the laptop, nomic-embed-text turns each chunk into a 768 dimension vector, small enough to run on a notebook. On the company server, gpt-oss:120b writes the wiki pages and turns retrieved chunks into answers. LiteLLM sits in front of the server as the team's routing proxy and speaks the OpenAI compatible API: one endpoint, one key, one model list, and swapping the backend behind a model name is a config change nobody on the client side notices.
That is the setup I ended with. It is not the one I started with.
The first four steps run once and then incrementally after each commit. The last two run every time an agent asks. The wiki pass went through LiteLLM from day one and never gave me trouble. Nothing in this flow leaves the company network, and the step that runs most often, embedding, never leaves the laptop.
First, the door has no name
Before any of that could run, the proxy hostname did not resolve. The laptop was on a VPN into an Azure environment, not into the company network, and that VPN captures every DNS lookup and sends it to Azure's resolver, which has never heard of the company's internal names. Windows can route a single suffix to its own resolver: a Name Resolution Policy Table rule. One line in an admin PowerShell, no restart, survives VPN reconnects:
Add-DnsClientNrptRule -Namespace ".internal.example" -NameServers "10.0.0.2","10.0.0.3"
nslookup litellm.internal.exampleGotcha 1: the parameter nobody wrote
My first version sent embeddings through LiteLLM too, to bge-m3 on the server. Same door for everything, one less thing running on the laptop. File search and symbol lookup worked. The first question that needed meaning returned this:
unsupported parameter
Indexed 0 items (N failed)A different client against the same endpoint and the same model worked. I diffed the two requests and found one difference: encoding_format.
I had never set it. The OpenAI client sets it for you. Inside embeddings.create(), if you do not say how you want the numbers encoded, it requests base64 and decodes the bytes back into floats. Smaller payload, sensible default against OpenAI's own servers.
# inside the openai package, not in your code
if not is_given(encoding_format):
params["encoding_format"] = "base64"LiteLLM translates each field of the request into the backend's own API. Ollama's embed endpoint has no such field, so to LiteLLM every value of encoding_format is unknown, and by default unknown means rejected. Passing "float" explicitly changes the value, not the presence of the field, and presence is what gets checked. There is no switch for "send nothing".
The client is two layers: friendly methods like create() that build the body and fill in defaults, and underneath them a plain post() that knows the base URL, auth, retries and timeouts but adds nothing to the body you hand it. The unwanted field lives in the top layer, so I patched Repowise's embedder to skip it.
# repowise's embedder, patched: call the client's HTTP layer directly
response = client.post(
"/embeddings",
body={"model": model, "input": chunks},
cast_to=CreateEmbeddingResponse,
)
vectors = [item.embedding for item in response.data]cast_to parses the reply into the same typed response create() would have returned, so Repowise never notices. No encoding requested, so Ollama answers in plain floats. The model was swappable. The request shape was not.
Gotcha 2: the width
Requests now succeeded and LanceDB rejected every vector as the wrong shape. Repowise knew OpenAI's models and assumed 1536 for anything else, and bge-m3 returns 1024. REPOWISE_EMBEDDING_DIMS=1024 fixes it.
Result: 1024 dimension vectors, roughly 1.6 seconds per item through LiteLLM, zero failed. It worked.
The fix I did not take, and the one I did
LiteLLM has a setting for exactly this: drop_params: true discards any parameter the backend does not understand. One line in the proxy config, no patch, problem gone. I did not ask for it. The proxy is shared by every team, and a proxy that swallows unknown fields would have hidden my mistake and every other team's too: a misspelled parameter, or one a backend genuinely needs.
The patch kept the cost of the mistake where it was made. It was still a patch, on a package that the next pip install -U would silently revert, so it grew a script with a --check mode that runs on shell startup and complains before the next Indexed 0 items.
Then I asked the question I should have asked first: why are code chunks crossing the network at all? A 768 dimension embedder runs fine on a laptop. 1433 pages embedded locally, zero failed, no proxy in the path, no round trip per chunk, no parameter to argue about, no patch script to maintain. The only thing that still needs the server is the 120B model that writes the answers, and that is what LiteLLM is for.
Setup, the shape I ended with
pip install repowise
ollama pull nomic-embed-text
export OLLAMA_EMBEDDING_MODEL=nomic-embed-text
export LITELLM_BASE_URL=https://litellm.internal.example/v1
export LITELLM_API_KEY=$KEY
export REPOWISE_MODEL=gpt-oss:120b
cd repo && repowise initConfiguration files, secrets and other sensitive paths go into the ignore file before the first chunk is cut.
Is it enough for a serious codebase?
Mostly. nomic-embed-text is a general text embedder, weaker on identifier heavy code than bge-m3 or a code tuned model. Repowise compensates by fusing full-text and vector hits, so exact names still land, and the embedder is one ollama pull away from being swapped. A laptop embeds a large monorepo slowly the first time and incrementally after that. The parts that feel like understanding rather than search, get_overview, get_why, the wiki, come from the prose pass, and that is where the 120B model earns its place.
Send only what you mean. Do not ask the server to forgive you for not doing so. The best fix for a parameter nobody wrote was a request nobody sends.
Share this post