Repository navigation
Description
Summary
search_graph with semantic_query returns the same result set for any query whatsoever — different phrases, single terms, different languages all return identical hits, in what appears to be alphabetical order. The vector data in the index is fine (verified by computing cosine similarity manually against the SQLite DB); the bug is in the query executor: it returns exactly the nodes that have NO row in node_vectors, which suggests a LEFT JOIN + ORDER BY similarity DESC where the NULL/NaN similarity of vector-less nodes sorts above every real cosine score.
Environment
- v0.8.1, darwin-arm64 (release binary, checksum verified)
- Large TypeScript + Java monorepo (~10k files, 154k nodes after
mode: "moderate"indexing) - Reproduced identically via
clione-shot mode AND via the stdio MCP server (tools/call)
Reproduction
codebase-memory-mcp cli index_repository '{"repo_path":"<repo>","mode":"moderate"}'
# Any of these return the SAME hits (JSON keys from a registry.json, alphabetical):
codebase-memory-mcp cli search_graph '{"project":"<slug>","semantic_query":["where is client debt calculated"],"limit":6}'
codebase-memory-mcp cli search_graph '{"project":"<slug>","semantic_query":["debt"],"limit":6}'
codebase-memory-mcp cli search_graph '{"project":"<slug>","semantic_query":["cost basis FIFO when selling stocks"],"limit":6}'Every call returns (excerpt):
$behaviors_note | apps/.../ui/registry.json
$bulkZipDownload_note | apps/.../ui/registry.json
$candidateExemplar_note| apps/.../ui/registry.json
...
Note the results are query-independent and alphabetically ordered — the classic signature of sorting on NULL/NaN similarity.
Root-cause evidence (from the index DB)
The data side is healthy:
node_vectors: 46,606 rows × 768d int8 — populated.token_vectors: 18,049 tokens with vector + idf; all my query terms (debt,overdue,balance,fifo, …) are present.
Manual cosine check (Python over the SQLite DB, query vector = mean of its token vectors):
query "client debt overdue balance" vs CcmaSaldoCalculator → cos = 0.9492 (the CORRECT hit)
query "client debt overdue balance" vs $behaviors_note → node has NO row in node_vectors
So the correct ranking exists in the data — CcmaSaldoCalculator (a debt-calculation class) scores 0.949 and should be the top hit. Instead, the executor returns precisely the nodes that are absent from node_vectors (JSON-key nodes from a registry.json, which apparently never get vectors).
Suggested fix
Exclude vector-less nodes from the semantic ranking — INNER JOIN node_vectors (or WHERE nv.vector IS NOT NULL), and/or treat missing similarity as -inf rather than letting NULL/NaN win the sort.
Side note
BM25 (query) and search_code work great on the same index — this report is only about the semantic_query path.
Activity
DeusData commentedon Jul 7, 2026
Thanks for the semantic_query diagnosis and the manual vector check. Triage: high-priority query correctness bug under #592/#594.
Vector-less nodes winning semantic-only ranking is exactly the sort of silent false-confidence result we need to prevent. The fix should prove semantic-only queries return vector-ranked primary results and that total/has_more still describe the semantic candidate set.
DeusData commentedon Jul 11, 2026
Verification against current main (post-v0.9.0 semantic rework), with a small mixed fixture indexed in full mode:
semantic_query:["debt","client"]→calculate_client_debtfirst at score 0.9586semantic_query:["email","welcome"]→send_welcome_emailfirst at 0.9274Distinct queries, distinct correct rankings, real scores — the identical-alphabetical symptom does not reproduce. On the diagnosis: both v0.8.1 and current main use an INNER JOIN onto
node_vectors(checked the v0.8.1 tag directly), so vector-less nodes can't enter the result set; and the cosine paths are zero-norm-guarded at both the SQL function and the re-rank layer. What best fits your observation (ties resolving to insertion order — alphabetical registry.json keys) is DEGENERATE node vectors in the 0.8.1 moderate-mode index (e.g. body-less JSON-key nodes collapsing to identical vectors → all scores tie). The v0.9.0 semantic pass was substantially reworked (per-node token vectors, storage changes), which most plausibly fixed that generation bug.Could you re-index your monorepo on v0.9.0 (or 0.9.1, imminent) with the same
mode:"moderate"and rerun your three probes? If rankings still collapse, two artifacts would pin it immediately: (1)SELECT count(*), count(DISTINCT vector) FROM node_vectorson the new cache (identical-vector collapse shows up as a tiny distinct count), and (2) the top-6 with scores from any one probe. Thanks for the unusually thorough diagnosis — the manual-cosine verification is what let us rule layers out quickly.github-actions commentedon Jul 11, 2026
Thanks for the report! To move this forward we need a bit more so we can reproduce it ourselves:
codebase-memory-mcp --versionyou're onOnce that's here we'll pick it straight back up. Heads-up: issues left
awaiting-reporterare automatically closed after a few weeks of silence, but a comment reopens the door anytime.Icosa2050 commentedon Jul 26, 2026
Reproduced on v0.9.0 (this issue was filed against 0.8.1), with a minimal public repro and what I believe is the source-level cause:
https://github.com/Icosa2050/cbm-semantic-query-repro — 84 nodes, synthetic Python across three unrelated domains,
./verify.shasserts every claim below. Verified identical on macOS 15 arm64 and Linux 7.0 x86_64 from clean clones with a virginCBM_CACHE_DIR.Query-time vectors never consult the pretrained table
At index time,
cbm_sem_random_index()(src/semantic/semantic.c:437) looks each token up in the vendorednomic-embed-codetable and only falls back to sparse random indexing on a miss, so node vectors carry real pretrained semantics.At query time,
vs_build_keyword_vectors()(src/store/store.c:6245) does not use that function. It triesvs_load_enriched_vector()(src/store/store.c:6176), which reads only the per-projecttoken_vectorstable:and on a miss falls through to
vs_fill_sparse_random()(src/store/store.c:6202), which is a plain XXH3 hash with no nomic lookup.So any keyword outside the project's harvested vocabulary is turned into a content-free hash and compared against semantically-built node vectors. It is returned silently, with no error and no signal to the caller.
token_vectorsis populated only from indexed identifiers (phase3c_export_token_vectors,src/pipeline/pass_semantic_edges.c:1101) — 108 tokens in this 84-node repo.Consequences
Multi-word keywords can never match, since the lookup is on the whole string and the table holds single tokens:
The docs do prescribe single keywords, so the first form is misuse on my part — but it is accepted and answered with noise rather than rejected.
Ordinary words miss too when absent from the identifiers.
temperatureis not in this project's vocabulary (the field iscabin_temperature_c), so it degrades to a hash and returns negatives, with the actual temperature function ranked third:No score floor.
vs_min_cosine_score()(src/store/store.c:6267) takes the min across keywords and nothing rejects a negative. Taking the min also penalises specialists:battery_is_criticalis rank 3 on["battery"](+0.033) but rank 16 on["battery","temperature","sensor"], because itstemperaturecomponent is negative while a generalist scores mediocre-positive on all three.Builtins are 39% of the semantic corpus.
cbm_store_vector_searchfilters ton.label IN ('Function','Method','Class')(src/store/store.c:6345), which here is 18 rows — 7 of themlen,print,upper,lower,append,pop,get.The project name affects scores. It is tokenised into the vocabulary like any other token (
reproappears as a row intoken_vectors), so it shifts IDF corpus-wide. Same commit, byte-identical source, only--namediffers: rank-1 scores 0.05703263 vs 0.05976143.On this issue's diagnosis
The
LEFT JOIN node_vectorstheory does not seem to apply. The vector path already uses an inner join (src/store/store.c:6341):The identical alphabetical
resultsarray does reproduce here — three unrelated queries hash the same,totalequals the full node count. But percli search_graph --help, semantic output goes tosemantic_results, andresultsis the structural-filter field; with noquery/name_pattern/labelsupplied there is nothing to filter it by. That may be intended. It is still easy to misread, sincetotal: 84beside a populatedresultsarray looks like 84 matches — which I think is how theLEFT JOINreading arose.Possible fixes
vs_fill_sparse_randomthe same nomic fallbackcbm_sem_random_indexalready has, or share one code path. This looks like the root cause; the rest are amplifiers.<python-builtins>and equivalent stub nodes fromnode_vectors.vs_min_cosine_score, and reconsider min-across-keywords.BM25 and
name_patternare unaffected throughout — the same phrase via--queryreturns exactly the three pricing symbols.Separately, the repro README documents an unrelated bug:
.cbmignoreexclusions are not applied when re-indexing an existing project (inclusions are;delete_projectis currently required). Happy to file that on its own if useful.Icosa2050 commentedon Jul 26, 2026
Correction to my comment above — please read this one instead.
I traced my own evidence and found it was produced through a broken path. The repro is rewritten; the retraction and the parts that survive are below, plus a validated fix.
What I got wrong
Every example in my previous comment used
cli search_graph --semantic-query '["..."]'. That flag does not parse its array. It passes the literal text["temperature"]as a single keyword, which can never be in any vocabulary, so it always took the fallback path and returned noise. The JSON argument form, piped stdin, and the MCP tool all parse correctly.So my claim that language builtins outrank project code is withdrawn. With correct parsing:
All four pricing functions, no builtins. Semantic search works well when the keyword resolves. Apologies for the noise.
The flag not parsing its array is itself a bug — it fails silently rather than erroring.
What survives, and the actual defect
Index time uses
cbm_sem_random_index()(src/semantic/semantic.c:437), which looks the token up in the vendored nomic table and only falls back to sparse random indexing on a miss.Query time does not.
vs_build_keyword_vectors()(src/store/store.c:6245) triesvs_load_enriched_vector()(src/store/store.c:6176), which reads only the per-projecttoken_vectorstable, then falls through tovs_fill_sparse_random()(src/store/store.c:6202) — a plain XXH3 hash, no nomic lookup.token_vectorsholds only tokens harvested from indexed identifiers. In this 84-node repotemperatureis absent (the field iscabin_temperature_c) even though it is in the nomic vocabulary at index 35753. Becausevs_min_cosine_score()(src/store/store.c:6267) takes the minimum across keywords, that one hash keyword collapses the whole query:["battery"]battery_is_critical+0.946 (telemetry)["battery","temperature","sensor"]haversine_distance_km+0.069 (routing)An order of magnitude lower, and the wrong domain.
Fix
plus
#include "semantic/semantic.h".VS_VEC_DIM == CBM_SEM_DIM == 768.["temperature"]haversine_distance_km+0.069 (routing)tyre_pressure_low+0.396,refrigeration_breached+0.337 (telemetry)["battery","temperature","sensor"]haversine_distance_km+0.069tyre_pressure_low+0.396["battery"]battery_is_critical+0.946make -f Makefile.cbm test→ 5934 passed, 1 skipped, byte-identical to the unpatched baseline I ran on the same machine. The single skip is the network-gated incremental test.Also found
limitchanges the winner, not just the row count.fetch_limit = limit * 5(src/store/store.c:6357) and the SQL pre-filter orders by the first keyword only, then re-ranks by min, so a small pool never sees the best-by-min candidate:["battery","temperature","sensor"]summarize_sensor_alarms+0.0267compute_parcel_price+0.0506haversine_distance_km+0.0690Scores depend on the project name. It is tokenised into the vocabulary like anything else, shifting IDF corpus-wide. Byte-identical source, only
--namediffers:battery_is_criticalscores 0.94567562 underreproand 0.94138202 underothername..cbmignoreexclusions are not applied on re-index (inclusions are;delete_projectis required first). Unrelated to this issue — happy to file separately.On this issue's diagnosis
The identical alphabetical
resultsarray does reproduce. But theLEFT JOIN node_vectorstheory does not fit: the vector path already uses an inner join (src/store/store.c:6341). Percli search_graph --help, semantic output goes tosemantic_results, andresultsis the structural-filter field — with noquery/name_pattern/labelsupplied there is nothing to filter it by, so this may be intended.total: 84beside a populatedresultsarray is easy to read as "84 matches", which is likely how theLEFT JOINreading arose.Repro, rewritten and with
verify.shcovering all of the above: https://github.com/Icosa2050/cbm-semantic-query-repro — 11/11 on macOS 15 arm64, 10/11 on Linux 7.0 x86_64 (one check skips withoutsqlite3), from clean clones.DeusData commentedon Jul 28, 2026
Thanks @Icosa2050 for retracting the invalid evidence and replacing it with a corrected, public, cross-platform reproducer. The awaiting-reporter label has been removed.
The surviving findings are present on current main: vs_load_enriched_vector reads only the per-project token_vectors row, then vs_fill_sparse_random uses XXH3 when the token is absent (src/store/store.c:7570-7607); multi-keyword ranking prefilters limit*5 candidates by the first keyword before min-score reranking (store.c:7734-7757). Those are distinct from the original LEFT JOIN theory, which the INNER JOIN at 7737-7742 correctly disproves.
Keeping this priority/high in 0.9.1-rc with the corrected scope: query-vector fallback parity, typed CLI array parsing, and limit-independent ranking correctness. Please file the .cbmignore reindex behavior separately as offered so it does not get lost here. Thank you for the unusually responsible correction.
DeusData commentedon Sep 5, 2026
Closing as fixed on main: the semantic-only path was isolated from the BM25/alphabetical fallback in de4ec9b (#1319), on top of the compact search_graph output in 4843a34; vector-less nodes no longer win the ranking. The pagination follow-up that grew out of this report (#938) is being resolved separately against main's lossless semantic pagination. Thank you for the clear repro.
fix(mcp): harden semantic search inputs and store-error handling
fix(mcp): harden semantic search inputs and store-error handling