Spaces:
Running
Running
File size: 14,749 Bytes
7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 954e0aa ba5fa56 7b90b65 ba5fa56 ac347e7 7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 ac347e7 7b90b65 ac347e7 7b90b65 6ee4b4a 7b90b65 6ee4b4a 7b90b65 ba5fa56 4bbd5b3 ba5fa56 4bbd5b3 ba5fa56 00c3bab 7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 ac347e7 7b90b65 ba5fa56 7b90b65 ba5fa56 7b90b65 6ee4b4a ba5fa56 7b90b65 ba5fa56 7b90b65 ba5fa56 6ee4b4a ba5fa56 7b90b65 ba5fa56 4bbd5b3 ba5fa56 4bbd5b3 ba5fa56 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 | """
Batch Workers β Async batching infrastructure for embedding and reranking inference.
Architecture:
- Single asyncio.Queue for embedding jobs and reranking jobs.
- Dedicated async worker coroutines drain queues in micro-batches.
- Workers offload heavy inference to threads via run_in_executor.
- A shared in-memory job_store dict tracks job status + results.
- Warmup loop periodically does a dummy forward pass for both embedder
and reranker to keep PyTorch/OpenMP threads alive during idle periods.
"""
import asyncio
import time
import uuid
from loguru import logger
from typing import Any, Optional
from .constants import (
BATCH_MAX_SIZE,
BATCH_WINDOW_S,
WARMUP_INTERVAL_S,
RERANK_BATCH_MAX_SIZE,
RERANK_BATCH_WINDOW_S,
)
from .schemas import EmbeddingJob, RerankJob
# βββββββββββββββββββββββ Job Store ββββββββββββββββββββββββ
job_store: dict[str, dict[str, Any]] = {}
"""
{
"<job_id>": {
"status": "pending" | "processing" | "done" | "error",
"result": <list[list[float]] for EmbeddingJob | list[dict] for RerankJob> | None,
"error": <str> | None,
}
}
"""
def create_job() -> str:
"""Create a new pending job and return its ID."""
job_id = str(uuid.uuid4())
job_store[job_id] = {"status": "pending", "result": None, "error": None}
return job_id
# βββββββββββββββββββββββ Request-in-Flight Gate ββββββββββββββββββββββββ
_request_in_flight_count = 0
def set_request_in_flight(active: bool):
"""Increment/decrement in-flight counter. Thread-safe enough for a gate."""
global _request_in_flight_count
if active:
_request_in_flight_count += 1
else:
_request_in_flight_count = max(0, _request_in_flight_count - 1)
def is_request_in_flight() -> bool:
return _request_in_flight_count > 0
# βββββββββββββββββββββββ Queues & Loop ββββββββββββββββββββββββ
embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue()
rerank_queue: asyncio.Queue[RerankJob] = asyncio.Queue()
_main_loop: Optional[asyncio.AbstractEventLoop] = None
# βββββββββββββββββββββββ Workers ββββββββββββββββββββββββ
async def embedding_worker():
"""
Drains up to {BATCH_MAX_SIZE} embedding jobs every {BATCH_WINDOW_S * 1000:.0f}ms.
One SentenceTransformer forward pass per batch:
1. Collect texts from all jobs in the batch
2. get_embedder().embed_documents(all_texts) β raw [B, D] embeddings
3. Distribute results back to individual jobs
Results are written into job_store and each job's done Event is set.
"""
from src.rag.rag import get_embedder
loop = asyncio.get_event_loop()
while True:
# Wait for at least one job
first_job: EmbeddingJob = await embedding_queue.get()
batch: list[EmbeddingJob] = [first_job]
# Collect up to 7 more within the time window
deadline = loop.time() + BATCH_WINDOW_S
while len(batch) < BATCH_MAX_SIZE:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
job = await asyncio.wait_for(embedding_queue.get(), timeout=remaining)
batch.append(job)
except asyncio.TimeoutError:
break
try:
set_request_in_flight(True)
# Gather all texts from all jobs in the batch
all_texts: list[str] = []
text_counts: list[int] = []
for job in batch:
all_texts.extend(job.texts)
text_counts.append(len(job.texts))
# Single forward pass for the entire batch
embedder = get_embedder()
all_embeddings = await loop.run_in_executor(
None, embedder.embed_documents, all_texts
)
# Distribute results back to individual jobs
idx = 0
for i, job in enumerate(batch):
n = text_counts[i]
job_result = all_embeddings[idx: idx + n]
idx += n
# Use .get() or setdefault to avoid KeyError if initialization was missed
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
job_store[job.job_id].update({
"status": "done",
"result": job_result
})
job.done.set()
except Exception as e:
logger.error(f"Embedding batch failed: {e}", exc_info=True)
for job in batch:
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "error", "result": None, "error": str(e)}
else:
job_store[job.job_id].update({
"status": "error",
"error": str(e)
})
# Critical: always set the event so the request doesn't hang
if not job.done.is_set():
job.done.set()
finally:
set_request_in_flight(False)
async def rerank_worker():
"""
Drains up to {RERANK_BATCH_MAX_SIZE} rerank jobs every {RERANK_BATCH_WINDOW_S * 1000:.0f}ms.
Batch reranking forward pass:
1. Collect (query, doc_text) pairs across all jobs in the micro-batch.
2. Call _score_pairs_batch(all_pairs) in thread pool.
3. Distribute scores back to jobs, add 'rerank_score', sort descending, slice top_k.
4. Results are written into job_store and each job's done Event is set.
"""
from src.rag.reranker import _score_pairs_batch
loop = asyncio.get_event_loop()
while True:
first_job: RerankJob = await rerank_queue.get()
batch: list[RerankJob] = [first_job]
deadline = loop.time() + RERANK_BATCH_WINDOW_S
while len(batch) < RERANK_BATCH_MAX_SIZE:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
job = await asyncio.wait_for(rerank_queue.get(), timeout=remaining)
batch.append(job)
except asyncio.TimeoutError:
break
try:
set_request_in_flight(True)
all_pairs: list[list[str]] = []
job_pair_counts: list[int] = []
for job in batch:
cnt = 0
for rec in job.records:
text = str(rec.get(job.content_key, ""))
all_pairs.append([job.query, text])
cnt += 1
job_pair_counts.append(cnt)
if all_pairs:
all_scores = await loop.run_in_executor(
None, _score_pairs_batch, all_pairs
)
else:
all_scores = []
idx = 0
for i, job in enumerate(batch):
n = job_pair_counts[i]
job_scores = all_scores[idx : idx + n]
idx += n
scored_records: list[tuple[float, dict[str, Any]]] = []
for s, rec in zip(job_scores, job.records):
r_copy = dict(rec)
r_copy["rerank_score"] = float(s)
scored_records.append((float(s), r_copy))
scored_records.sort(key=lambda x: x[0], reverse=True)
k_int = max(1, int(round(job.top_k)))
top_records = [r for _, r in scored_records[:k_int]]
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
job_store[job.job_id].update({
"status": "done",
"result": top_records,
})
job.done.set()
except Exception as e:
logger.error(f"Reranking batch failed: {e}", exc_info=True)
for job in batch:
fallback_records = []
k_int = max(1, int(round(job.top_k)))
for r in job.records[:k_int]:
r_copy = dict(r)
if "rerank_score" not in r_copy:
r_copy["rerank_score"] = float(r_copy.get("similarity", 0.0))
fallback_records.append(r_copy)
if job.job_id not in job_store:
job_store[job.job_id] = {"status": "error", "result": fallback_records, "error": str(e)}
else:
job_store[job.job_id].update({
"status": "error",
"result": fallback_records,
"error": str(e),
})
if not job.done.is_set():
job.done.set()
finally:
set_request_in_flight(False)
# βββββββββββββββββββββββ Warmup Loop ββββββββββββββββββββββββ
async def _warmup_loop():
"""
Periodically does a dummy forward pass for both embedder and reranker models
to prevent OpenMP/MKL thread pool spin-down during idle periods.
Skipped entirely if a real request is in flight.
"""
from src.rag.rag import warmup_embedder
from src.rag.reranker import warmup_reranker
loop = asyncio.get_event_loop()
while True:
await asyncio.sleep(WARMUP_INTERVAL_S)
if is_request_in_flight():
continue
t0 = time.monotonic()
try:
await loop.run_in_executor(None, warmup_embedder)
await loop.run_in_executor(None, warmup_reranker)
except Exception as e:
logger.warning(f"Warmup cycle error (non-fatal): {e}")
continue
elapsed_ms = (time.monotonic() - t0) * 1000
logger.info(f"Warmup cycle done (embedder + reranker) ({elapsed_ms:.0f}ms)")
# βββββββββββββββββββββββ Startup ββββββββββββββββββββββββ
_workers_started = False
def start_workers():
"""
Launch all async worker coroutines. Call once during app startup.
- 1 embedding worker (batched SentenceTransformer inference)
- 1 reranker worker (batched Jina cross-encoder inference)
- 1 warmup loop (keeps OpenMP threads alive for embedder and reranker)
"""
global _workers_started, _main_loop
if _workers_started:
return
_workers_started = True
try:
_main_loop = asyncio.get_running_loop()
except RuntimeError:
_main_loop = asyncio.get_event_loop()
# Launch worker tasks on the running event loop
asyncio.create_task(embedding_worker(), name="embedding_worker_0")
asyncio.create_task(rerank_worker(), name="rerank_worker_0")
asyncio.create_task(_warmup_loop(), name="warmup_loop")
logger.info("Batch workers started (embedding worker + rerank worker + warmup loop)")
# βββββββββββββββββββββββ Submission Helpers ββββββββββββββββββββββββ
async def rerank_async(
query: str,
records: list[dict[str, Any]],
top_k: int,
content_key: str = "content",
timeout: float = 30.0,
) -> list[dict[str, Any]]:
"""
Async interface to route reranking through the batch worker queue.
Falls back to synchronous rerank_dicts if workers are not started or on timeout/failure.
"""
from src.rag.reranker import rerank_dicts
if not records:
return []
top_k = max(1, int(round(top_k)))
if len(records) <= 1 or not _workers_started:
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
job_id = str(uuid.uuid4())
job = RerankJob(
job_id=job_id,
query=query,
records=records,
top_k=top_k,
content_key=content_key,
)
job_store[job_id] = {"status": "pending", "result": None, "error": None}
await rerank_queue.put(job)
try:
await asyncio.wait_for(job.done.wait(), timeout=timeout)
except asyncio.TimeoutError:
job_store.pop(job_id, None)
logger.warning("rerank_async timed out waiting for batch worker. Falling back to direct rerank_dicts.")
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
entry = job_store.pop(job_id, None)
if entry and entry.get("result") is not None:
return entry["result"]
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
def rerank_batch(
query: str,
records: list[dict[str, Any]],
top_k: int,
content_key: str = "content",
timeout: float = 30.0,
) -> list[dict[str, Any]]:
"""
Synchronous interface for batched reranking.
- If called from a worker thread while the main event loop is running, dispatches
to rerank_async via asyncio.run_coroutine_threadsafe.
- If called from the main thread event loop or if workers are not started,
safely calls rerank_dicts directly without blocking the event loop or hanging.
"""
from src.rag.reranker import rerank_dicts
if not records:
return []
top_k = max(1, int(round(top_k)))
if len(records) <= 1 or not _workers_started or _main_loop is None or not _main_loop.is_running():
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
try:
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is _main_loop:
# Running on main thread event loop: avoid blocking wait, use direct rerank_dicts
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
# Running in a worker thread: dispatch to main event loop queue
future = asyncio.run_coroutine_threadsafe(
rerank_async(query, records, top_k=top_k, content_key=content_key, timeout=timeout),
_main_loop,
)
return future.result(timeout=timeout)
except Exception as e:
logger.warning(f"rerank_batch dispatch failed ({e}); falling back to direct rerank_dicts.")
return rerank_dicts(query, records, top_k=top_k, content_key=content_key)
|