ai-agent / src /ai_agent /api /pipeline.py
github-actions[bot]
Deploy to Hugging Face Space
1fef60d
Raw
History Blame Contribute Delete
15.9 kB
# api/pipeline.py
from __future__ import annotations
import os
import re
import json
import logging
from pathlib import Path
from typing import List, Optional
from ai_agent.retriever.reranker import CrossEncoderReranker
from ai_agent.retriever.software_doc import SoftwareDoc
from ai_agent.retriever.text_embedder import LocalBGEEmbedder
from ai_agent.retriever.vector_index import IndexItem, VectorIndex
from ai_agent.utils.config import get_retrieval_config
from ai_agent.utils.tags import strip_tags
from ai_agent.utils.image_meta import detect_ext_token, summarize_image_metadata
from ai_agent.utils.utils import _env_flag
log = logging.getLogger("pipeline")
class RAGImagingPipeline:
def __init__(
self,
index_dir: Optional[str] = None,
min_results: int = 5,
max_retries: int = 2,
):
"""Initialize the RAG imaging pipeline."""
self.index_dir = Path(
index_dir or os.getenv("RAG_INDEX_DIR", "artifacts/rag_index")
)
self.index_dir.mkdir(parents=True, exist_ok=True)
self.min_results = min_results
self.max_retries = max_retries
retrieval_cfg = get_retrieval_config()
embed_cfg = retrieval_cfg.get("embedder", {}) if isinstance(retrieval_cfg, dict) else {}
rerank_cfg = retrieval_cfg.get("reranker", {}) if isinstance(retrieval_cfg, dict) else {}
self.embedder = LocalBGEEmbedder(
model_name=embed_cfg.get("model_name", "Qwen/Qwen3-Embedding-8B"),
device=embed_cfg.get("device"),
backend=embed_cfg.get("backend", "remote"),
base_url=embed_cfg.get("base_url", "https://inference-rcp.epfl.ch/v1"),
api_key_env=embed_cfg.get("api_key_env", "EPFL_API_KEY_EMBEDDER"),
timeout_s=float(embed_cfg.get("timeout_s", 20.0)),
)
self.reranker = CrossEncoderReranker(
model_name=rerank_cfg.get("model_name", "BAAI/bge-reranker-v2-m3"),
base_url=rerank_cfg.get("base_url", "https://inference-rcp.epfl.ch/v1"),
backend=rerank_cfg.get("backend", "remote"),
api_key_env=rerank_cfg.get("api_key_env", "EPFL_API_KEY_EMBEDDER"),
timeout_s=float(rerank_cfg.get("timeout_s", 20.0)),
device=rerank_cfg.get("device"),
)
self.index = self._load_or_build_index()
self._startup_embed_enabled = _env_flag("EMBED_CATALOG_ON_START", default=True)
self._startup_status_emitted = False
# Optional startup pre-embedding: embed catalog once so runtime queries
# only need query embedding and FAISS lookup.
if self._startup_embed_enabled:
self._ensure_catalog_embedded_once()
log.info("Startup catalog embedding complete")
else:
log.info("Startup catalog embedding disabled (EMBED_CATALOG_ON_START=0)")
log.info(
"Pipeline initialized with index at %s (docs=%d, startup_embed=%s)",
self.index_dir,
len(self.index.docs),
self._startup_embed_enabled,
)
self._emit_startup_status_once()
def _emit_startup_status_once(self) -> None:
"""Emit startup embedding status once, even if constructor logs were missed."""
if self._startup_status_emitted:
return
if self._startup_embed_enabled:
log.info(
"Startup status: embedding enabled (index docs=%d)",
len(self.index.docs),
)
else:
log.info("Startup status: embedding disabled by EMBED_CATALOG_ON_START")
self._startup_status_emitted = True
def _load_or_build_index(self) -> VectorIndex:
try:
return VectorIndex.load(self.index_dir, self.embedder)
except Exception:
log.exception(
"Failed to load FAISS index from %s; creating empty in-memory index",
self.index_dir,
)
return VectorIndex(self.embedder)
def reload_index(self) -> bool:
try:
new_idx = VectorIndex.load(self.index_dir, self.embedder)
self.index = new_idx
return True
except Exception:
logging.getLogger("api").exception("reload_index failed")
return False
def _read_catalog_docs(self, catalog_path: Path) -> List[SoftwareDoc]:
docs: List[SoftwareDoc] = []
if not catalog_path.exists():
return docs
# Peek at the first non-whitespace character to decide the format.
first_char = ""
try:
with catalog_path.open("r", encoding="utf-8") as fh:
for ch in iter(lambda: fh.read(1), ""):
if not ch.isspace():
first_char = ch
break
except Exception:
log.exception("Failed reading catalog from %s", catalog_path)
return docs
if first_char == "[":
# JSON array — must be loaded all at once.
try:
text = catalog_path.read_text(encoding="utf-8")
parsed = json.loads(text)
if isinstance(parsed, dict):
parsed = [parsed]
if isinstance(parsed, list):
for row in parsed:
try:
docs.append(SoftwareDoc.model_validate(row))
except Exception:
continue
except Exception:
log.exception("Failed reading JSON array catalog: %s", catalog_path)
else:
# JSONL (or single JSON object) — stream line by line.
try:
with catalog_path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
docs.append(SoftwareDoc.model_validate(json.loads(line)))
except Exception:
continue
except Exception:
log.exception("Failed reading JSONL catalog: %s", catalog_path)
return docs
def _ensure_catalog_embedded_once(self) -> None:
# If index already contains docs, startup embedding is done.
if len(self.index.docs) > 0:
log.info(
"Startup embedding skipped: FAISS already has %d docs",
len(self.index.docs),
)
return
catalog_path = Path(os.getenv("SOFTWARE_CATALOG", "dataset/catalog.jsonl"))
docs = self._read_catalog_docs(catalog_path)
if not docs:
log.warning(
"Startup embedding skipped: no docs loaded from %s", catalog_path
)
return
items = [IndexItem(id=d.name, doc=d) for d in docs if getattr(d, "name", None)]
if not items:
log.warning(
"Startup embedding skipped: catalog had no valid named docs in %s",
catalog_path,
)
return
delta = self.index.sync_with_catalog(items)
self.index.save(self.index_dir)
log.info(
"Startup catalog embedding complete: docs=%d (added=%d, updated=%d, removed=%d)",
len(items),
delta["added"],
delta["updated"],
delta["removed"],
)
def _apply_reranker(self, query: str, hits: List[dict], top_k: int) -> List[dict]:
if not hits:
return []
if self.reranker is None:
return hits[:top_k]
pool = hits[: min(len(hits), max(50, top_k * 3))]
texts = [h["doc"].to_retrieval_text() for h in pool]
try:
ranked = self.reranker.rerank(query, texts, top_k=len(texts))
except Exception:
for h in pool:
h["rerank_score"] = None
return pool[:top_k]
out: List[dict] = []
for i, s in ranked[:top_k]:
item = dict(pool[int(i)])
item["rerank_score"] = float(s)
out.append(item)
if len(out) < top_k:
used = {pool[int(i)]["id"] for i, _ in ranked[:top_k]}
for h in pool:
if h["id"] in used:
continue
h = dict(h)
h["rerank_score"] = h.get("rerank_score", None)
out.append(h)
if len(out) >= top_k:
break
return out
# ----------------------- Agent-facing lightweight APIs -------------------
def _build_image_hint_text(self, image_paths: Optional[List[str]]) -> str:
"""
Turn image paths into extra text hints for retrieval.
- Converts file extensions into format:xxx tokens (matching SoftwareDoc keywords)
- Adds a short metadata summary (modality, body region, dims...)
Result is a single string that we append to the text query before embedding.
"""
if not image_paths:
return ""
hints: List[str] = []
# 1) Format tokens (DICOM / NIfTI / TIFF / ...)
ext_str = detect_ext_token(image_paths)
if ext_str:
for tok in ext_str.split():
# match keywords like "format:tiff" that SoftwareDoc.to_retrieval_text()
# puts into the index.
hints.append(f"format:{tok.lower()}")
# 2) Human-readable metadata (includes modality/body/dims)
meta = summarize_image_metadata(image_paths)
if meta:
# collapse whitespace and keep it reasonably short
compact = " ".join(meta.split())
hints.append(compact[:300])
return " ".join(hints)
def retrieve_no_rerank(
self,
query: str,
image_paths: Optional[List[str]] = None,
top_k: int = 30,
exclusions: Optional[List[str]] = None,
) -> List[dict]:
"""
Return raw vector hits WITHOUT applying the CrossEncoder reranker.
Each item: {id, doc, score}. Optional `image_paths` are used to derive
additional text hints (format / modality / anatomy / dims) that are
appended to the query before embedding.
Relies on BGE-M3 semantic embeddings and approximate nearest-neighbor
vector search.
"""
# Fallback visibility: if constructor-time logs were not emitted by runtime
# logging configuration, emit startup status on first real retrieval.
self._emit_startup_status_once()
def _norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").strip().lower())
excluded_norm = {_norm(x) for x in (exclusions or []) if x}
# 1) Strip any tags from the query
clean_q = strip_tags(query)
# 2) Add image-derived hints (format, modality, anatomy, dims, ...)
image_hints = self._build_image_hint_text(image_paths)
if image_hints:
final_q = f"{clean_q} {image_hints}".strip()
else:
final_q = clean_q
log.info(
f"Retrieval query: {clean_q}"
+ (f" + metadata: {image_hints[:50]}..." if image_hints else "")
+ (
f" | startup_embed={self._startup_embed_enabled}"
f" docs={len(self.index.docs)}"
)
)
# 4) Vector search
pool_k = max(50, top_k * 3)
hits = self.index.search(final_q, k=pool_k, reranker=None)
# 5) Apply name-based exclusions if any
if excluded_norm:
hits = [
h
for h in hits
if _norm(getattr(h["doc"], "name", "")) not in excluded_norm
]
# 6) Check if results are sufficient, retry with broader terms if not
attempt = 0
while len(hits) < self.min_results and attempt < self.max_retries:
attempt += 1
log.info(
f"Insufficient results ({len(hits)} < {self.min_results}), attempting retry {attempt}/{self.max_retries}"
)
# Generate alternative by simplifying query (remove specific terms, keep general ones)
# Strategy: use first 2-3 words only to broaden the search
words = clean_q.split()
if len(words) > 3:
alt_task = " ".join(words[:3])
log.info(f"Trying broader query: {alt_task}")
# Build alternative query with image hints
if image_hints:
alt_q = f"{alt_task} {image_hints}".strip()
else:
alt_q = alt_task
# Search with alternative
alt_hits = self.index.search(alt_q, k=pool_k, reranker=None)
# Merge results (avoiding duplicates)
existing_ids = {h["id"] for h in hits}
for h in alt_hits:
if h["id"] not in existing_ids:
if (
not excluded_norm
or _norm(getattr(h["doc"], "name", "")) not in excluded_norm
):
hits.append(h)
existing_ids.add(h["id"])
log.info(f"After retry {attempt}: {len(hits)} total results")
else:
log.warning(
f"Query too short to generate alternative for retry {attempt}"
)
break
# 7) Attach convenience fields expected downstream
for h in hits:
h["__sim__"] = float(h.get("score", 0.0))
h["__rerank__"] = 0.0
return hits[:top_k]
def rerank_only(self, query: str, hits: List[dict], top_k: int = 10) -> List[dict]:
"""Apply CrossEncoder reranker to a pre-fetched hit list.
Returns new list of dicts (subset) with rerank_score set.
"""
if not hits:
return []
# Recreate query with any existing format tokens already embedded in retrieval
ranked = self._apply_reranker(strip_tags(query), hits, top_k=top_k)
return ranked
def retrieve(
self,
query: str,
image_paths: Optional[List[str]] = None,
top_k: int = 10,
exclusions: Optional[List[str]] = None,
) -> List[dict]:
"""
Retrieve and automatically rerank results using BGE-M3 + CrossEncoder.
This is the main retrieval method that combines:
1. Semantic search via BGE-M3 embeddings (no query expansion)
2. Precision reranking via CrossEncoder
3. Image metadata hints (format, modality, dimensions)
Returns top_k results after CrossEncoder reranking.
"""
# Get more candidates than needed for reranking
pool_k = max(30, top_k * 3)
hits = self.retrieve_no_rerank(
query=query,
image_paths=image_paths,
top_k=pool_k,
exclusions=exclusions,
)
# Apply reranking to get final top_k
if hits:
return self.rerank_only(query, hits, top_k=top_k)
return []
def get_doc(self, name: str) -> Optional[SoftwareDoc]:
"""Lookup a SoftwareDoc by name (case-sensitive match)."""
try:
return self.index.docs.get(name)
except Exception:
return None