Semantic search
Natural-language and hybrid graph-propagation retrieval: embed your code, tune the vector backend, and query across repos and languages.
Semantic search (optional) adds natural-language retrieval on top of the graph, so you can find code by
what it does even when you don't know its name. Set enabled = true under [embeddings] in the config,
run contextlake kb embed to vectorize the indexed nodes into a local store, and serve then exposes two
retrieval tools:
semantic_searchfor queries where the exact symbol name is unknown.hybrid_search, which seeds Personalized PageRank with the embedding hits and propagates relevance across the graph (HippoRAG-style) to surface structurally related nodes, a function's callers, a package's dependents, that a pure semantic match would miss.
flowchart LR
N[("indexed definitions
and endpoints")] --> EMB["kb embed"] --> V[("vectors")]
Q(["a natural-language query"]) --> A{"any content term
known to the index?"}
A -->|no| NONE(["no results, and the terms
it could not find"])
A -->|yes| R["semantic or hybrid retrieval"]
V --> R
G[("the graph")] -.->|"hybrid propagates
relevance across it"| R
R --> H(["cited hits: repo, file:line,
kind, name"])
--retriever fts is the third path: keyword-only, so it reads no vectors and needs no floor of its own.
Where the vectors come from. provider defaults to auto, which uses a local Ollama when the daemon
is reachable and already has the configured model pulled, and otherwise the built-in CPU embedder (the
kb-local extra). If neither is available it embeds nothing rather than reaching for a network service.
Both of those run on your machine, so on the defaults your code never leaves it; name provider =
"openai" explicitly if you want a hosted model instead.
Backends and tuning#
The vector store uses an exact pure-Python cosine scan by default; install the optional ANN backend with
pip install "contextlake[kb-vec]" (sqlite-vec) for larger workspaces. Three [embeddings] keys tune it:
vector_backend(defaultauto) pickssqlite-vecwhen that extra is installed and falls back to the pure-Pythonbrutescan otherwise; force one withvector_backend = "sqlite-vec"or"brute".vector_chunk_size(the sqlite-vecvec0KNN chunk size, default 1024; clamped to a multiple of 8) is applied when the vector store is first created, so re-embed from scratch to change an existing store.batch_size(default64) sets how many nodes are embedded per batch.
What gets embedded#
The code definitions (classes, functions, methods, interfaces, structs, enums) and HTTP endpoints, each with its name, qualified name, file path, and captured signature and docstring, so a natural-language query like "refund a payment to the original card" finds the right function even when its name says nothing of the sort. A name alone is thin signal for a natural-language query; the signature and the docstring are where the words the query actually uses tend to live. File, module, and package nodes are deliberately not embedded: a path or a shared package name is low semantic signal, and skipping them keeps results clean and avoids re-embedding cross-repo shared nodes once per referencing repo.
Data members, macros, typedefs, enum constants and file-scope variables are embedded too. They are
the majority of the symbols in a C or C++ tree and, before they had vectors, no semantic or hybrid query
could return one of them at all. They are also thinner signal than a function: a data member usually
carries a name and a type and nothing else. Measured on a large legacy tree, adding them costs the kinds
that were already embedded about five percentage points of recall@10 in exchange for tens of thousands of
symbols going from unreachable to findable. If your tree is C++-heavy and you would rather have the
sharper ranking than the reach, kb query --kind narrows any single query, and the trade-off is recorded
per kind in kb/kinds.py so it can be revisited with the numbers in view.
Five more kinds are embedded, from outside the code itself: frontend route nodes, SQL table and
view definitions, Terraform resource nodes, and adr architecture decision records. That makes 17
embeddable kinds in all, which is why
Index the code graph can promise that a route, a table or a decision record is
semantically searchable, and it is worth knowing before you write off a natural-language query about
your schema or your infrastructure as out of scope.
kb embed --limit N caps how many nodes are embedded per repository in one pass: the first N
embeddable nodes of each repo's shard, in shard order. Read it as a sampling knob, not a resumable
one. It takes the same first N every time, it replaces that repo's vectors rather than adding to
them, and a limited pass deliberately never stamps the repo as fully embedded, so nothing is skipped as
up to date afterwards and a later plain kb embed does the whole repository. Use it to try the
pipeline on a large fleet before committing to a full run, not to embed a repo in instalments.
Which model?#
The default is minishlab/potion-base-8M, a static embedding model: it looks each token up in a
table instead of running a transformer forward pass, which is why it is small enough to ship as a CPU
default and fast enough that query latency is not the thing you notice. minishlab/potion-base-32M is
the larger sibling of the same family, one config line away: model = "minishlab/potion-base-32M" under
[embeddings] (on a fresh vector store, the identity guard refuses to mix models). The kb-fastembed
extra plus engine = "fastembed" swaps the static model for an ONNX transformer (bge-small) instead.
Which one is better on your code is a question with a local answer, and
kb eval is how to get it: build a golden set from queries your team
actually types, embed with one model, score, re-embed with the other, score again. No published ranking
of these models against somebody else's corpus is worth as much as that run.
Like index, embed is incremental: it re-embeds only repos whose indexed HEAD moved since they were
last embedded, so a scheduled refresh over a large fleet stays cheap. Pass --force to re-embed
everything. When an upgrade changes the embedded text format itself, embed detects the stale store and
re-embeds everything once, announcing why, then incremental behavior resumes.
A single query returns cited hits (repo · file:line · kind · name) that span repos and languages, here
the C# and Python payment paths together. --retriever fts|semantic|hybrid picks keyword, vector, or
graph-propagation ranking:
When the query has no anchor#
A vector index has no concept of "no match": it returns its k nearest however far away they are, and every
one of them is a real node with a real file and line, so an answer about nothing you asked reads as cited
and checkable. --retriever semantic|hybrid therefore refuses a query when not one of its content
terms appears anywhere in the index, and names the terms it could not find:
No matches for 'SamlAssertionValidator': nothing indexed matches 'SamlAssertionValidator'.
No results are shown rather than the nearest k, which would all be real nodes and none of
them about this query. Index the repo that should answer it, or retry with a term the graph knows.
One indexed term anywhere in the query is enough to let the hits through, so ordinary multi-word questions
are unaffected. The exit code stays 0: "nothing in here is about that" is a valid answer. The
semantic_search and hybrid_search MCP tools apply the same rule over the same store, and return an
empty list rather than prose. With [embeddings] off the query degrades to keyword search instead, which
has its own notion of "no match" and needs no floor.
Measuring retrieval quality#
contextlake kb eval keeps retrieval falsifiable. Point it at a golden-query JSON file, each entry pairs
a query with the node ids it should return:
{
"queries": [
{"query": "CatalogService", "expected": ["demo_app_catalogservice"]},
{"query": "charge", "expected": ["charge"], "match": "name", "kind": "function"}
]
}
Then contextlake kb eval --golden queries.json reports precision@k / recall@k / MRR plus a cost
dimension (estimated tokens per query, and precision per 1k tokens), so "route to the cheapest sufficient
source" becomes a number, not a vibe. Score any retriever with --retriever fts|semantic|hybrid
(semantic/hybrid need embeddings built); a change like embed-bodies or a reranker is then judged by
whether the numbers move.
Are the citations real?#
Those metrics answer one question: did the right node come back? They say nothing about whether the
file:line it carries still points at that symbol, and the citation is what an agent is actually told to
go and read. A wrong citation is worse than a miss, because it looks like an answer.
--verify-citations opens every returned node's file at its recorded line and checks the symbol's name is
there:
$ contextlake kb eval --golden queries.json --verify-citations
Citations: 178/180 verified (98.9%) of 184 distinct nodes
4 unverifiable (no local checkout for the repo)
name_absent: 2
src/billing/refund.py:88 (name_absent)
Failures are named rather than counted: file_missing (the graph outlived the file), line_out_of_range
(the file shrank under a stale index), name_absent (the line exists, the symbol is not on it),
no_citation (a symbol node carrying no file or line at all). A repository whose recorded clone is not on
this machine is reported as unverifiable and kept out of the rate, so a run without the mirror reads as
"nothing was checked" rather than as a pass.
It is off by default: it does filesystem work proportional to the results and needs the checkout present.
