Skip to content

Fix/hybrid search id space - #22

Open
dartsoer-netizen wants to merge 7 commits into
Brainwires:mainfrom
dartsoer-netizen:fix/hybrid-search-id-space
Open

Fix/hybrid search id space#22
dartsoer-netizen wants to merge 7 commits into
Brainwires:mainfrom
dartsoer-netizen:fix/hybrid-search-id-space

Conversation

@dartsoer-netizen

Copy link
Copy Markdown

No description provided.

ilyasoer and others added 3 commits July 28, 2026 14:10
Hybrid search has been silently degraded to pure vector search in every
non-trivial index. The two retrieval arms keyed their results on different
id spaces:

  - vector arm:  `row_offset + i`, a position within THIS query's result
                 batches, so always 0..n
  - BM25 arm:    `count_rows() + i`, a table row number assigned at index
                 time

Reciprocal Rank Fusion therefore combined two disjoint key sets. Every
consequence we observed follows from that one defect:

  - each vector hit scored exactly 1/(60+rank), i.e. 0.016393, 0.016129,
    ... for every query regardless of match quality, which makes min_score
    meaningless against hybrid scores
  - keyword_score was always null, because BM25 wrote into original_scores
    under an id no vector entry shared
  - keyword-only hits were dropped: their row number was not a valid index
    into the returned batches, so the materialisation scan never found them
  - querying an exact symbol name returned unrelated chunks, since only the
    semantic arm was ever contributing

The row-number scheme was also unstable on its own terms: incremental
re-indexing deletes and re-adds rows, so the numbering drifted from what
BM25 had stored.

Both arms now key on the chunk's stable `file_path:start_line`, which the
vector table already stored in its `id` column. Derivation moved into one
place, LanceVectorDB::chunk_id, since having it written out twice is how
the two drifted apart.

Also:

  - BM25Search stores `chunk_id` as a STORED text field instead of a u64
    `id`. Tantivy field handles are positional, so an index directory
    written by an older build would be read with the wrong types; new()
    now detects a pre-chunk_id schema and rebuilds the directory. It is a
    derived artifact, so that costs only a re-index.
  - keyword-only hits are fetched from the table by id after fusion. This
    is what makes exact-symbol search actually work: such a chunk often
    is not in the vector top-N at all, so it has to be materialised
    separately rather than looked up in the vector batches.
  - the BM25 read guard is scoped so it is released before the new await;
    holding it across one makes the future non-Send, which the
    VectorDatabase trait requires. An explicit drop() is not sufficient.

Why the existing tests passed: test_search_hybrid stores a single batch
into a fresh table, where count_rows() is 0 and the row numbers 0..n
coincide with the batch positions 0..n. The bug only appears from the
second insert onward, or whenever distance order differs from insertion
order. The new test_search_hybrid_across_multiple_batches reproduces both
conditions -- it inserts the keyword target first but gives it the vector
furthest from the query -- and fails on the parent commit with
"keyword_score must be populated for a BM25 match".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tracing_subscriber::fmt::init() defaults to stdout. In stdio MCP mode
stdout carries the JSON-RPC stream, so every log line the server emitted
was injected into the protocol and the client saw malformed frames.

This is a plain bug for any stdio MCP server, independent of this project's
configuration, which is why it is worth carrying upstream rather than
keeping as a local patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tools returned empty for every input. Distinct causes, one shared
piece.

Symbol resolution snapped to the enclosing node. Both find_references and
get_call_graph located "the symbol at this position" by taking the FIRST
definition whose line range contained the line. On a call site that is
always the enclosing function, so asking about SendNotification2(...) inside
ProcessDeviceNotifyCache resolved to ProcessDeviceNotifyCache and then
answered a question nobody asked. Resolution now reads the identifier token
actually under the cursor and prefers a definition of that name, falling
back to the INNERMOST containing range rather than the first. find_definition
uses the same helper, so it stops reporting an enclosing class for a member.

References were only ever searched in the defining file. extract_references
was called with the target's own FileInfo and nothing else, so any reference
from another translation unit was invisible -- which is the normal case for
anything public. Both tools now scan the defining file plus the files the
index says mention the symbol. The shortlist comes from keyword search: a
reference must contain the literal identifier, so BM25 names exactly the
right files and tree-sitter runs only on those instead of the whole corpus.
This depends on the hybrid-search fix in the previous commit; it would have
been useless while the keyword arm was dead.

get_call_graph additionally attributed callers to definitions from the wrong
file, since it matched call sites found anywhere against the defining file's
definition list. Callers are now attributed to the innermost enclosing
function of the file the call was actually found in, and deduplicated per
(file, function).

search_git_history returned nothing because search_filtered post-filters.
It searched the whole table for limit*3 candidates and then kept only rows
with language "git-commit". A few hundred commits share that table with tens
of thousands of code chunks, so no commit ever reached the candidate set and
the filter emptied the list every time. The candidate pool is now widened
when a filter is actually present. Predicate pushdown into the LanceDB query
is the real fix and is noted as such in the code.

Also fixes a hazard introduced by the previous commit: every commit in a
repository is stored with file_path "git://<repo>" and start_line 0, so a
fusion key of path+line collapsed an entire history onto one id and the BM25
index would have held a single document for it. chunk_id now includes
file_hash, which is the commit hash for commits and is constant within a
file for code, where start_line still separates chunks.

Tests: 25 in vector_db pass, including a new
test_commit_chunks_are_not_collapsed_by_shared_path. Full library suite is
402 passed / 13 failed, and those 13 fail identically on the parent commit --
they are pre-existing Windows issues in fs_lock, path normalisation and
concurrent indexing, untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dartsoer-netizen

Copy link
Copy Markdown
Author

Accepted

ilyasoer and others added 4 commits July 29, 2026 14:10
Callees were never unimplemented -- get_call_graph builds them in full. The
name was parsed out of Reference::target_symbol_id with the wrong layout: that
field holds a Definition id, `def:<file>:<name>:<line>`, not a SymbolId id,
`<file>:<name>:<line>:<col>`. The old forward split(':') took parts[1], which
is the file path, so every lookup missed and the list came back empty.

Adds Definition::name_from_storage_id, which strips the `def:` prefix and
splits from the right so a Windows drive-letter colon cannot shift the fields.

Cross-TU callees needed a second fix: extract_references only emits a reference
whose identifier is a key in the index it is handed, and that index was built
from the target file alone, so a callee defined in another translation unit was
invisible at extraction time rather than filtered later. The index is now
widened first from call-site identifiers in the target span via
files_mentioning, bounded at 40 names x 5 files, with control-flow keywords
filtered so `if (` is not reported as a call. The emitted node now carries the
definition's own file rather than the requested one.

list_symbols(file_path) is new: every definition in a file as name / kind /
line span / signature, with no chunk content. It is the enumeration primitive
the server lacked -- query_codebase and search_by_filters are relevance-ranked
with a limit, and find_definition/find_references need a position you already
have. Unit1.cpp returns 297 definitions in ~115 ms.

Verified on PPSKiosk Unit1.cpp:16359 (WndProc): returns its five documented
targets plus five cross-file ones in DeviceManager.cpp/XFSMonitoring.cpp.
Empty callers there is correct -- nothing names a VCL override. Full suite
405 passed / 13 failed, the same 13 failing on a stashed baseline.

Known limits: list_symbols does not enumerate header members (Unit1.h yields 4
symbols), and the C++ parser desynchronises on some Borland constructs, losing
definitions across a range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independent defects, both surfaced by using the server against a large
non-ASCII C++ tree.

get_statistics reported total_files == total_points == total_vectors, because
the stored row count was substituted for all three. DatabaseStats now carries a
distinct file count and a LanguageBreakdown of (language, file_count,
chunk_count), computed in the Lance backend from the stored file_path column,
and the MCP layer maps it through to LanguageStats. The Qdrant backend does not
scan payloads, so it leaves total_files and database_size_bytes at 0 rather than
repeating the substitution that made the old numbers misleading. Checked against
a 1341-file tree: 1341 files / 15525 chunks, with the per-language rows summing
exactly to both totals.

Byte caps applied to commit messages and diff text could land inside a multi-byte
character, where &s[..n] and String::truncate panic. The three sites that cap by
byte length -- the git chunker, the git walker, and the client-side git indexer
-- now floor the offset through git::floor_char_boundary. Trees with Cyrillic
commit messages hit that cut sooner or later.

Tests cover the boundary helper directly and the statistics path in the Lance
backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition nodes whose name could not be extracted were dropped with no
diagnostic, so a short or empty symbol list was indistinguishable from a
complete one. Track them as SkippedDefinition (line, node kind, reason,
snippet), surface them in ListSymbolsResponse.skipped via a new
extract_definitions_reporting trait method, and log a warning per file.

Also fix two early returns in the C/C++ name lookup (find_name_node and
find_innermost_identifier) that returned None before trying the generic
identifier fallback, which was one source of silently vanishing
definitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`cargo fmt` hard-errored ("failed to resolve mod `stack_graphs`") because
relations/mod.rs declares `pub mod stack_graphs;` behind the `stack-graphs`
feature, but no source file for it existed -- rustfmt tries to resolve every
module regardless of cfg. Added src/relations/stack_graphs/mod.rs as an
honest placeholder: StackGraphsProvider::new() always errors, which
HybridRelationsProvider already handles by falling back to RepoMapProvider,
so enabling the feature changes nothing observable yet.

Fixed the real bugs behind 13 tests that only failed on Windows:

- fs_lock.rs: fs2 signals lock contention differently per platform (Unix:
  EWOULDBLOCK -> io::ErrorKind::WouldBlock; Windows: ERROR_LOCK_VIOLATION,
  which std does not map to WouldBlock). The old `.kind() == WouldBlock`
  check missed every contended lock on Windows and turned "someone else is
  indexing" into a hard error instead of Ok(None). Now compares raw OS error
  codes against fs2's own `lock_contended_error()`. This was the root cause
  of 9 of the 13 failures (fs_lock's own tests plus every index-lock test
  built on top of it). Also fixed a misplaced brace that had put
  test_concurrent_lock_fails_async outside `mod tests`, so it compiled
  unconditionally instead of only under cfg(test).

- file_walker/mod.rs: matches_patterns() matched include/exclude patterns
  against the absolute path instead of the path relative to the walk root,
  so any ancestor directory name containing the pattern as a substring
  (e.g. "Users" containing "rs") produced false positives unrelated to the
  file itself. Root cause of test_do_index_with_include_patterns failing
  deterministically on this machine.

- tests.rs: three tests encoded Unix-only path assumptions --
  `starts_with('/')` after canonicalize() (Windows yields `\?\C:\...`; now
  checks `Path::is_absolute()`), and two comparing `file_path` against
  literal "src/" without accounting for Windows' native '\' separator.

All 423 lib tests pass; default and `--features stack-graphs` builds and
`cargo fmt` both succeed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants