Franskaman110's picture
Cold start once the user answers the first question
9e71f85
Raw
History Blame Contribute Delete
7.78 kB
"""Retrieval-augmented generation: the one shared entry point.
Both the ``@app.api(name="chat")`` endpoint and the Gradio UI call ``answer()``
so there is a single grounding code path.
Per the hardware decision, query embedding **and** generation run inside one
``@spaces.GPU`` function (``_rag_generate``) β€” a single GPU allocation per turn,
not two separate bursts. FAISS search is CPU work but runs inside the same
allocation (it's cheap and avoids a second GPU round-trip). The corpus index is
prebuilt offline and only loaded here.
"""
from __future__ import annotations
import spaces
from .indexing.embedder import Embedder
from .indexing.store import VectorStore
from .llm.client import LLMClient, get_llm_client
from .llm.prompt import build_grounded_prompt
from .schemas import ChatMessage, ChatResponse, Chunk
_store: VectorStore | None = None
_llm: LLMClient | None = None
# Retrieval is run per knowledge source then fused, so each source contributes
# regardless of the other's similarity-score scale (directive = long English
# legal text; IDA = short salary summaries). Sources to retrieve from.
_SOURCES = ("directive", "lonstatistik")
K_PER_SOURCE = 5 # top-k pulled from each source before fusion
K_FINAL = 6 # chunks kept after fusion (passed to the LLM)
RRF_C = 60 # Reciprocal Rank Fusion constant (standard default)
def _rrf_fuse(
ranked_lists: list[list[tuple[Chunk, float]]], k_final: int, c: int = RRF_C
) -> list[Chunk]:
"""Fuse per-source ranked lists with Reciprocal Rank Fusion.
Rank-based and score-scale-agnostic: each chunk scores ``sum 1/(c + rank)``
across the lists it appears in. Our sources are disjoint, so this cleanly
interleaves the lists by rank. Returns the top ``k_final`` chunks.
"""
score: dict[str, float] = {}
keep: dict[str, Chunk] = {}
for lst in ranked_lists:
for rank, (chunk, _s) in enumerate(lst):
score[chunk.id] = score.get(chunk.id, 0.0) + 1.0 / (c + rank + 1)
keep[chunk.id] = chunk
ordered = sorted(keep, key=lambda cid: score[cid], reverse=True)
return [keep[cid] for cid in ordered[:k_final]]
def _get_store() -> VectorStore:
"""Load the persisted FAISS index once (lazy singleton).
The corpus is embedded **offline** (``scripts/build_index.py`` on ZeroGPU)
and persisted, so runtime never re-embeds it β€” only the query is embedded.
Raises if no index is present.
"""
global _store
if _store is None:
store = VectorStore()
if not store.load():
raise RuntimeError(
"No FAISS index found at data/processed/index/. Build it first "
"with `scripts/build_index.py` (on a GPU machine / the ZeroGPU "
"Space) and commit the result."
)
_store = store
return _store
def _citations(chunks: list[Chunk]) -> list[str]:
"""Dedup citation labels, preserving retrieval order."""
return list(dict.fromkeys(c.citation for c in chunks if c.citation))
def _get_llm() -> LLMClient:
global _llm
if _llm is None:
_llm = get_llm_client("default")
return _llm
@spaces.GPU(duration=300)
def _embed_corpus(texts: list[str]):
"""Embed the full corpus on GPU β€” used by ``build_index()``."""
return Embedder.get().embed_passages(texts)
def build_index() -> str:
"""Build the FAISS index in-process (admin trigger for ZeroGPU Spaces).
Reuses the same ingest β†’ embed β†’ persist pipeline as
``scripts/build_index.py``, but runs through the live Gradio process so
``@spaces.GPU`` can allocate a real GPU on ZeroGPU. Resets the cached
store so the next chat query picks up the new index.
"""
global _store
from .indexing.ingest import build_corpus, load_directive_chunks, load_ida_chunks
from .indexing.store import DEFAULT_INDEX_DIR
n_dir = len(load_directive_chunks())
n_ida = len(load_ida_chunks())
chunks = build_corpus()
vectors = _embed_corpus([c.text for c in chunks])
dim = vectors.shape[1] if vectors.size else 0
store = VectorStore()
store.add(chunks, vectors)
store.persist()
_store = None
msg = (
f"Index built: {n_dir} directive + {n_ida} IDA = {len(chunks)} chunks "
f"(dim={dim}) β†’ {DEFAULT_INDEX_DIR}"
)
import os
space_id = os.environ.get("SPACE_ID")
if space_id:
from huggingface_hub import HfApi
HfApi(token=os.environ.get("HF_TOKEN")).upload_folder(
folder_path=str(DEFAULT_INDEX_DIR),
path_in_repo="data/processed/index",
repo_id=space_id,
repo_type="space",
commit_message="Persist FAISS index (built via admin UI)",
)
msg += f"\nCommitted to {space_id} β€” index will survive restarts."
else:
msg += "\nNot on HF Spaces β€” commit data/processed/index/ manually."
return msg
@spaces.GPU(duration=120)
def _warmup_gpu() -> bool:
"""Load + exercise the embedder and LLM inside one GPU allocation.
Triggered early (when the user starts the onboarding wizard) so the weights
are downloaded and the models resident by the time the chat is reached β€” the
first real question then answers without the cold-start wait.
"""
Embedder.get().embed_query("warm up")
_get_store() # FAISS load is CPU/cheap, but prime it too
_get_llm().chat([ChatMessage(role="user", content="Hi")], max_new_tokens=1)
return True
def warmup() -> dict:
"""Pre-warm the chat models; never raises (fire-and-forget from the UI)."""
try:
_warmup_gpu()
return {"status": "ready"}
except Exception as exc: # noqa: BLE001 β€” warmup is best-effort
return {"status": "error", "detail": str(exc)}
@spaces.GPU(duration=180)
def _retrieve_and_stream(query: str, lang: str, k: int, context: str | None = None):
"""Embed query, retrieve per source + fuse, and stream generation.
One GPU allocation. Retrieves the top ``K_PER_SOURCE`` from each knowledge
source independently, fuses with Reciprocal Rank Fusion, and keeps the top
``k`` fused chunks β€” so both the Directive and IDA statistics can surface
even though their similarity scores live on different scales. ``context`` is
the user's compact dashboard summary, injected into the prompt so answers can
be specific to them. Yields ``(partial_reply, chunks)`` as tokens arrive so
the UI renders incrementally.
"""
qvec = Embedder.get().embed_query(query)
store = _get_store()
ranked = [store.search_by_source(qvec, s, K_PER_SOURCE) for s in _SOURCES]
chunks = _rrf_fuse(ranked, k_final=k)
prompt = build_grounded_prompt(query, chunks, lang, context=context)
acc = ""
for piece in _get_llm().stream(prompt):
acc += piece
yield acc, chunks
def answer_stream(
messages: list[ChatMessage], lang: str = "en", k: int = K_FINAL,
context: str | None = None,
):
"""Stream grounded answers as ``ChatResponse`` snapshots (growing reply)."""
query = next((m.content for m in reversed(messages) if m.role == "user"), "")
if not query.strip():
yield ChatResponse(reply="Please ask a question.", citations=[])
return
for acc, chunks in _retrieve_and_stream(query, lang, k, context):
yield ChatResponse(reply=acc, citations=_citations(chunks))
def answer(
messages: list[ChatMessage], lang: str = "en", k: int = K_FINAL,
context: str | None = None,
) -> ChatResponse:
"""Non-streaming answer (collapses the stream to its final snapshot)."""
resp = ChatResponse(reply="", citations=[])
for resp in answer_stream(messages, lang=lang, k=k, context=context):
pass
return resp