refactor(graph): drop the HNSW reader from the vector/search route - #1368
Conversation
The HNSW branch of POST /vector/search returned rows through
result.get_as_list(), a method ladybug's QueryResult does not have. The
hasattr guard around it had no else branch, so every HNSW search answered
200 OK with {"results": [], "total": 0} -- no error, no log, plausible
latency. Its sibling call sites (build, info, materialize) all carry the
`else list(result)` fallback; this one did not.
Nothing called it. GraphClient.vector_search() is the only client for the
route and never sends `backend`, so it defaults to lance and cannot reach
the branch; its sole caller is a unit test. The module docstring already
said HNSW is "searched in Cypher via CALL QUERY_VECTOR_INDEX, not the
/search route" -- the code contradicted its own documentation.
Rather than repair a reader with no consumers, remove it and keep one way
to search an HNSW index. backend='hnsw' now returns 400 naming the Cypher
CALL and the /query endpoint to send it to, so a caller who lands here
leaves with the working query instead of an empty list.
The tests could not have caught this: they set get_as_list on a MagicMock,
so hasattr was true under test and false against the engine. Replaced with
route-level tests that exercise the real request path.
Verified on the local stack against a subgraph holding 384-d fastembed
vectors: build still indexes, backend='hnsw' 400s with the pointer, and
CALL QUERY_VECTOR_INDEX through /v1/graphs/{g}/query/cypher returns the
correct nearest neighbour for each probe.
Claude-Session: https://claude.ai/code/session_01HHvoJGP23TsTZZ8njeRTWj
|
Change classification: normal — platform or application code changed. A normal change carries a documented automated review on this pull request before merge, in place of a second human approver. The |
|
@claude please review this PR |
|
Claude finished @jfrench9's task in 4m 44s —— View job Reviewing PR #1368
ReviewThis is a clean, well-scoped cleanup. Verified the core claim: Confirmed the removal is safe:
Test coverage: the new One very minor, non-blocking note: removing the I wasn't able to execute No blocking issues found. This looks good to merge. |
Four call sites read rows as rows = result.get_as_list() if hasattr(result, "get_as_list") else list(result) QueryResult has no get_as_list, so the guard is always false and all four have been taking the fallback since the ladybug rename. They work. The problem is the shape: it reads as though either branch might fire, and a fifth copy written without the `else` is a silent empty result, not an error. That fifth copy existed -- the vector-search HNSW reader removed in PR #1368, which answered 200 with zero rows for every query. Replaced with result_rows(), which uses the engine's documented has_next()/get_next() cursor, matching the row-to-dict readers already in service.py and engine.py. No behavior change; the fallback each site was silently taking is now the only path. Left alone: _copy_result_rows in materialize.py guards on get_as_arrow, which QueryResult does have, and service.py/engine.py build column-mapped dicts rather than positional rows. The tests are the other half. The old ones set get_as_list on a MagicMock, so hasattr was true under test and false against the engine -- the mock manufactured the method whose absence was the bug. Swapping in a fake with the engine's real surface made those tests hang instead of pass: MagicMock returns a truthy Mock from has_next(), so the cursor never terminates. That is the same lie in the other direction, and it is why FakeQueryResult now lives in tests/graph_api/conftest.py for anything reading rows off a result. test_results.py covers order, exhaustion, unnormalized rows, and a trap fake that fails if anything reaches for get_as_list again. Verified on the local stack: GET /vector (TABLE_INFO + COUNT), POST /vector/build, and the no-such-table path all behave as before. The TABLE_INFO read is the same query and reader materialize's _get_target_columns uses. Claude-Session: https://claude.ai/code/session_01HHvoJGP23TsTZZ8njeRTWj
What
Removes the HNSW branch of
POST /databases/{g}/tables/{t}/vector/search. HNSW indexes are searched in Cypher viaCALL QUERY_VECTOR_INDEX; that route now returns 400 forbackend="hnsw"with the query to send instead. The Lance branch is untouched, and so is/vector/build, which is the live half of the module.Why
The HNSW reader read its rows through
result.get_as_list()— a methodladybug0.18.1'sQueryResultdoes not have (it exposesget_all,rows_as_dict,get_next/has_next). Thehasattrguard around it had noelse, so the function fell through with an empty list:Every HNSW search returned
200 {"results": [], "total": 0}with a plausible latency and nothing logged. The sibling call sites in the same module (lines 263, 386, 401) andtables/materialize.py:153all carry theelse list(result)fallback — only the reader lacked it.Nothing consumes it.
GraphClient.vector_search()is the route's only client and never sendsbackend, so it defaults tolanceand cannot reach the branch; its sole caller is a unit test./vector/buildwithbackend="hnsw"is live (chunked_materialization.py:169) and is unaffected. The module docstring already said HNSW is "searched in Cypher via CALL QUERY_VECTOR_INDEX, not the /search route" — the code contradicted the docs shipping beside it.Repairing a reader with no consumers would leave two ways to search one index, one of them undocumented. Removing it leaves the way that works, and the 400 hands the caller the working query rather than an empty list.
Tests
The existing tests could not have caught this — they set
get_as_liston aMagicMock, sohasattrwas true under test and false against the engine. That's the same shape of blind spot either way, so the replacements are route-level and go through the real request path: the 400, the pointer's contents, an explicit assertion that a 200-with-no-rows can no longer happen, and thatbackend="lance"is still served.Verification
Beyond the suite, on the local stack against a subgraph with a
FLOAT[384]embedding column and 8 nodes of real fastembed vectors:backend="hnsw"→400namingCALL QUERY_VECTOR_INDEX('Document', 'document_vec_index', ...)andPOST /databases/{g}/querycolumn(removed from the request model) →422, perextra = "forbid"/vector/buildbackend="hnsw"→ still indexes,row_count: 8CALL QUERY_VECTOR_INDEXthrough/v1/graphs/{g}/query/cypher→ correct nearest neighbour on all three probes (goodwill → Goodwill impairment 0.316; coffee roasting → Espresso roasting 0.255; buyback → Share repurchase 0.351)Follow-up, not in this PR
The
hasattr(result, "get_as_list")pattern remains at three sites in this module plusmaterialize.py:153. All are correct today because they have the fallback, but all four are guarding against a method the engine no longer has, so all four silently take the fallback path. Worth collapsing to a single reader helper separately.🤖 Generated with Claude Code
https://claude.ai/code/session_01HHvoJGP23TsTZZ8njeRTWj