|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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 == "[":
|
|
|
| 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:
|
|
|
| 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 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
|
|
|
|
|
| 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] = []
|
|
|
|
|
| ext_str = detect_ext_token(image_paths)
|
| if ext_str:
|
| for tok in ext_str.split():
|
|
|
|
|
| hints.append(f"format:{tok.lower()}")
|
|
|
|
|
| meta = summarize_image_metadata(image_paths)
|
| if meta:
|
|
|
| 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.
|
| """
|
|
|
|
|
| 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}
|
|
|
|
|
| clean_q = strip_tags(query)
|
|
|
|
|
| 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)}"
|
| )
|
| )
|
|
|
|
|
| pool_k = max(50, top_k * 3)
|
| hits = self.index.search(final_q, k=pool_k, reranker=None)
|
|
|
|
|
| if excluded_norm:
|
| hits = [
|
| h
|
| for h in hits
|
| if _norm(getattr(h["doc"], "name", "")) not in excluded_norm
|
| ]
|
|
|
|
|
| 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}"
|
| )
|
|
|
|
|
|
|
| words = clean_q.split()
|
| if len(words) > 3:
|
| alt_task = " ".join(words[:3])
|
| log.info(f"Trying broader query: {alt_task}")
|
|
|
|
|
| if image_hints:
|
| alt_q = f"{alt_task} {image_hints}".strip()
|
| else:
|
| alt_q = alt_task
|
|
|
|
|
| alt_hits = self.index.search(alt_q, k=pool_k, reranker=None)
|
|
|
|
|
| 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
|
|
|
|
|
| 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 []
|
|
|
| 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.
|
| """
|
|
|
| pool_k = max(30, top_k * 3)
|
| hits = self.retrieve_no_rerank(
|
| query=query,
|
| image_paths=image_paths,
|
| top_k=pool_k,
|
| exclusions=exclusions,
|
| )
|
|
|
|
|
| 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
|
|
|