Skip to content

Adopt whole-file BM25 ranking - #256

Closed
Skyline-23 wants to merge 1 commit into
trailhq:mainfrom
Skyline-23:feat/file-one-hop-rerank
Closed

Skyline-23 wants to merge 1 commit into
trailhq:mainfrom
Skyline-23:feat/file-one-hop-rerank

Conversation

@Skyline-23

@Skyline-23 Skyline-23 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #257.

Summary

  • rank standalone file-first retrieval with BM25 over repository-relative paths and raw source files
  • persist the whole-file BM25 index in the existing ask sidecar
  • rebuild the file index live when the sidecar is missing or predates the new field
  • keep existing symbol and graph ranking paths for structural navigation and representative span selection
  • preserve workspace federation behavior
  • add focused coverage for tokenization, cache parity, prefix filtering, fallback behavior, and ranking-layer isolation

Motivation

Direct one-hop graph score propagation was not stable enough to ship. Low propagation weights had little effect, while strong propagation admitted graph clusters and caused repository-specific regressions.

The raw-file BM25 baseline improves pooled retrieval quality without language-specific rules or repository-specific tuning.

Benchmark

Four repositories, 155 valid historical PR cases:

Condition R@1 R@5 R@10 MRR B400 Paired R@10 gain/loss
Natural 24.9% → 28.7% 47.0% → 58.3% 58.4% → 64.2% .425 → .477 57.0% → 63.9% 23 / 18
Stem-blind 13.9% → 20.7% 26.0% → 41.8% 35.5% → 47.7% .222 → .349 35.2% → 44.9% 36 / 12

Natural R@10 by repository:

  • PocketBase: 62.0% → 63.2%
  • NestJS: 63.7% → 62.5%
  • Django: 60.0% → 72.7%
  • Spring Boot: 50.4% → 57.0%

NestJS has a small Natural R@10 regression, but its R@1, R@5, MRR, and stem-blind metrics improve. Pooled Natural and stem-blind results improve.

Validation

  • npm run build
  • BM25-focused tests: 66/66
  • GraphRank isolation tests: 15/15
  • full suite in an isolated clean checkout: 1062 passed, 0 failed, 1 skipped
  • graft build
  • graft check
  • git diff --check

The benchmark artifacts are retained separately in graft-retrieval-bench/results/file-one-hop-product-bm25-*-k10-b400.json.

- Rank file-first retrieval from raw path and source text with benchmark-compatible BM25.
- Persist the file index during graph builds and fall back to live indexing for older caches.
- Preserve legacy symbol and graph ranking paths behind focused internal test controls.
- Cover tokenization, sidecar parity, prefix filtering, and ranking-layer isolation.
@trailhq-graft

trailhq-graft Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

2 areas changed → 6 areas can be affected. 17 dependent symbols, depth 2.
Tests: 2 areas updated their tests.
Tag: @anirudhkumar-nanonets — 6 of 8 areas · @shhdwi — 7 of 8 areas · @bhavesh-gupta-investis — Synchronous Execution

flowchart TB
  A0(("Workspace Graph Federation<br/>6 symbols"))
  A1(("CLI Engine<br/>3 symbols"))
  A2(("MCP Tool Invocation<br/>3 symbols"))
  A3(("Pull Request Review<br/>3 symbols"))
  A4(("Viewer Build<br/>1 symbol"))
  AX(("1 smaller area<br/>1 symbol"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Workspace Graph Federation 6 src/graph/load.ts:L95-L100 loadAskIndexCached — calls, depth 1 File Search Indexing, Graph Construction
CLI Engine 3 src/engine.ts:L105-L114 ask — calls, depth 1 File Search Indexing, Graph Construction
MCP Tool Invocation 3 src/mcp/tools.ts:L256-L338 callSingleTool — calls, depth 2 File Search Indexing, Graph Construction
Pull Request Review 3 src/app/review.ts:L45-L96 reviewPullRequest — calls, depth 1 File Search Indexing, Graph Construction
Viewer Build 1 scripts/build-viewer.mjs:L1-L45 build-viewer.mjs — calls, depth 2 Graph Construction
Synchronous Execution 1 src/claude/sync-run.ts:L19-L33 runSync — calls, depth 2 Graph Construction
Who knows this code — 3 people across 8 areas
Area Who knows it
Graph Construction · changed @anirudhkumar-nanonets — 17 commits, last 8d ago · @shhdwi — 5 commits, last 16d ago
File Search Indexing · changed @shhdwi — 16 commits, last 29d ago · @anirudhkumar-nanonets — 12 commits, last 17d ago
Workspace Graph Federation · affected @anirudhkumar-nanonets — 9 commits, last 17d ago · @shhdwi — 8 commits, last 16d ago
CLI Engine · affected @anirudhkumar-nanonets — 36 commits, last yesterday · @shhdwi — 24 commits, last 15d ago
MCP Tool Invocation · affected @shhdwi — 14 commits, last 16d ago · @anirudhkumar-nanonets — 6 commits, last 17d ago
Pull Request Review · affected @anirudhkumar-nanonets — 2 commits, last today
Viewer Build · affected @shhdwi — 2 commits, last 16d ago
Synchronous Execution · affected @shhdwi — 3 commits, last 1mo ago · @bhavesh-gupta-investis — 1 commit, last 4d ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 17 dependent symbols, grouped by area

Workspace Graph Federation — 6 symbols in 4 files

  • src/graph/load.ts:L95-L100 — loadAskIndexCached (calls, depth 1)
    95: export function loadAskIndexCached(outDir: string): AskIndex | null {
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 1)
    208: // `graphOnly`: write the graph, the ask sidecar and the fingerprint, nothing
  • src/graph/workspace.ts:L225-L342 — federateAsk (calls, depth 1)
    249: // Pass 1: run each child's ask; keep its hits + RAW top-hit coverage.
  • src/graph/refresh.ts:L235-L261 — ensureFreshChildren (calls, depth 2)
  • src/graph/workspace-cli.ts:L88-L97 — runWorkspaceAsk (calls, depth 2)
  • src/graph/workspace-cli.ts:L49-L70 — buildChild (calls, depth 2)

CLI Engine — 3 symbols in 2 files

  • src/engine.ts:L105-L114 — ask (calls, depth 1)
    105: ask(dir: string, query: string, opts: { limit?: number; source?: boolean; full?: boolean; in?: string; graphRank?: boolean } = {}): AskResult {
  • src/engine.ts:L88-L98 — graph (calls, depth 1)
    89: return buildGraph(dir, {
  • src/cli.ts:L157-L167 — refreshBefore (calls, depth 2)

MCP Tool Invocation — 3 symbols in 1 file

  • src/mcp/tools.ts:L256-L338 — callSingleTool (calls, depth 2)
    269: const r = engine.ask(root, query, { limit, source: true, full: args.full === true, in: inArg });
  • src/mcp/tools.ts:L151-L197 — callWorkspaceTool (calls, depth 2)
  • src/mcp/tools.ts:L228-L253 — callTool (calls, depth 2)

Pull Request Review — 3 symbols in 2 files

  • src/app/review.ts:L45-L96 — reviewPullRequest (calls, depth 1)
    52: await buildGraph(checkout.dir);
  • src/app/server.ts:L24-L29 — AppSeams (references, depth 2)
  • src/app/server.ts:L41-L126 — createApp (references, depth 2)

Viewer Build — 1 symbol in 1 file

  • scripts/build-viewer.mjs:L1-L45 — build-viewer.mjs (calls, depth 2)
    3: * assets). Runs as part of `npm run build`; the bundle ships in the package

Synchronous Execution — 1 symbol in 1 file

  • src/claude/sync-run.ts:L19-L33 — runSync (calls, depth 2)
Test signal per changed area — 2 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Graph Construction — 1 of 1 reached · 3 test files changed here: test/ask-index.test.ts, test/ask.test.ts, test/graphrank.test.ts
  • ✓ File Search Indexing — 6 of 13 reached · 4 test files changed here: test/ask-index.test.ts, test/ask.test.ts, test/file-bm25.test.ts, test/graphrank.test.ts
    • not reached: lexical, liveFileSources, counts, identifierParts, idfFor, pairs, underPrefix
29 test suites also reference this code

37 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/container-extract.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-load.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • test/graph-r.test.ts
  • test/graph-references.test.ts
  • test/graph-refresh.test.ts
  • …9 more

graft blast · origin/main...HEAD · depth 2 · 8 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

@Skyline-23 Skyline-23 closed this Aug 28, 2026
@Skyline-23 Skyline-23 reopened this Aug 28, 2026
github-actions Bot added a commit that referenced this pull request Aug 28, 2026
@Skyline-23 Skyline-23 closed this Aug 28, 2026
github-actions Bot added a commit that referenced this pull request Aug 28, 2026
@Skyline-23 Skyline-23 reopened this Aug 28, 2026
github-actions Bot added a commit that referenced this pull request Aug 28, 2026
@shhdwi

shhdwi commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Verified locally on top of current main — this is solid work and it builds/tests clean (full ask suite 131/131, the "identical with/without sidecar" acceptance test passes, sidecar persisted at build with a live rebuild fallback). Concrete #257 win reproduced: "authentication token refresh" promotes the body/comment-heavy handler.ts to #1 where symbol ranking put authService.ts first.

The reason I'm not merging it straight away is a product-direction call for @shrishdwi, not a defect: this makes whole-file BM25 the default file-first path, which knowingly reclassifies the guarantees that just landed in #205/#137/#126 (exact top-lock, sibling-span delay, test-file de-ranking) as "legacy" behind fileBm25:false. The 155-case benchmark backing the switch is external, so the numbers can't be reproduced in-repo, and there's a small NestJS R@10 dip alongside the net gains.

@shrishdwi — this is a genuine "which retrieval philosophy is the default" decision. Options: (1) merge as-is (BM25 default, symbol heuristics behind the flag), (2) land it flipped — fileBm25 opt-in until the benchmark is reproducible in-repo, or (3) add the 155-case eval to the repo first so future ranking changes are guarded. All three keep this work; happy to do whichever you pick. Really nice measurement-driven contribution regardless.

@shhdwi

shhdwi commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the careful benchmark! Since this was opened, main's ask pipeline has changed a lot (IDF-weighted scoring, random-walk graph ranking, the fusion step in src/ask/fuse.ts), and this PR replaces main's default file ordering outright. In a quick check of 12 queries against graft's own source, each with a known target file, current main ranked the target the same or higher every time, and this branch ranked it lower in 7 of them (for example shim-template.ts #1→#8 and openai.ts #1→#3). If whole-file BM25 still wins when re-benchmarked against current main, ideally added as one more signal in fuse.ts rather than replacing the file order, we would be glad to review a fresh PR. Closing this one.

@shhdwi shhdwi closed this Sep 29, 2026
github-actions Bot added a commit that referenced this pull request Sep 29, 2026
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.

Adopt whole-file BM25 for file-first retrieval

2 participants