codesearch / app.py
CamQuestByte
fix(ui): re-run a column when its retriever dropdown changes
3bc0c14
Raw
History Blame Contribute Delete
9.64 kB
"""
CodeSearch β€” Gradio UI
Side-by-side comparison of four retrieval modes over CodeSearchNet Python:
BM25 Β· Dense (MiniLM) Β· Hybrid (RRF) Β· Hybrid + cross-encoder rerank
Boot is NON-INDEXING by design:
- Dense vectors live in Qdrant, built offline via scripts/index_corpus.py.
- BM25 loads a prebuilt slim index (.cache/bm25-slim) in full-corpus mode.
- The app refuses to embed the corpus at boot (see the guard below).
Modes:
SMOKE_TEST_SIZE=-1 β†’ full corpus; BM25 from the slim index. (HF Space secret)
SMOKE_TEST_SIZE=100 β†’ local pipeline check; BM25 built in-memory. Dense/hybrid
modes return full-Qdrant ids not in the small corpus, so
only BM25 is meaningful in smoke mode.
"""
import os
import random
import sys
import time
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
import gradio as gr
from codesearch.config import SMOKE_TEST_SIZE, TOP_K
from codesearch.data import load_codesearch
from codesearch.retrievers.bm25 import BM25Retriever
from codesearch.retrievers.bm25_index import BM25Index
from codesearch.retrievers.dense import DenseRetriever
from codesearch.retrievers.hybrid import HybridRetriever
from codesearch.retrievers.reranker import CrossEncoderReranker
_HERE = os.path.dirname(os.path.abspath(__file__))
BM25_SLIM_DIR = os.path.join(_HERE, ".cache", "bm25-slim")
# Candidate pool for hybrid fusion and reranking in the UI. Small on purpose:
# reranking is inference-bound (~20 pairs/query on CPU), and K=20 keeps
# hybrid+rerank under the 3s latency budget. Eval uses K=100 for Recall; the UI
# trades a little recall for responsiveness. See M3.2 design notes.
UI_POOL = 20
# ---------------------------------------------------------------------------
# Boot: load corpus + wire retrievers once. No indexing happens here.
# ---------------------------------------------------------------------------
print("=== CodeSearch Boot ===")
corpus, queries = load_codesearch() # queries = eval set (test docstrings w/ known gold)
# Two id-keyed views over the corpus:
# corpus_by_id β€” for rendering result cards (dense/hybrid hits carry id+score only)
# corpus_lookup β€” reranker candidate text (code_tokens, already space-joined)
corpus_by_id = {d["id"]: d for d in corpus}
corpus_lookup = {d["id"]: d["code_tokens"] for d in corpus}
# Ground truth for the eval queries: each test docstring maps to the id of the
# function it documents. Lets the UI badge the "correct" hit and its rank β€” but
# only for these known queries; a free-form query has no gold.
gold_by_query = {q["query"]: q["relevant_id"] for q in queries}
eval_query_texts = list(gold_by_query.keys())
if SMOKE_TEST_SIZE == -1:
print(f"Loading slim BM25 index from {BM25_SLIM_DIR} ...")
bm25 = BM25Retriever(BM25Index.load_index_only(BM25_SLIM_DIR, corpus))
else:
print(f"Smoke mode (n={SMOKE_TEST_SIZE}): building in-memory BM25 index...")
bm25 = BM25Retriever(corpus)
dense = DenseRetriever(recreate_collection=False)
if not dense.collection_exists_and_populated():
raise RuntimeError(
"Qdrant collection is empty. Build it offline with scripts/index_corpus.py β€” "
"the app will not embed the full corpus at boot."
)
hybrid = HybridRetriever(bm25, dense, pool_size=UI_POOL)
reranker = CrossEncoderReranker(hybrid, corpus_lookup, pool_size=UI_POOL)
RETRIEVERS = {
"BM25": bm25,
"Dense (MiniLM)": dense,
"Hybrid (RRF)": hybrid,
"Hybrid + Rerank": reranker,
}
MODES = list(RETRIEVERS.keys())
print(f"Ready. corpus={len(corpus):,} docs Β· modes={MODES}")
# ---------------------------------------------------------------------------
# Search + formatting
# ---------------------------------------------------------------------------
def _fmt_latency(dt: float) -> str:
return f"⏱ **{dt:.2f} s**" if dt >= 1.0 else f"⏱ **{dt * 1000:.0f} ms**"
def _format(hits: list[dict], mode: str, gold_id: str | None = None) -> str:
if not hits:
return "_No results._"
cards = []
for i, h in enumerate(hits, 1):
doc = corpus_by_id.get(h["id"])
if doc is None:
cards.append(
f"**#{i}** β€” `{h['id']}` not in local corpus "
f"(expected in smoke mode; full corpus on the Space)."
)
continue
docstring = (doc.get("docstring") or "").strip()[:200]
code = (doc.get("code") or "").strip()[:400]
url = doc.get("url", "")
score = h.get("score", 0.0)
# Provenance line: shows WHY a doc ranked where it did, per mode.
prov = f"score **{score:.4f}**"
if h.get("bm25_rank") is not None or h.get("dense_rank") is not None:
prov += f" Β· bm25 #{h.get('bm25_rank')} Β· dense #{h.get('dense_rank')}"
if "pre_rerank_score" in h:
prov += f" Β· pre-rerank {h['pre_rerank_score']:.4f}"
gold = " βœ… **GOLD**" if gold_id is not None and h["id"] == gold_id else ""
cards.append(
f"**#{i}**{gold} β€” {prov}\n\n"
f"{docstring}{'…' if docstring else ''}\n\n"
f"```python\n{code}{'…' if code else ''}\n```\n"
+ (f"[source]({url})\n" if url else "")
+ "\n---"
)
return "\n".join(cards)
def _run(mode: str, query: str, gold_id: str | None) -> tuple[str, str]:
retr = RETRIEVERS[mode]
t0 = time.perf_counter()
try:
hits = retr.retrieve(query, top_k=TOP_K)
except Exception as e: # one mode failing must not take down the search
traceback.print_exc()
return f"⚠️ **{mode} failed:** `{type(e).__name__}: {e}`", "β€”"
dt = time.perf_counter() - t0
# Gold verdict header β€” only for known eval queries (free-form has no gold).
verdict = ""
if gold_id is not None:
rank = next((i for i, h in enumerate(hits, 1) if h["id"] == gold_id), None)
verdict = (
f"### βœ… Gold answer at rank #{rank}\n\n"
if rank is not None
else f"### βœ— Gold answer not in top-{TOP_K}\n\n"
)
return verdict + _format(hits, mode, gold_id), _fmt_latency(dt)
def pick_random() -> str:
"""Fill the query box with a random eval query (one whose gold we know)."""
return random.choice(eval_query_texts) if eval_query_texts else ""
def search(query: str, left_mode: str, right_mode: str):
"""Run both columns and return (left_md, left_latency, right_md, right_latency)."""
if not query.strip():
msg = "_Enter a query above._"
return msg, "", msg, ""
# Known only for eval queries (exact match); free-form typing β†’ gold_id None.
gold_id = gold_by_query.get(query) or gold_by_query.get(query.strip())
left_md, left_lat = _run(left_mode, query, gold_id)
right_md, right_lat = _run(right_mode, query, gold_id)
return left_md, left_lat, right_md, right_lat
def update_column(query: str, mode: str) -> tuple[str, str]:
"""Re-run a single column β€” wired to each retriever dropdown's change so
switching the retriever refreshes that column without re-running the other."""
if not query.strip():
return "_Enter a query above._", ""
gold_id = gold_by_query.get(query) or gold_by_query.get(query.strip())
return _run(mode, query, gold_id)
# ---------------------------------------------------------------------------
# UI β€” selectable two-column compare
# ---------------------------------------------------------------------------
with gr.Blocks(title="CodeSearch") as demo:
gr.Markdown(
f"""
# πŸ” CodeSearch
Natural-language code search over **CodeSearchNet Python** ({len(corpus):,} functions).
Pick a retriever per column and compare β€” watch where lexical (BM25), semantic
(dense), fusion (RRF), and reranking disagree.
Hit **🎲 Random eval query** to load a real benchmark query with a known answer β€”
the **βœ… GOLD** badge marks the correct function, and each column reports the rank it landed at.
"""
)
with gr.Row():
query_box = gr.Textbox(
placeholder="e.g. parse a JSON file and return a dict",
label="Query",
scale=4,
)
search_btn = gr.Button("Search", variant="primary", scale=1)
random_btn = gr.Button("🎲 Random eval query", scale=1)
with gr.Row():
with gr.Column():
left_mode = gr.Dropdown(MODES, value="BM25", label="Left retriever")
left_lat = gr.Markdown()
left_out = gr.Markdown()
with gr.Column():
right_mode = gr.Dropdown(
MODES, value="Hybrid + Rerank", label="Right retriever"
)
right_lat = gr.Markdown()
right_out = gr.Markdown()
inputs = [query_box, left_mode, right_mode]
outputs = [left_out, left_lat, right_out, right_lat]
search_btn.click(fn=search, inputs=inputs, outputs=outputs)
query_box.submit(fn=search, inputs=inputs, outputs=outputs)
# Random: fill the box with a known-gold eval query, then run the comparison.
random_btn.click(fn=pick_random, outputs=query_box).then(
fn=search, inputs=inputs, outputs=outputs
)
# Changing a retriever re-runs only its own column (keeps the other as-is).
left_mode.change(
fn=update_column, inputs=[query_box, left_mode], outputs=[left_out, left_lat]
)
right_mode.change(
fn=update_column, inputs=[query_box, right_mode], outputs=[right_out, right_lat]
)
if __name__ == "__main__":
demo.launch(theme=gr.themes.Monochrome())