Safe runtime fixes: ONNX session tuning + webapp per-token panel rebuild
Browse filesrerank.make_ort_session (used by both the reranker and, via import, the
embedder): disables ONNX Runtime's spin-waiting between requests -- by
default worker threads busy-spin after an inference for a microsecond
latency win, which burns CPU quota between queries on the Space's shared
2 vCPUs -- and adds an env-configurable intra-op thread cap
(CANLEX_ORT_THREADS; unset keeps ORT's default, so dev behaviour and
numerics are unchanged). 159-Q eval re-run as a gate: identical
(Hit@1 .81 / Hit@3 .94 / Hit@5 .97 / MRR .88).
webapp/app.py: the sources panel was re-rendered from the FULL tool log
on every streamed token -- O(tokens x log-size) string churn plus a
re-sent multi-KB Gradio payload per token late in a session. The
rendered markdown is now cached and refreshed only when a tool call
completes. Display-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- canlex/embed.py +2 -2
- canlex/rerank.py +21 -1
- webapp/app.py +13 -7
|
@@ -71,8 +71,8 @@ class Embedder:
|
|
| 71 |
if model_path is None:
|
| 72 |
raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
|
| 73 |
tok_path = hf_hub_download(EMB_REPO, "tokenizer.json")
|
| 74 |
-
|
| 75 |
-
|
| 76 |
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 77 |
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 78 |
self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
|
|
|
|
| 71 |
if model_path is None:
|
| 72 |
raise RuntimeError(f"Could not download an ONNX model from {EMB_REPO}.")
|
| 73 |
tok_path = hf_hub_download(EMB_REPO, "tokenizer.json")
|
| 74 |
+
from .rerank import make_ort_session
|
| 75 |
+
self.session = make_ort_session(model_path)
|
| 76 |
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 77 |
self.tokenizer = Tokenizer.from_file(tok_path)
|
| 78 |
self.tokenizer.enable_truncation(max_length=_MAX_TOKENS)
|
|
@@ -20,6 +20,26 @@ _MAX_DOC_CHARS = int(os.environ.get("CANLEX_RERANK_DOC_CHARS", "1000"))
|
|
| 20 |
# 3000 -> 1000 (with pool 50 -> 16) held the eval exactly.
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
class Reranker:
|
| 24 |
"""Cross-encoder that scores (query, section) pairs for relevance.
|
| 25 |
|
|
@@ -36,7 +56,7 @@ class Reranker:
|
|
| 36 |
with open(cfg_path, encoding="utf-8") as fh:
|
| 37 |
self.pad_id = json.load(fh).get("pad_token_id", 0)
|
| 38 |
|
| 39 |
-
self.session =
|
| 40 |
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 41 |
|
| 42 |
self.tokenizer = Tokenizer.from_file(tok_path)
|
|
|
|
| 20 |
# 3000 -> 1000 (with pool 50 -> 16) held the eval exactly.
|
| 21 |
|
| 22 |
|
| 23 |
+
def make_ort_session(model_path):
|
| 24 |
+
"""A CPU InferenceSession tuned for shared, small-vCPU hosts.
|
| 25 |
+
|
| 26 |
+
Two deviations from onnxruntime defaults: (1) spin-waiting between requests
|
| 27 |
+
is disabled -- by default ORT worker threads busy-spin after an inference
|
| 28 |
+
for a microsecond-scale latency win, which on the Space's shared 2 vCPUs
|
| 29 |
+
burns quota between queries; (2) the intra-op thread count is
|
| 30 |
+
env-configurable (CANLEX_ORT_THREADS) so concurrent sessions can be capped
|
| 31 |
+
below core count if contention is ever observed. Unset, ORT's default
|
| 32 |
+
(all cores) is kept, so single-query numerics and speed are unchanged."""
|
| 33 |
+
opts = ort.SessionOptions()
|
| 34 |
+
threads = int(os.environ.get("CANLEX_ORT_THREADS", "0"))
|
| 35 |
+
if threads:
|
| 36 |
+
opts.intra_op_num_threads = threads
|
| 37 |
+
opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
|
| 38 |
+
opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
|
| 39 |
+
return ort.InferenceSession(model_path, sess_options=opts,
|
| 40 |
+
providers=["CPUExecutionProvider"])
|
| 41 |
+
|
| 42 |
+
|
| 43 |
class Reranker:
|
| 44 |
"""Cross-encoder that scores (query, section) pairs for relevance.
|
| 45 |
|
|
|
|
| 56 |
with open(cfg_path, encoding="utf-8") as fh:
|
| 57 |
self.pad_id = json.load(fh).get("pad_token_id", 0)
|
| 58 |
|
| 59 |
+
self.session = make_ort_session(model_path)
|
| 60 |
self.input_names = {i.name for i in self.session.get_inputs()}
|
| 61 |
|
| 62 |
self.tokenizer = Tokenizer.from_file(tok_path)
|
|
@@ -427,6 +427,11 @@ async def _agentic_answer(question: str):
|
|
| 427 |
trace: list[str] = []
|
| 428 |
|
| 429 |
answer_buf = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
def status_md(thinking: bool = True) -> str:
|
| 432 |
lines = [f"- {line}" for line in trace]
|
|
@@ -435,7 +440,7 @@ async def _agentic_answer(question: str):
|
|
| 435 |
return "\n".join(lines) if lines else ""
|
| 436 |
|
| 437 |
for step in range(MAX_TOOL_ITERATIONS):
|
| 438 |
-
yield status_md(), answer_buf,
|
| 439 |
|
| 440 |
# Stream Gemini's next turn. Stream text deltas to the answer
|
| 441 |
# panel optimistically; revert to the pre-turn answer if it
|
|
@@ -451,7 +456,7 @@ async def _agentic_answer(question: str):
|
|
| 451 |
turn_text += chunk["text"]
|
| 452 |
yield (status_md(),
|
| 453 |
answer_buf + turn_text,
|
| 454 |
-
|
| 455 |
elif chunk["type"] == "function_call":
|
| 456 |
turn_calls.append(chunk["call"])
|
| 457 |
if optimistic and turn_text:
|
|
@@ -460,7 +465,7 @@ async def _agentic_answer(question: str):
|
|
| 460 |
optimistic = False
|
| 461 |
yield (status_md(),
|
| 462 |
answer_buf,
|
| 463 |
-
|
| 464 |
elif chunk["type"] == "finish":
|
| 465 |
turn_parts = chunk["parts"] or []
|
| 466 |
# Capture any text-only finish reason so the caller can
|
|
@@ -479,7 +484,7 @@ async def _agentic_answer(question: str):
|
|
| 479 |
answer_buf += turn_text
|
| 480 |
yield (status_md(thinking=False),
|
| 481 |
_match_badge(tool_log) + answer_buf,
|
| 482 |
-
|
| 483 |
return
|
| 484 |
|
| 485 |
# Tool turn. If the model emitted a commentary fragment before
|
|
@@ -499,10 +504,11 @@ async def _agentic_answer(question: str):
|
|
| 499 |
args = call.get("args") or {}
|
| 500 |
label = _summarize_call(name, args)
|
| 501 |
trace.append(label)
|
| 502 |
-
yield status_md(), answer_buf,
|
| 503 |
|
| 504 |
output = await _run_tool(session, name, args)
|
| 505 |
tool_log.append((name, args, output))
|
|
|
|
| 506 |
function_responses.append({
|
| 507 |
"functionResponse": {
|
| 508 |
"name": name,
|
|
@@ -525,12 +531,12 @@ async def _agentic_answer(question: str):
|
|
| 525 |
turn_text += chunk["text"]
|
| 526 |
yield (status_md(thinking=False),
|
| 527 |
answer_buf + turn_text,
|
| 528 |
-
|
| 529 |
answer_buf += turn_text or \
|
| 530 |
"_(no answer produced after the tool-call budget was exhausted)_"
|
| 531 |
yield (status_md(thinking=False),
|
| 532 |
_match_badge(tool_log) + answer_buf,
|
| 533 |
-
|
| 534 |
|
| 535 |
|
| 536 |
# --- Gradio handler -----------------------------------------------------------
|
|
|
|
| 427 |
trace: list[str] = []
|
| 428 |
|
| 429 |
answer_buf = ""
|
| 430 |
+
# The rendered sources panel is cached and refreshed only when a
|
| 431 |
+
# tool call completes: rebuilding it from the full tool_log on
|
| 432 |
+
# every streamed token was O(tokens x log-size) string churn (tens
|
| 433 |
+
# of KB re-rendered and re-sent per token late in a session).
|
| 434 |
+
sources_md = ""
|
| 435 |
|
| 436 |
def status_md(thinking: bool = True) -> str:
|
| 437 |
lines = [f"- {line}" for line in trace]
|
|
|
|
| 440 |
return "\n".join(lines) if lines else ""
|
| 441 |
|
| 442 |
for step in range(MAX_TOOL_ITERATIONS):
|
| 443 |
+
yield status_md(), answer_buf, sources_md
|
| 444 |
|
| 445 |
# Stream Gemini's next turn. Stream text deltas to the answer
|
| 446 |
# panel optimistically; revert to the pre-turn answer if it
|
|
|
|
| 456 |
turn_text += chunk["text"]
|
| 457 |
yield (status_md(),
|
| 458 |
answer_buf + turn_text,
|
| 459 |
+
sources_md)
|
| 460 |
elif chunk["type"] == "function_call":
|
| 461 |
turn_calls.append(chunk["call"])
|
| 462 |
if optimistic and turn_text:
|
|
|
|
| 465 |
optimistic = False
|
| 466 |
yield (status_md(),
|
| 467 |
answer_buf,
|
| 468 |
+
sources_md)
|
| 469 |
elif chunk["type"] == "finish":
|
| 470 |
turn_parts = chunk["parts"] or []
|
| 471 |
# Capture any text-only finish reason so the caller can
|
|
|
|
| 484 |
answer_buf += turn_text
|
| 485 |
yield (status_md(thinking=False),
|
| 486 |
_match_badge(tool_log) + answer_buf,
|
| 487 |
+
sources_md)
|
| 488 |
return
|
| 489 |
|
| 490 |
# Tool turn. If the model emitted a commentary fragment before
|
|
|
|
| 504 |
args = call.get("args") or {}
|
| 505 |
label = _summarize_call(name, args)
|
| 506 |
trace.append(label)
|
| 507 |
+
yield status_md(), answer_buf, sources_md
|
| 508 |
|
| 509 |
output = await _run_tool(session, name, args)
|
| 510 |
tool_log.append((name, args, output))
|
| 511 |
+
sources_md = _format_sources(tool_log)
|
| 512 |
function_responses.append({
|
| 513 |
"functionResponse": {
|
| 514 |
"name": name,
|
|
|
|
| 531 |
turn_text += chunk["text"]
|
| 532 |
yield (status_md(thinking=False),
|
| 533 |
answer_buf + turn_text,
|
| 534 |
+
sources_md)
|
| 535 |
answer_buf += turn_text or \
|
| 536 |
"_(no answer produced after the tool-call budget was exhausted)_"
|
| 537 |
yield (status_md(thinking=False),
|
| 538 |
_match_badge(tool_log) + answer_buf,
|
| 539 |
+
sources_md)
|
| 540 |
|
| 541 |
|
| 542 |
# --- Gradio handler -----------------------------------------------------------
|