Skip to content

feat(search): lexical fallback for graph-only / macOS Intel (PR-LEX)#403

Merged
HumanBean17 merged 5 commits into
masterfrom
feat/lexical-search-graph-only
Jul 7, 2026
Merged

feat(search): lexical fallback for graph-only / macOS Intel (PR-LEX)#403
HumanBean17 merged 5 commits into
masterfrom
feat/lexical-search-graph-only

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

Problem

On macOS Intel (x86_64) the vector stack is uninstallable — torch>=2.3 and lancedb>=0.26 both dropped macOS x86_64 wheels — so installs run graph-only and search returns an empty success=False envelope. Vectors can't come back cleanly: the last Intel torch (2.2.2) caps at Python 3.11, while requires-python = ">=3.11" actively supports 3.12/3.13.

Change

Make search return ranked keyword results on Intel via a new lexical backend over the symbol graph that graph-only mode already builds. Same tool contract — same SearchHit/SearchOutput, same NodeFilter, 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 from search_lancedb.py (which imports lancedb at module top and is 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 (new) — run_lexical_search(): LadybugDB Symbol query with role/path pushdown, name/type/fqn/signature relevance scoring (normalized [0,1]), real source snippets read from disk (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.
  • mcp_v2.py — the run_search is None branch 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 — register search_lexical + search_scoring as py-modules so the installed jrag CLI 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 on sys.path.)
  • Tests (tests/test_search_lexical.py, 10 cases; updated tests/test_graph_only_boot.py) + docs (README, CONFIGURATION, AGENT-GUIDE).

Verification

  • Affected suites green: test_graph_only_boot, test_search_lexical, test_search_lancedb, test_search_lancedb_capability, test_lance_optimize, test_mcp_v2, test_mcp_v2_compose.
  • Full suite: 1168 passed, 14 skipped.
  • End-to-end via the installed jrag CLI from /tmp with the vector stack blocked (simulating macOS Intel):
    $ jrag search "distribution chunk service" --limit 3
    DistributionChunkService  @svc  role=SERVICE  file=svc/DistributionChunkService.java:2  score=0.880  chunks=4
    next: jrag inspect svc.DistributionChunkService
    

Notes / out of scope

  • Ranking is heuristic keyword overlap (name > fqn > signature/annotation), not BM25. Upgradable to SQLite FTS5 later without changing the tool contract.
  • Only Java Symbol nodes are searched; sql/yaml LanceDB tables don't exist in graph-only mode (advisory). Route/Client/Producer remain reachable via find/resolve/neighbors.
  • Did not pursue pinning the old vector stack (stale, unsupported, Python-3.11-only).

🤖 Generated with Claude Code

HumanBean17 and others added 2 commits July 8, 2026 00:03
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>
@HumanBean17

Copy link
Copy Markdown
Owner Author

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) — jrag search --explain crashed on macOS Intel. jrag.py imported explain_score_components from search_lancedb, which is unimportable on graph-only installs (lancedb/sentence-transformers excluded by the PEP 508 markers). The re-export inside search_lancedb can't help — you can't import a name from a module that itself fails to load. Fixed: import from the dependency-free search_scoring. Test asserts the CLI source imports from search_scoring and that blocking the vector stack leaves search_scoring importable while search_lancedb is not.

C2 (Critical) — keyword ranking missed most matches on real repos. The lexical backend reused the vector-path overfetch limit (4× page) on an unordered Cypher scan with no ORDER BY. That's correct on the vector path (LanceDB returns rows pre-ranked by similarity) but on the lexical scan it returned only the first ~20 symbols in arbitrary storage order, so a plain search "distribution" on a >20-symbol repo silently missed its best match. Fixed: fetch the full candidate pool up to the safety cap (_CANDIDATE_LIMIT_CAP) and rank in Python. Test builds >20 distinct matching symbols and asserts they all return (verified: returns exactly 20 under the old formula, 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, so a member node Type#method(...) accepted by the Cypher pushdown was dropped by the post-filter. Fixed: added the raw fqn key. Test drives the bug end-to-end — runs the search, then re-checks each row through the same _node_matches_filter the real search_v2 loop uses (verified: fails with fqns=[None] without the key).

Verification: test_search_lexical.py (13) + test_graph_only_boot.py (4) green; test_mcp_v2.py + test_mcp_v2_compose.py (163) green; ruff clean on changed files.

The two Minor/Recommendation items from the review (test for table="all"; doc note on the 5000-symbol ceiling) are noted but deferred — happy to fold them in if wanted.

🤖 Generated with Claude Code

HumanBean17 and others added 2 commits July 8, 2026 00:40
… 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>
@HumanBean17

Copy link
Copy Markdown
Owner Author

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>
@HumanBean17

Copy link
Copy Markdown
Owner Author

macOS-15-Intel CI: 17 failures → fixed (dea9ea2)

All 17 failures on the new macos-15-intel leg were vector-stack-dependent tests that never self-skipped on a graph-only install — none related to the lexical-search feature. Two fix strategies:

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). These monkeypatch run_search → vector path → embed the query → need sentence_transformers.
  • test_mcp_v2_compose.py — added the needs_vectors marker (mirroring test_mcp_v2.py) + applied to 2 search tests.
  • test_brownfield_overrides.pyimportorskip("cocoindex") on the 2 Lance chunk-schema pre-flights, importorskip("lancedb") on the round-trip.
  • test_lance_optimize.pyimportorskip("lancedb") on the TABLES-constant parity test.

Path-aware (graph-only is now a supported mode → keep coverage there rather than skip):

  • test_java_codebase_rag_cli.py_vector_stack_available(); test_increment_first_run_falls_back_to_full and test_cli_tables_lists_known_table now assert the graph-only-correct output (increment notes "vectors skipped"; tables is {} but graph is reported). Vector-path behavior unchanged.

Verified locally by simulating graph-only (sys.modules[m]=None flips both find_spec and import, so needs_vectors and importorskip both trigger): the 15 vector-only tests SKIP; running them normally 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, and the workflow does not gate on ruff).

Note: this leg is continue-on-error (non-blocking) per the existing policy until it is reliably green across a few merges.

@HumanBean17 HumanBean17 merged commit 6910174 into master Jul 7, 2026
4 checks passed
HumanBean17 added a commit that referenced this pull request Jul 11, 2026
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>
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.

1 participant