feat(search): lexical fallback for graph-only / macOS Intel (PR-LEX)#403
Conversation
On macOS Intel (x86_64) the vector stack is uninstallable (torch>=2.3 and lancedb>=0.26 dropped macOS x86_64 wheels), so installs run graph-only and `search` returned an empty failure envelope. Vectors can't come back cleanly: the last Intel torch (2.2.2) caps at Python 3.11, while the project supports 3.12/3.13. Add a lexical (keyword) search backend over the symbol graph that graph-only mode already builds — same tool contract (SearchHit/SearchOutput, NodeFilter, CLI), keyword-ranked instead of semantic, with real source snippets and an advisory noting the mode. - search_scoring.py: dependency-free scoring/dedup primitives extracted from search_lancedb.py (unimportable on Intel) so both backends share them; re-exported from search_lancedb for backward compat. explain_score_components gains a lexical= flag. - search_lexical.py: run_lexical_search() — LadybugDB Symbol query with path/role pushdown, name/type/fqn/signature relevance scoring, disk snippets (graph source_root, signature fallback), _dedup_by_fqn, and the same row-dict shape as run_search. Raises a clean "lexical search unavailable" error when no graph exists (mapped to success=False). - mcp_v2.py: the run_search-is-None branch now dispatches to lexical and converges on the existing row->hit loop; SearchOutput.lexical_mode field; advisories for lexical mode / sql-yaml-not-indexed / hybrid-ignored. - pyproject.toml: register search_lexical + search_scoring as py-modules so the installed jrag CLI can import them from outside the repo (without this the Intel user path ships broken). - tests/test_search_lexical.py (10 cases) + updated test_graph_only_boot.py; docs in README, CONFIGURATION, AGENT-GUIDE. Full suite: 1168 passed, 14 skipped. Verified end-to-end via the installed jrag CLI with the vector stack blocked (simulating macOS Intel). Co-Authored-By: Claude <noreply@anthropic.com>
Three issues surfaced by post-PR code review, all masked in the dev/CI environment because the vector stack is installed there and the fixture corpus is small. Each now has a regression test verified to catch the bug. C1 (Critical) — `jrag search --explain` hard-crashed on macOS Intel. jrag.py imported explain_score_components from search_lancedb, which is unimportable on graph-only installs (lancedb/sentence-transformers are excluded by the PEP 508 markers and imported at its module top). The re-export inside search_lancedb can't help — you can't import a name from a module that itself fails to load. Import from dependency-free search_scoring instead (always installed). New test asserts the CLI source imports from search_scoring AND that blocking the vector stack makes search_lancedb unimportable while search_scoring stays importable. C2 (Critical) — keyword ranking missed most matches on real repos. run_lexical_search reused the vector-path overfetch formula (4x the page) on an UNORDERED Cypher scan with no ORDER BY. On the vector path that's fine (LanceDB returns rows pre-ranked by similarity); on the lexical scan it returned only the first ~N symbols in arbitrary storage order, so a plain `search "distribution"` on a repo with >~20 symbols silently missed its best match. Fetch the full candidate pool up to the safety cap (_CANDIDATE_LIMIT_CAP) and rank in Python. Removed the now- unused DEDUP_OVERFETCH import. New test builds >20 distinct matching symbols and asserts they all come back (verified: fails with exactly 20 under the old formula, passes with all 30 under the fix). I1 (Important) — member-level fqn_contains matches were dropped. The shared _node_matches_filter re-checks fqn_contains against row['fqn'] (falling back to primary_type_fqn). The lexical row set only primary_type_fqn (the bare type), so a member node 'Type#method(...)' that the Cypher pushdown accepted was dropped by the post-filter. Added the raw 'fqn' key. New test drives the bug end-to-end: run the search, then re-check each returned row through the same _node_matches_filter the real search_v2 loop uses (verified: fails with fqns=[None] without the key). Tested: tests/test_search_lexical.py (13) + tests/test_graph_only_boot.py (4) green; tests/test_mcp_v2.py + test_mcp_v2_compose.py (163) green; ruff clean for changed files (pre-existing F821 at jrag.py:1693 untouched). Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed all three findings from the code review (commit `be6976e`). Each is a one/few-line fix with a regression test verified to catch the bug — both were invisible in dev/CI because the vector stack is installed there and the fixture corpus is small. C1 (Critical) — C2 (Critical) — keyword ranking missed most matches on real repos. The lexical backend reused the vector-path overfetch limit ( I1 (Important) — member-level Verification: The two Minor/Recommendation items from the review (test for 🤖 Generated with Claude Code |
… coverage) The Intel leg was disabled in 9061da0 because macos-13 — the old Intel label — was retired by GitHub on 2025-12-04, so the job hung pending a runner image that no longer existed. Re-enable it with macos-15-intel, GitHub's designated x86_64 migration image (available until 2027-08). This is the only CI matrix leg that exercises the graph-only install path and the new lexical-search fallback (search_lexical): the PEP 508 markers gate the vector trio off on Intel, so `pip install -e .` is graph-only and every vector test file skips via pytest.importorskip. Without this leg the graph-only/lexical path had zero CI coverage — which is exactly how the C1 import crash (an Intel-only failure) slipped through in the first place. Also move the HuggingFace model-cache guard from macos-13 to macos-15-intel (the model is never downloaded there). The leg stays non-blocking (continue-on-error) per the existing policy for newly-added OS legs. Co-Authored-By: Claude <noreply@anthropic.com>
…(review-2) A second code-review pass (3 parallel reviewers — correctness, import/packaging, test-quality) found no production bugs but flagged two things: the C2 regression test was a false sentinel, and the 5000-row candidate cap silently truncates on large repos. C2 test (Critical): the count-based test used limit=50, under which the buggy page-derived formula yields 200 >= the 30-symbol fixture pool, so it passed GREEN under the very bug it guarded. A deterministic behavioral test is impossible here — the buggy formula `(limit+offset)*4` always overfetches enough to fill the windowed page, so only the nondeterministic *identity* of returned symbols differs. Replaced with a white-box test capturing the LIMIT param sent to the graph and asserting it equals _CANDIDATE_LIMIT_CAP independent of page size (verified: captures [20,80] + fails under the page-derived formula; [5000,5000] + passes under the fix). Cap-truncation advisory (Important): on a large repo the cap-sized scan returns an arbitrary, storage-order-dependent subset with no signal that deeper matches were never ranked. run_lexical_search now appends an advisory when the fetch hits the cap; the lexical-mode/sql-yaml/hybrid advisories are emitted before the call so they lead the message. Two tests cover fire-at-cap and silent-below-cap. End-to-end dispatch test (coverage gap): drive the real search_v2 down the lexical branch (run_search=None) against a fixture graph — covers the shared row->hit loop convergence, lexical_mode, the graph-only advisory, SearchHit fqn, and --explain score_components, none of which the direct run_lexical_search unit tests reach. Tested: test_search_lexical.py (16) + test_graph_only_boot.py (4) + test_mcp_v2.py + test_mcp_v2_compose.py (163) green; ruff clean on changed files. Co-Authored-By: Claude <noreply@anthropic.com>
|
Update: second review pass (3 parallel reviewers) + Intel CI, all pushed. CI — Intel macOS leg re-enabled (`a30757c`). The previous `macos-13` leg was disabled because GitHub retired the macos-13 image on 2025-12-04 (the job hung pending a runner that no longer existed). Re-enabled with `macos-15-intel` — GitHub's designated x86_64 migration image (available until 2027-08). This is now the only CI matrix leg that exercises the graph-only install path and the lexical fallback (vector tests skip via `pytest.importorskip`); it's non-blocking (continue-on-error) initially. This is exactly the coverage gap that let the C1 Intel-only crash slip through. C2 test hardened (`8a8a6eb`). Two reviewers independently found the C2 regression test was a false sentinel — with `limit=50` the buggy page-derived formula computes `200 ≥ 30`-symbol pool, so it passed green under the bug. A deterministic behavioral test is impossible here (the buggy `(limit+offset)*4` always overfetches enough to fill the windowed page; only the nondeterministic symbol identity differs). Replaced with a white-box test asserting the fetch LIMIT equals `_CANDIDATE_LIMIT_CAP` independent of page size — verified to capture `[20,80]` and fail under the bug. Cap-truncation advisory (`8a8a6eb'). On a large repo the 5000-row scan cap returns an arbitrary, storage-order-dependent subset with no signal. `run_lexical_search` now appends an advisory when the fetch hits the cap (mode advisories reordered to lead the message). Covered by fire/silent tests. End-to-end dispatch test added (`8a8a6eb`). Drives the real `search_v2` down the lexical branch against a fixture graph — covers the shared row→hit loop, `lexical_mode`, the graph-only advisory, `SearchHit.fqn`, and `--explain` (gaps the unit tests over `run_lexical_search` don't reach). Reviewer verdicts: import/packaging — merge, no fixes; correctness — no production bugs (scoring math verified, no double-truncation/dedup, all fixes clean); test-quality — with fixes (the C2 sentinel + coverage gaps, now addressed). Remaining deferred minors: offset/empty-query/`table="all"` unit tests, negative-raw cosmetic, `role_w` reuse — noted, low priority. 🤖 Generated with Claude Code |
The newly-added macos-15-intel CI leg installs graph-only (vector trio gated
off by PEP 508 markers). 17 tests assumed the vector stack and failed there —
none related to the lexical-search feature; they're vector-path/Lance-schema
tests that never self-skipped. Fixes fall into two groups:
Skip (vector-specific contracts; the lexical path has its own coverage in
test_search_lexical.py):
- test_mcp_v2.py: @needs_vectors on 9 search tests (no-lance-index failure
envelope, hybrid+all fail-fast, hybrid FTS fallback, explain/dedup/chunks)
that monkeypatch run_search -> vector path -> embed the query.
- test_mcp_v2_compose.py: add needs_vectors marker (mirror test_mcp_v2.py) +
apply to 2 search tests.
- test_brownfield_overrides.py: importorskip('cocoindex') on the 2 Lance chunk
schema pre-flights, importorskip('lancedb') on the round-trip test.
- test_lance_optimize.py: importorskip('lancedb') on the TABLES-constant parity
test (search_lancedb imports lancedb at module top).
Path-aware (graph-only is now a supported mode — keep coverage there instead
of skipping):
- test_java_codebase_rag_cli.py: add _vector_stack_available(); make
test_increment_first_run_falls_back_to_full and test_cli_tables_lists_known_table
assert the graph-only-correct output (increment notes "vectors skipped";
`tables` is {} but `graph` is reported).
Verified locally: simulating graph-only (sys.modules[m]=None flips both
find_spec and import) makes all 15 vector-only tests SKIP; running them
normally (vector path) they PASS; the 2 CLI tests pass on the vector path.
Collection clean (264 tests). Ruff clean on changed lines (2 pre-existing
`import types` F401 in test_lance_optimize.py left untouched — out of scope).
Co-Authored-By: Claude <noreply@anthropic.com>
macOS-15-Intel CI: 17 failures → fixed (dea9ea2)All 17 failures on the new Skip (vector-specific contracts; the lexical path has its own coverage in
Path-aware (graph-only is now a supported mode → keep coverage there rather than skip):
Verified locally by simulating graph-only ( Note: this leg is |
Replace the macOS-Intel heuristic keyword scan with Okapi BM25 over a LadybugDB FTS index on Symbol.search_text, fetched DB-side — killing the bounded 5000-row Python scan that silently missed matches past the cap on large repos. The hand-rolled name/type/fqn/role heuristic now re-ranks the BM25 candidates, and remains the fallback when the FTS index or extension is unavailable (older graph, or an offline first run where INSTALL FTS can't fetch from extension.ladybugdb.com). LadybugDB (the embedded graph DB that is kuzu's ancestor, not a kuzu wrapper) ships this natively in the same code_graph.lbug the graph lives in — no new dependency, no sidecar store. The PR #403 note "upgradable to SQLite FTS5" pointed at the wrong store. Build (build_ast_graph): - Symbol.search_text column: camelCase-split token soup of name + fqn + signature + annotations + capabilities (same _split_identifier the query path uses, so index/query tokenization agree). - sym_fts FTS index (Okapi BM25, porter stemmer): ensured at the end of every full build and as a self-heal on incremental. Auto-maintains on COPY/MERGE/ DELETE, so increment stays fresh without drop+recreate. - _drop_all drops the FTS index first (LadybugDB refuses DROP TABLE on a table referenced by an FTS index); incremental_rebuild and _delete_file_scope LOAD EXTENSION FTS so Symbol DML can maintain the index. - ONTOLOGY_VERSION 18 -> 19 (semantic change; one-time reindex). Query (search_lexical): BM25-first — QUERY_FTS_INDEX fetches top-K candidates, re-MATCH re-applies the NodeFilter (role/module/path/kind), then the heuristic re-ranks and dedup_by_fqn runs as before. Per-connection FTS-load cache keyed by object (WeakSet), not id() — id() is reused after GC and broke under test batching. Same SearchHit/SearchOutput/NodeFilter contract; _score_components gains a bm25 entry. Bench (~/jrag-bench/shopizer, master vs branch): build phases flat (init 34.65->30.19s, reprocess --graph-only 7.63->7.21s, increment 18.98->17.30s — within run variance; vectors phase untouched); query mean 82.8ms -> 23.6ms (3.5x), max 111.7ms -> 25.0ms. Full suite green. Fork B (BM25 + vector hybrid via RRF on the primary path) tracked in #431. Co-authored-by: Claude <noreply@anthropic.com>
Problem
On macOS Intel (x86_64) the vector stack is uninstallable —
torch>=2.3andlancedb>=0.26both dropped macOS x86_64 wheels — so installs run graph-only andsearchreturns an emptysuccess=Falseenvelope. Vectors can't come back cleanly: the last Inteltorch(2.2.2) caps at Python 3.11, whilerequires-python = ">=3.11"actively supports 3.12/3.13.Change
Make
searchreturn ranked keyword results on Intel via a new lexical backend over the symbol graph that graph-only mode already builds. Same tool contract — sameSearchHit/SearchOutput, sameNodeFilter, same CLI — just keyword-ranked instead of semantic, with an advisory noting the mode. No reindex, no new heavy deps; Apple Silicon/Linux/Windows are unchanged.search_scoring.py(new) — dependency-free scoring/dedup primitives extracted fromsearch_lancedb.py(which imports lancedb at module top and is unimportable on Intel), so both backends share them. Re-exported fromsearch_lancedbfor backward compat.explain_score_componentsgains alexical=flag.search_lexical.py(new) —run_lexical_search(): LadybugDBSymbolquery with role/path pushdown, name/type/fqn/signature relevance scoring (normalized[0,1]), real source snippets read from disk (graphsource_root, signature fallback),_dedup_by_fqn, and the same row-dict shape asrun_search. Raises a clean"lexical search unavailable"error when no graph exists.mcp_v2.py— therun_search is Nonebranch now dispatches to lexical and converges on the existing row→hit loop;SearchOutput.lexical_mode; advisories for lexical mode / sql-yaml-not-indexed / hybrid-ignored.pyproject.toml— registersearch_lexical+search_scoringaspy-modulesso the installedjragCLI can import them from outside the repo. (Without this the real Intel path ships broken — caught by the full suite; an in-process sim missed it because the repo root was onsys.path.)tests/test_search_lexical.py, 10 cases; updatedtests/test_graph_only_boot.py) + docs (README, CONFIGURATION, AGENT-GUIDE).Verification
test_graph_only_boot,test_search_lexical,test_search_lancedb,test_search_lancedb_capability,test_lance_optimize,test_mcp_v2,test_mcp_v2_compose.jragCLI from/tmpwith the vector stack blocked (simulating macOS Intel):Notes / out of scope
Symbolnodes are searched;sql/yamlLanceDB tables don't exist in graph-only mode (advisory). Route/Client/Producer remain reachable viafind/resolve/neighbors.🤖 Generated with Claude Code