CamQuestByte Claude Opus 4.8 commited on
Commit
195ff64
Β·
1 Parent(s): 5132b10

M4: non-indexing Gradio app + deps refresh

Browse files

app.py: rewrite boot to be non-indexing β€” load corpus, attach slim BM25 index (full mode) or build in-memory (smoke), connect Qdrant and refuse to embed at boot. Four modes (BM25/Dense/Hybrid/Hybrid+Rerank, pool=20) in a selectable two-column compare UI with per-column latency and per-mode error isolation. requirements.txt: drop dead rank-bm25, add bm25s; mirror pyproject. pyproject/uv.lock: prune rank-bm25 (superseded by bm25s in M1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +141 -65
  2. pyproject.toml +0 -1
  3. requirements.txt +4 -1
  4. uv.lock +0 -14
app.py CHANGED
@@ -1,94 +1,166 @@
1
  """
2
- M0 Hello World β€” Gradio UI
3
 
4
- Shows BM25 and dense results side-by-side for the same query.
5
- Uses SMOKE_TEST_SIZE docs (default: 100).
6
 
7
- To run locally:
8
- python app.py
 
 
9
 
10
- To deploy to HF Spaces:
11
- - Push this repo to a HF Space (Gradio SDK)
12
- - Set QDRANT_URL, QDRANT_API_KEY as Space secrets
13
- - Set SMOKE_TEST_SIZE=100 for M0, -1 for full corpus (M4)
 
14
  """
15
 
16
- import sys
17
  import os
 
 
 
 
18
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
19
 
20
  import gradio as gr
21
 
 
22
  from codesearch.data import load_codesearch
23
  from codesearch.retrievers.bm25 import BM25Retriever
 
24
  from codesearch.retrievers.dense import DenseRetriever
25
- from codesearch.config import TOP_K
 
26
 
27
- # ---------------------------------------------------------------------------
28
- # Boot: load data + build indexes once at startup
29
- # ---------------------------------------------------------------------------
30
 
31
- print("=== CodeSearch M0 Boot ===")
32
- corpus, queries = load_codesearch()
 
 
 
33
 
34
- print("Building BM25 index...")
35
- bm25 = BM25Retriever(corpus)
 
36
 
37
- print("Initializing dense retriever...")
38
- dense = DenseRetriever(recreate_collection=False)
39
- dense.index_corpus(corpus)
40
 
41
- print("Ready.")
 
 
 
 
42
 
43
- # ---------------------------------------------------------------------------
44
- # Search function
45
- # ---------------------------------------------------------------------------
 
 
 
46
 
47
- def search(query: str) -> tuple[str, str]:
48
- """Run BM25 and dense retrieval, return formatted results for both columns."""
49
- if not query.strip():
50
- return "Enter a query above.", "Enter a query above."
 
 
51
 
52
- bm25_results = bm25.retrieve(query, top_k=TOP_K)
53
- dense_results = dense.retrieve(query, top_k=TOP_K)
54
 
55
- return _format_results(bm25_results), _format_results(dense_results)
 
 
 
 
 
 
56
 
 
57
 
58
- def _format_results(results: list[dict]) -> str:
59
- if not results:
60
- return "No results."
61
 
62
- lines = []
63
- for i, r in enumerate(results, 1):
64
- score = r.get("score", 0.0)
65
- docstring = (r["docstring"] or "").strip()[:200]
66
- code_preview = (r["code"] or "").strip()[:300]
67
- url = r.get("url", "")
68
 
69
- lines.append(
70
- f"**#{i} β€” score: {score:.4f}**\n"
71
- f"**Docstring:** {docstring}{'…' if len(r.get('docstring','')) > 200 else ''}\n\n"
72
- f"```python\n{code_preview}{'…' if len(r.get('code','')) > 300 else ''}\n```\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  + (f"[source]({url})\n" if url else "")
74
  + "\n---"
75
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- return "\n".join(lines)
 
 
 
 
 
 
 
78
 
79
 
80
  # ---------------------------------------------------------------------------
81
- # Gradio UI
82
  # ---------------------------------------------------------------------------
83
 
84
- with gr.Blocks(title="CodeSearch M0") as demo:
85
  gr.Markdown(
86
- """
87
- # πŸ” CodeSearch β€” M0 Hello World
88
- **Dataset:** CodeSearchNet Python ({n} docs)
89
- **Retrieval modes:** BM25 (left) vs Dense / HNSW (right)
90
- Notice where they agree and where they diverge β€” that gap is what this project is about.
91
- """.format(n=len(corpus))
92
  )
93
 
94
  with gr.Row():
@@ -100,17 +172,21 @@ Notice where they agree and where they diverge β€” that gap is what this project
100
  search_btn = gr.Button("Search", variant="primary", scale=1)
101
 
102
  with gr.Row():
103
- bm25_out = gr.Markdown(label="BM25 results")
104
- dense_out = gr.Markdown(label="Dense (MiniLM) results")
105
-
106
- with gr.Row():
107
- gr.Markdown(
108
- "_Tip: try a query like 'convert string to datetime' β€” "
109
- "BM25 needs those exact words, dense finds semantically similar code._"
110
- )
111
-
112
- search_btn.click(fn=search, inputs=query_box, outputs=[bm25_out, dense_out])
113
- query_box.submit(fn=search, inputs=query_box, outputs=[bm25_out, dense_out])
 
 
 
 
114
 
115
 
116
  if __name__ == "__main__":
 
1
  """
2
+ CodeSearch β€” Gradio UI
3
 
4
+ Side-by-side comparison of four retrieval modes over CodeSearchNet Python:
5
+ BM25 Β· Dense (MiniLM) Β· Hybrid (RRF) Β· Hybrid + cross-encoder rerank
6
 
7
+ Boot is NON-INDEXING by design:
8
+ - Dense vectors live in Qdrant, built offline via scripts/index_corpus.py.
9
+ - BM25 loads a prebuilt slim index (.cache/bm25-slim) in full-corpus mode.
10
+ - The app refuses to embed the corpus at boot (see the guard below).
11
 
12
+ Modes:
13
+ SMOKE_TEST_SIZE=-1 β†’ full corpus; BM25 from the slim index. (HF Space secret)
14
+ SMOKE_TEST_SIZE=100 β†’ local pipeline check; BM25 built in-memory. Dense/hybrid
15
+ modes return full-Qdrant ids not in the small corpus, so
16
+ only BM25 is meaningful in smoke mode.
17
  """
18
 
 
19
  import os
20
+ import sys
21
+ import time
22
+ import traceback
23
+
24
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
25
 
26
  import gradio as gr
27
 
28
+ from codesearch.config import SMOKE_TEST_SIZE, TOP_K
29
  from codesearch.data import load_codesearch
30
  from codesearch.retrievers.bm25 import BM25Retriever
31
+ from codesearch.retrievers.bm25_index import BM25Index
32
  from codesearch.retrievers.dense import DenseRetriever
33
+ from codesearch.retrievers.hybrid import HybridRetriever
34
+ from codesearch.retrievers.reranker import CrossEncoderReranker
35
 
36
+ _HERE = os.path.dirname(os.path.abspath(__file__))
37
+ BM25_SLIM_DIR = os.path.join(_HERE, ".cache", "bm25-slim")
 
38
 
39
+ # Candidate pool for hybrid fusion and reranking in the UI. Small on purpose:
40
+ # reranking is inference-bound (~20 pairs/query on CPU), and K=20 keeps
41
+ # hybrid+rerank under the 3s latency budget. Eval uses K=100 for Recall; the UI
42
+ # trades a little recall for responsiveness. See M3.2 design notes.
43
+ UI_POOL = 20
44
 
45
+ # ---------------------------------------------------------------------------
46
+ # Boot: load corpus + wire retrievers once. No indexing happens here.
47
+ # ---------------------------------------------------------------------------
48
 
49
+ print("=== CodeSearch Boot ===")
50
+ corpus, _ = load_codesearch() # queries unused β€” the app takes live user input
 
51
 
52
+ # Two id-keyed views over the corpus:
53
+ # corpus_by_id β€” for rendering result cards (dense/hybrid hits carry id+score only)
54
+ # corpus_lookup β€” reranker candidate text (code_tokens, already space-joined)
55
+ corpus_by_id = {d["id"]: d for d in corpus}
56
+ corpus_lookup = {d["id"]: d["code_tokens"] for d in corpus}
57
 
58
+ if SMOKE_TEST_SIZE == -1:
59
+ print(f"Loading slim BM25 index from {BM25_SLIM_DIR} ...")
60
+ bm25 = BM25Retriever(BM25Index.load_index_only(BM25_SLIM_DIR, corpus))
61
+ else:
62
+ print(f"Smoke mode (n={SMOKE_TEST_SIZE}): building in-memory BM25 index...")
63
+ bm25 = BM25Retriever(corpus)
64
 
65
+ dense = DenseRetriever(recreate_collection=False)
66
+ if not dense.collection_exists_and_populated():
67
+ raise RuntimeError(
68
+ "Qdrant collection is empty. Build it offline with scripts/index_corpus.py β€” "
69
+ "the app will not embed the full corpus at boot."
70
+ )
71
 
72
+ hybrid = HybridRetriever(bm25, dense, pool_size=UI_POOL)
73
+ reranker = CrossEncoderReranker(hybrid, corpus_lookup, pool_size=UI_POOL)
74
 
75
+ RETRIEVERS = {
76
+ "BM25": bm25,
77
+ "Dense (MiniLM)": dense,
78
+ "Hybrid (RRF)": hybrid,
79
+ "Hybrid + Rerank": reranker,
80
+ }
81
+ MODES = list(RETRIEVERS.keys())
82
 
83
+ print(f"Ready. corpus={len(corpus):,} docs Β· modes={MODES}")
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # Search + formatting
87
+ # ---------------------------------------------------------------------------
88
 
 
 
 
 
 
 
89
 
90
+ def _fmt_latency(dt: float) -> str:
91
+ return f"⏱ **{dt:.2f} s**" if dt >= 1.0 else f"⏱ **{dt * 1000:.0f} ms**"
92
+
93
+
94
+ def _format(hits: list[dict], mode: str) -> str:
95
+ if not hits:
96
+ return "_No results._"
97
+
98
+ cards = []
99
+ for i, h in enumerate(hits, 1):
100
+ doc = corpus_by_id.get(h["id"])
101
+ if doc is None:
102
+ cards.append(
103
+ f"**#{i}** β€” `{h['id']}` not in local corpus "
104
+ f"(expected in smoke mode; full corpus on the Space)."
105
+ )
106
+ continue
107
+
108
+ docstring = (doc.get("docstring") or "").strip()[:200]
109
+ code = (doc.get("code") or "").strip()[:400]
110
+ url = doc.get("url", "")
111
+ score = h.get("score", 0.0)
112
+
113
+ # Provenance line: shows WHY a doc ranked where it did, per mode.
114
+ prov = f"score **{score:.4f}**"
115
+ if h.get("bm25_rank") is not None or h.get("dense_rank") is not None:
116
+ prov += f" Β· bm25 #{h.get('bm25_rank')} Β· dense #{h.get('dense_rank')}"
117
+ if "pre_rerank_score" in h:
118
+ prov += f" Β· pre-rerank {h['pre_rerank_score']:.4f}"
119
+
120
+ cards.append(
121
+ f"**#{i}** β€” {prov}\n\n"
122
+ f"{docstring}{'…' if docstring else ''}\n\n"
123
+ f"```python\n{code}{'…' if code else ''}\n```\n"
124
  + (f"[source]({url})\n" if url else "")
125
  + "\n---"
126
  )
127
+ return "\n".join(cards)
128
+
129
+
130
+ def _run(mode: str, query: str) -> tuple[str, str]:
131
+ retr = RETRIEVERS[mode]
132
+ t0 = time.perf_counter()
133
+ try:
134
+ hits = retr.retrieve(query, top_k=TOP_K)
135
+ except Exception as e: # one mode failing must not take down the search
136
+ traceback.print_exc()
137
+ return f"⚠️ **{mode} failed:** `{type(e).__name__}: {e}`", "β€”"
138
+ dt = time.perf_counter() - t0
139
+ return _format(hits, mode), _fmt_latency(dt)
140
+
141
 
142
+ def search(query: str, left_mode: str, right_mode: str):
143
+ """Run both columns and return (left_md, left_latency, right_md, right_latency)."""
144
+ if not query.strip():
145
+ msg = "_Enter a query above._"
146
+ return msg, "", msg, ""
147
+ left_md, left_lat = _run(left_mode, query)
148
+ right_md, right_lat = _run(right_mode, query)
149
+ return left_md, left_lat, right_md, right_lat
150
 
151
 
152
  # ---------------------------------------------------------------------------
153
+ # UI β€” selectable two-column compare
154
  # ---------------------------------------------------------------------------
155
 
156
+ with gr.Blocks(title="CodeSearch") as demo:
157
  gr.Markdown(
158
+ f"""
159
+ # πŸ” CodeSearch
160
+ Natural-language code search over **CodeSearchNet Python** ({len(corpus):,} functions).
161
+ Pick a retriever per column and compare β€” watch where lexical (BM25), semantic
162
+ (dense), fusion (RRF), and reranking disagree.
163
+ """
164
  )
165
 
166
  with gr.Row():
 
172
  search_btn = gr.Button("Search", variant="primary", scale=1)
173
 
174
  with gr.Row():
175
+ with gr.Column():
176
+ left_mode = gr.Dropdown(MODES, value="BM25", label="Left retriever")
177
+ left_lat = gr.Markdown()
178
+ left_out = gr.Markdown()
179
+ with gr.Column():
180
+ right_mode = gr.Dropdown(
181
+ MODES, value="Hybrid + Rerank", label="Right retriever"
182
+ )
183
+ right_lat = gr.Markdown()
184
+ right_out = gr.Markdown()
185
+
186
+ inputs = [query_box, left_mode, right_mode]
187
+ outputs = [left_out, left_lat, right_out, right_lat]
188
+ search_btn.click(fn=search, inputs=inputs, outputs=outputs)
189
+ query_box.submit(fn=search, inputs=inputs, outputs=outputs)
190
 
191
 
192
  if __name__ == "__main__":
pyproject.toml CHANGED
@@ -8,7 +8,6 @@ version = "0.1.0"
8
  description = "Semantic code search: BM25 vs dense vs hybrid β€” with rigorous eval"
9
  requires-python = ">=3.11"
10
  dependencies = [
11
- "rank-bm25>=0.2.2",
12
  "sentence-transformers>=3.0.0",
13
  "qdrant-client>=1.9.0",
14
  "gradio>=4.36.0",
 
8
  description = "Semantic code search: BM25 vs dense vs hybrid β€” with rigorous eval"
9
  requires-python = ">=3.11"
10
  dependencies = [
 
11
  "sentence-transformers>=3.0.0",
12
  "qdrant-client>=1.9.0",
13
  "gradio>=4.36.0",
requirements.txt CHANGED
@@ -1,4 +1,7 @@
1
- rank-bm25>=0.2.2
 
 
 
2
  sentence-transformers>=3.0.0
3
  qdrant-client>=1.9.0
4
  gradio>=4.36.0
 
1
+ # HF Spaces build deps. Hand-maintained to mirror pyproject.toml runtime
2
+ # dependencies (top-level only; pip resolves transitives β€” incl. torch via
3
+ # sentence-transformers β€” against HF's base image). Keep in sync with pyproject.
4
+ bm25s>=0.3.4
5
  sentence-transformers>=3.0.0
6
  qdrant-client>=1.9.0
7
  gradio>=4.36.0
uv.lock CHANGED
@@ -417,7 +417,6 @@ dependencies = [
417
  { name = "numpy" },
418
  { name = "python-dotenv" },
419
  { name = "qdrant-client" },
420
- { name = "rank-bm25" },
421
  { name = "sentence-transformers" },
422
  { name = "tqdm" },
423
  ]
@@ -437,7 +436,6 @@ requires-dist = [
437
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
438
  { name = "python-dotenv", specifier = ">=1.0.0" },
439
  { name = "qdrant-client", specifier = ">=1.9.0" },
440
- { name = "rank-bm25", specifier = ">=0.2.2" },
441
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
442
  { name = "sentence-transformers", specifier = ">=3.0.0" },
443
  { name = "tqdm", specifier = ">=4.66.0" },
@@ -2163,18 +2161,6 @@ wheels = [
2163
  { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" },
2164
  ]
2165
 
2166
- [[package]]
2167
- name = "rank-bm25"
2168
- version = "0.2.2"
2169
- source = { registry = "https://pypi.org/simple" }
2170
- dependencies = [
2171
- { name = "numpy" },
2172
- ]
2173
- sdist = { url = "https://files.pythonhosted.org/packages/fc/0a/f9579384aa017d8b4c15613f86954b92a95a93d641cc849182467cf0bb3b/rank_bm25-0.2.2.tar.gz", hash = "sha256:096ccef76f8188563419aaf384a02f0ea459503fdf77901378d4fd9d87e5e51d", size = 8347, upload-time = "2022-02-16T12:10:52.196Z" }
2174
- wheels = [
2175
- { url = "https://files.pythonhosted.org/packages/2a/21/f691fb2613100a62b3fa91e9988c991e9ca5b89ea31c0d3152a3210344f9/rank_bm25-0.2.2-py3-none-any.whl", hash = "sha256:7bd4a95571adadfc271746fa146a4bcfd89c0cf731e49c3d1ad863290adbe8ae", size = 8584, upload-time = "2022-02-16T12:10:50.626Z" },
2176
- ]
2177
-
2178
  [[package]]
2179
  name = "regex"
2180
  version = "2026.4.4"
 
417
  { name = "numpy" },
418
  { name = "python-dotenv" },
419
  { name = "qdrant-client" },
 
420
  { name = "sentence-transformers" },
421
  { name = "tqdm" },
422
  ]
 
436
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
437
  { name = "python-dotenv", specifier = ">=1.0.0" },
438
  { name = "qdrant-client", specifier = ">=1.9.0" },
 
439
  { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
440
  { name = "sentence-transformers", specifier = ">=3.0.0" },
441
  { name = "tqdm", specifier = ">=4.66.0" },
 
2161
  { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" },
2162
  ]
2163
 
 
 
 
 
 
 
 
 
 
 
 
 
2164
  [[package]]
2165
  name = "regex"
2166
  version = "2026.4.4"