Skip to content

Commit 96bf5e3

Browse files
committed
Improve query parsing, chunking, uploads & reindex
Multiple robustness and correctness fixes across ingestion, search, upload and ranking: - scripts/hybrid/filters.py: tighten DSL tokenizer to only match compact key:value (no spaces) to avoid capturing prose like "file: upload" or "repo: billing". - scripts/hybrid/ranking.py: fix span sorting to correctly order by score (negate score uniformly) to avoid wasting token budget on low-ranked penalized spans. - scripts/ingest/chunking.py: emit gap chunks for top-level code between symbols so imports/docstrings/top-level code are not dropped. - scripts/ingest/pipeline.py: move dedupe delete to after embeddings/upsert and add per-file lock wrapper for smart reindexing; add retries/fallback in pseudo_backfill_tick. - scripts/mcp_impl/context_search.py: scope fallback search to the same repo to avoid returning other repos' code. - scripts/mcp_impl/search.py: run in-process reranker with a timed ThreadPoolExecutor to honor rerank timeout, and ensure rerank_return_m is treated as a quality cap while honoring caller limits. - scripts/upload_delta_bundle.py: add atomic file writes, better error isolation for malformed/unsafe ops (skip and count failed ops instead of aborting bundle), collision warnings and safer apply logic. - scripts/upload_service.py: persist per-workspace upload sequence to disk, warn on workspace key collisions, handle idempotent replay, apply bundles inline (ack after apply) and return processing metrics; improved error responses. - scripts/workspace_state.py: make set_cached_file_hash atomic across threads/processes to avoid cache races. - tests: add query DSL tests, chunking regression test and update upload_service tests to assert failed ops are isolated rather than raising. These changes reduce silent failures, prevent data loss from partial operations, and make indexing/search behaviour more predictable.
1 parent 2024f17 commit 96bf5e3

12 files changed

Lines changed: 527 additions & 113 deletions

scripts/hybrid/filters.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,13 @@ def parse_query_dsl(queries: List[str]) -> Tuple[List[str], Dict[str, str]]:
150150
"""
151151
clean: List[str] = []
152152
extracted: Dict[str, str] = {}
153+
# Compact form only (`file:foo.py` — no whitespace around the colon).
154+
# Allowing `\s*` here turned natural-language queries like
155+
# "explain the file: upload flow" into hard filters (under="upload"),
156+
# and a prose "repo: billing" silently redirected the search to another
157+
# repo.
153158
token_re = re.compile(
154-
r"\b(?:(lang|language|file|path|under|kind|symbol|ext|not|case|repo))\s*:\s*([^\s]+)",
159+
r"\b(?:(lang|language|file|path|under|kind|symbol|ext|not|case|repo)):([^\s]+)",
155160
re.IGNORECASE,
156161
)
157162
for q in queries:

scripts/hybrid/ranking.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -872,10 +872,11 @@ def _flat_key(c):
872872
path = str(c.get("p") or "")
873873
start = int(c.get("start") or 0)
874874
score = float(m.get("raw_score") or m.get("score") or m.get("s") or 0.0)
875-
if score < 0:
876-
return (score, path, start)
877-
else:
878-
return (-score, path, start)
875+
# Highest score first for ALL scores. Negating only non-negatives
876+
# reversed the order among penalized (negative) spans and sorted
877+
# every negative ahead of weak positives — the token budget got
878+
# spent on the worst spans first.
879+
return (-score, path, start)
879880

880881
flattened.sort(key=_flat_key)
881882

scripts/ingest/chunking.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,38 @@ def chunk_semantic(
134134
break
135135

136136
if best_symbol:
137+
# Emit any gap between our current position and this symbol's start
138+
# (e.g. leading imports/module docstring, or top-level code between
139+
# two symbols) as its own chunk first, so it isn't silently dropped
140+
# from the index. Bounded by max_lines: best_symbol.start <= chunk_end.
141+
if best_symbol.start > chunk_start:
142+
gap_start = chunk_start
143+
gap_end = best_symbol.start - 1
144+
# Mirror the fallback branch below: don't let a chunk cross out
145+
# of an enclosing symbol we're still inside of. Split off any
146+
# remainder past it (unrelated top-level code) as its own,
147+
# unlabeled chunk instead.
148+
if enclosing_symbol is not None and gap_start <= enclosing_symbol.end:
149+
enclosed_end = min(gap_end, enclosing_symbol.end)
150+
chunks.append(
151+
{
152+
"text": "\n".join(lines[gap_start - 1 : enclosed_end]),
153+
"start": gap_start,
154+
"end": enclosed_end,
155+
"symbol": enclosing_symbol.name,
156+
"kind": enclosing_symbol.kind,
157+
}
158+
)
159+
gap_start = enclosed_end + 1
160+
if gap_start <= gap_end:
161+
chunks.append(
162+
{
163+
"text": "\n".join(lines[gap_start - 1 : gap_end]),
164+
"start": gap_start,
165+
"end": gap_end,
166+
}
167+
)
168+
137169
# Chunk this complete symbol
138170
chunk_text = "\n".join(lines[best_symbol.start - 1 : best_symbol.end])
139171
chunks.append(

scripts/ingest/pipeline.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,9 @@ def _index_single_file_inner(
413413
print(f"Skipping unchanged file: {file_path}")
414414
return False
415415

416-
if dedupe:
417-
delete_points_by_path(client, collection, str(file_path))
418-
416+
# NOTE: with dedupe, old points are deleted just before the upsert below,
417+
# AFTER embedding succeeded — deleting up front left the file entirely
418+
# unsearchable whenever the embedder failed mid-flight.
419419
symbols = _extract_symbols(language, text)
420420
imports, calls = _get_imports_calls(language, text)
421421
last_mod, churn_count, author_count = _git_metadata(file_path)
@@ -652,6 +652,8 @@ def make_point(pid, dense_vec, lex_vec, payload, lex_text: str = "", code_text:
652652
make_point(i, v, lx, m, lt, ct)
653653
for i, v, lx, m, lt, ct in zip(batch_ids, vectors, batch_lex, batch_meta, batch_lex_text, batch_code)
654654
]
655+
if dedupe:
656+
delete_points_by_path(client, collection, str(file_path))
655657
upsert_points(client, collection, points)
656658
try:
657659
ws = os.environ.get("WATCH_ROOT") or os.environ.get("WORKSPACE_PATH") or "/work"
@@ -661,6 +663,10 @@ def make_point(pid, dense_vec, lex_vec, payload, lex_text: str = "", code_text:
661663
except Exception:
662664
pass
663665
return True
666+
if dedupe:
667+
# The file yields no indexable chunks anymore (emptied or newly
668+
# excluded): still drop its stale points.
669+
delete_points_by_path(client, collection, str(file_path))
664670
return False
665671

666672

@@ -883,6 +889,57 @@ def process_file_with_smart_reindexing(
883889
*,
884890
allowed_vectors: set[str] | None = None,
885891
allowed_sparse: set[str] | None = None,
892+
) -> str:
893+
"""Cross-process file lock around smart reindexing.
894+
895+
Same guard as `index_single_file`: the smart scroll→delete→upsert
896+
sequence must not interleave with a concurrent full index of the same
897+
file — whichever writer finishes last would silently discard the
898+
other's points.
899+
"""
900+
_file_lock_ctx = None
901+
if file_indexing_lock is not None:
902+
try:
903+
_file_lock_ctx = file_indexing_lock(str(file_path))
904+
_file_lock_ctx.__enter__()
905+
except FileExistsError:
906+
print(f"[SMART_REINDEX] {file_path}: locked by another indexer, skipping")
907+
return "skipped"
908+
except Exception:
909+
pass
910+
try:
911+
return _process_file_with_smart_reindexing_inner(
912+
file_path,
913+
text,
914+
language,
915+
client,
916+
current_collection,
917+
per_file_repo,
918+
model,
919+
vector_name,
920+
allowed_vectors=allowed_vectors,
921+
allowed_sparse=allowed_sparse,
922+
)
923+
finally:
924+
if _file_lock_ctx is not None:
925+
try:
926+
_file_lock_ctx.__exit__(None, None, None)
927+
except Exception:
928+
pass
929+
930+
931+
def _process_file_with_smart_reindexing_inner(
932+
file_path,
933+
text: str,
934+
language: str,
935+
client: QdrantClient,
936+
current_collection: str,
937+
per_file_repo,
938+
model,
939+
vector_name: str | None,
940+
*,
941+
allowed_vectors: set[str] | None = None,
942+
allowed_sparse: set[str] | None = None,
886943
) -> str:
887944
"""Smart, chunk-level reindexing for a single file.
888945
@@ -1506,7 +1563,15 @@ def _maybe_ensure_collection() -> bool:
15061563
offset=next_offset,
15071564
)
15081565
except Exception:
1509-
if _maybe_ensure_collection():
1566+
# A single transient error must not abort the whole tick — the
1567+
# unprocessed tail would wait for a future tick. Retry briefly,
1568+
# then fall back to the ensure-collection path.
1569+
recovered = False
1570+
for _retry in (1, 2):
1571+
try:
1572+
time.sleep(0.4 * _retry)
1573+
except Exception:
1574+
pass
15101575
try:
15111576
points, next_offset = client.scroll(
15121577
collection_name=collection,
@@ -1516,10 +1581,25 @@ def _maybe_ensure_collection() -> bool:
15161581
with_vectors=True,
15171582
offset=next_offset,
15181583
)
1584+
recovered = True
1585+
break
15191586
except Exception:
1587+
continue
1588+
if not recovered:
1589+
if _maybe_ensure_collection():
1590+
try:
1591+
points, next_offset = client.scroll(
1592+
collection_name=collection,
1593+
scroll_filter=flt,
1594+
limit=batch_limit,
1595+
with_payload=True,
1596+
with_vectors=True,
1597+
offset=next_offset,
1598+
)
1599+
except Exception:
1600+
break
1601+
else:
15201602
break
1521-
else:
1522-
break
15231603

15241604
if not points:
15251605
break

scripts/mcp_impl/context_search.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,11 +748,25 @@ def _maybe_dict(val: Any) -> Dict[str, Any]:
748748
try:
749749
from scripts.hybrid_search import run_hybrid_search # type: ignore
750750

751+
# Scope the fallback exactly like the primary search. The primary
752+
# path is repo-scoped via _detect_current_repo(); running this
753+
# fallback unscoped silently returned OTHER repos' code whenever
754+
# the scoped search had zero hits (run_hybrid_search's own
755+
# auto-detect is env-only and misses the /work git checkout).
756+
repo_scope = None
757+
try:
758+
from scripts.mcp_impl.admin_tools import _detect_current_repo
759+
760+
repo_scope = _detect_current_repo()
761+
except Exception:
762+
repo_scope = None
763+
751764
model_name = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-base-en-v1.5")
752765
model = get_embedding_model_fn(model_name) if get_embedding_model_fn else None
753766
items2 = await asyncio.to_thread(
754767
lambda: run_hybrid_search(
755768
queries=queries,
769+
repo=repo_scope,
756770
limit=int(code_limit),
757771
per_path=int(per_path_val),
758772
language=language or None,

scripts/mcp_impl/search.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@
3939
_tokens_from_queries,
4040
safe_int,
4141
)
42-
from scripts.mcp_impl.workspace import _default_collection, _work_script
42+
from scripts.mcp_impl.workspace import (
43+
SESSION_DEFAULTS,
44+
SESSION_DEFAULTS_BY_SESSION,
45+
_SESSION_CTX_LOCK,
46+
_SESSION_LOCK,
47+
_default_collection,
48+
_work_script,
49+
)
50+
from scripts.mcp_impl.code_signals import _detect_code_signals
4351
from scripts.mcp_impl.admin_tools import _detect_current_repo, _run_async
4452
from scripts.mcp_toon import _should_use_toon, _format_results_as_toon
4553
from scripts.mcp_auth import require_collection_access as _require_collection_access
@@ -980,7 +988,20 @@ def _doc_for(obj: dict) -> str:
980988
_before_paths = [(o.get("path", "?").split("/")[-1], o.get("score", 0)) for o in cand_objs[:10]]
981989

982990
pairs = [(rq, d) for d in docs]
983-
scores = _rr_local(pairs)
991+
# Honor rerank_timeout_ms on the in-process path too: the
992+
# bare call blocked the server with no hybrid fallback
993+
# (the subprocess path already enforces the timeout).
994+
# shutdown(wait=False) so a timeout unblocks the request
995+
# instead of joining the stuck worker.
996+
import concurrent.futures as _futures
997+
998+
_rr_pool = _futures.ThreadPoolExecutor(max_workers=1)
999+
try:
1000+
scores = _rr_pool.submit(_rr_local, pairs).result(
1001+
timeout=max(0.05, float(rerank_timeout_ms) / 1000.0)
1002+
)
1003+
finally:
1004+
_rr_pool.shutdown(wait=False, cancel_futures=True)
9841005
# Blend rerank with fusion score to preserve pre-rerank boosts
9851006
# (symbol_exact, impl_boost, path boosts are otherwise lost)
9861007
_rerank_blend = float(os.environ.get("RERANK_BLEND_WEIGHT", "0.6") or 0.6)
@@ -1013,8 +1034,17 @@ def _doc_for(obj: dict) -> str:
10131034
blended_score += post_boost
10141035
blended.append((blended_score, rr_score, obj, post_boost))
10151036
ranked = sorted(blended, key=lambda x: x[0], reverse=True)
1037+
# rerank_return_m is a quality cap, not a result cap:
1038+
# honor a larger caller limit from the already-retrieved
1039+
# pool instead of silently returning fewer results.
1040+
_return_cap = int(rerank_return_m)
1041+
try:
1042+
if limit and int(limit) > _return_cap:
1043+
_return_cap = int(limit)
1044+
except Exception:
1045+
pass
10161046
tmp = []
1017-
for blended_s, rr_s, obj, post_b in ranked[: int(rerank_return_m)]:
1047+
for blended_s, rr_s, obj, post_b in ranked[:_return_cap]:
10181048
why_parts = obj.get("why", []) + [f"rerank_onnx:{float(rr_s):.3f}", f"blend:{float(blended_s):.3f}"]
10191049
if post_b > 0:
10201050
why_parts.append(f"post_sym:{float(post_b):.3f}")

0 commit comments

Comments
 (0)