Spaces:
Sleeping
Sleeping
chatrag
#1
by dreamthehacker - opened
- .env.example +2 -6
- README.md +1 -1
- app/__pycache__/main.cpython-312.pyc +0 -0
- app/main.py +262 -520
- requirements.txt +0 -1
.env.example
CHANGED
|
@@ -1,16 +1,12 @@
|
|
| 1 |
KB_BACKEND=qdrant
|
| 2 |
-
RAG_RETRIEVAL_MODE=
|
| 3 |
-
RAG_LLM_RERANK=true
|
| 4 |
-
RAG_RERANK_CANDIDATES=8
|
| 5 |
-
RAG_RERANK_MAX_TOKENS=280
|
| 6 |
QDRANT_URL=
|
| 7 |
QDRANT_API_KEY=
|
| 8 |
QDRANT_COLLECTION=doc_kb
|
| 9 |
|
| 10 |
HUGGINGFACE_API_TOKEN=
|
| 11 |
GROQ_API_KEY=
|
| 12 |
-
RAG_MODEL_ID=
|
| 13 |
-
RAG_MODEL_CANDIDATES=llama-3.1-8b-instant,llama-3.3-70b-versatile,openai/gpt-oss-20b
|
| 14 |
RAG_TEMPERATURE=0.2
|
| 15 |
RAG_MAX_TOKENS=512
|
| 16 |
|
|
|
|
| 1 |
KB_BACKEND=qdrant
|
| 2 |
+
RAG_RETRIEVAL_MODE=lexical
|
|
|
|
|
|
|
|
|
|
| 3 |
QDRANT_URL=
|
| 4 |
QDRANT_API_KEY=
|
| 5 |
QDRANT_COLLECTION=doc_kb
|
| 6 |
|
| 7 |
HUGGINGFACE_API_TOKEN=
|
| 8 |
GROQ_API_KEY=
|
| 9 |
+
RAG_MODEL_ID=openai/gpt-oss-20b
|
|
|
|
| 10 |
RAG_TEMPERATURE=0.2
|
| 11 |
RAG_MAX_TOKENS=512
|
| 12 |
|
README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
license: mit
|
| 3 |
title: ChatQnA RAG Service
|
| 4 |
sdk: docker
|
| 5 |
-
emoji:
|
| 6 |
colorFrom: red
|
| 7 |
colorTo: yellow
|
| 8 |
---
|
|
|
|
| 2 |
license: mit
|
| 3 |
title: ChatQnA RAG Service
|
| 4 |
sdk: docker
|
| 5 |
+
emoji: 🚀
|
| 6 |
colorFrom: red
|
| 7 |
colorTo: yellow
|
| 8 |
---
|
app/__pycache__/main.cpython-312.pyc
CHANGED
|
Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ
|
|
|
app/main.py
CHANGED
|
@@ -1,34 +1,33 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
import math
|
| 5 |
import os
|
| 6 |
import re
|
| 7 |
import traceback
|
| 8 |
-
from collections import Counter
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
from typing import Any, Optional
|
| 11 |
-
|
| 12 |
-
from fastapi import FastAPI
|
| 13 |
-
from openai import OpenAI
|
| 14 |
-
from pydantic import BaseModel, Field
|
| 15 |
-
|
| 16 |
-
from langchain_community.vectorstores import FAISS
|
| 17 |
-
from langchain_qdrant import QdrantVectorStore
|
| 18 |
-
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 19 |
-
from qdrant_client import QdrantClient
|
| 20 |
-
from qdrant_client.http import models as qmodels
|
| 21 |
-
from qdrant_client.http.models import Distance, VectorParams
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
EMBED_DIM = 384
|
| 25 |
-
FAISS_DIR = Path(os.getenv("FAISS_DIR", "faiss_store"))
|
| 26 |
-
PAGE_RE = re.compile(r"\[PAGE\s+(\d+)\]", re.IGNORECASE)
|
| 27 |
FIGURE_REF_RE = re.compile(r"\b(fig(?:ure)?\.?)\s*(\d+)\b", re.IGNORECASE)
|
| 28 |
TABLE_REF_RE = re.compile(r"\b(tab(?:le)?\.?)\s*(\d+)\b", re.IGNORECASE)
|
| 29 |
CITATION_REF_RE = re.compile(r"\[(S\d+)\]")
|
| 30 |
|
| 31 |
-
VISUAL_TYPES = {
|
| 32 |
"figure_explain",
|
| 33 |
"figure_ocr",
|
| 34 |
"figure_caption",
|
|
@@ -39,84 +38,73 @@ VISUAL_TYPES = {
|
|
| 39 |
"table_pdfplumber",
|
| 40 |
"table_unstructured",
|
| 41 |
"tesseract_ocr",
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
_VECTORSTORE: Optional[Any] = None
|
| 45 |
-
_EMBEDDINGS: Optional[Any] = None
|
| 46 |
-
|
| 47 |
-
STOPWORDS = {
|
| 48 |
-
"the",
|
| 49 |
-
"and",
|
| 50 |
-
"for",
|
| 51 |
-
"with",
|
| 52 |
-
"that",
|
| 53 |
-
"this",
|
| 54 |
-
"from",
|
| 55 |
-
"into",
|
| 56 |
-
"your",
|
| 57 |
-
"about",
|
| 58 |
-
"what",
|
| 59 |
-
"which",
|
| 60 |
-
"where",
|
| 61 |
-
"when",
|
| 62 |
-
"how",
|
| 63 |
-
"why",
|
| 64 |
-
"does",
|
| 65 |
-
"did",
|
| 66 |
-
"are",
|
| 67 |
-
"is",
|
| 68 |
-
"was",
|
| 69 |
-
"were",
|
| 70 |
-
"be",
|
| 71 |
-
"been",
|
| 72 |
-
"being",
|
| 73 |
-
"have",
|
| 74 |
-
"has",
|
| 75 |
-
"had",
|
| 76 |
-
"can",
|
| 77 |
-
"could",
|
| 78 |
-
"should",
|
| 79 |
-
"would",
|
| 80 |
-
"may",
|
| 81 |
-
"might",
|
| 82 |
-
"will",
|
| 83 |
-
"shall",
|
| 84 |
-
"a",
|
| 85 |
-
"an",
|
| 86 |
-
"of",
|
| 87 |
-
"to",
|
| 88 |
-
"in",
|
| 89 |
-
"on",
|
| 90 |
-
"at",
|
| 91 |
-
"by",
|
| 92 |
-
"as",
|
| 93 |
-
"it",
|
| 94 |
-
"its",
|
| 95 |
-
"or",
|
| 96 |
-
"if",
|
| 97 |
-
"but",
|
| 98 |
-
"not",
|
| 99 |
-
"we",
|
| 100 |
-
"our",
|
| 101 |
-
"you",
|
| 102 |
-
"they",
|
| 103 |
-
"their",
|
| 104 |
-
"them",
|
| 105 |
}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
flags=re.IGNORECASE,
|
| 110 |
-
)
|
| 111 |
-
DEFAULT_MODEL_ID = "llama-3.1-8b-instant"
|
| 112 |
-
DEFAULT_MODEL_CANDIDATES = [
|
| 113 |
-
"llama-3.1-8b-instant",
|
| 114 |
-
"llama-3.3-70b-versatile",
|
| 115 |
-
"openai/gpt-oss-20b",
|
| 116 |
-
]
|
| 117 |
-
|
| 118 |
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
role: str
|
| 121 |
content: str
|
| 122 |
|
|
@@ -166,73 +154,38 @@ class QueryResponse(BaseModel):
|
|
| 166 |
app = FastAPI(title="chatqna-rag-service", version="0.2.0")
|
| 167 |
|
| 168 |
|
| 169 |
-
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 170 |
-
return os.getenv(name, default)
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def get_bool_secret(name: str, default: bool = False) -> bool:
|
| 174 |
-
raw = (get_secret(name, str(default)) or "").strip().lower()
|
| 175 |
-
return raw in {"1", "true", "yes", "on"}
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def normalize_text(text: str) -> str:
|
| 179 |
-
return (text or "").strip()
|
| 180 |
-
|
| 181 |
|
| 182 |
-
def get_embeddings() -> Any:
|
| 183 |
-
"""
|
| 184 |
-
Lazy-load sentence-transformers stack to keep startup memory low.
|
| 185 |
-
"""
|
| 186 |
-
global _EMBEDDINGS
|
| 187 |
-
if _EMBEDDINGS is None:
|
| 188 |
-
from langchain_huggingface import HuggingFaceEmbeddings
|
| 189 |
|
| 190 |
-
|
| 191 |
-
return
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def get_retrieval_mode() -> str:
|
| 195 |
-
mode = (get_secret("RAG_RETRIEVAL_MODE", "semantic") or "semantic").strip().lower()
|
| 196 |
-
return mode if mode in {"lexical", "semantic"} else "semantic"
|
| 197 |
|
| 198 |
|
| 199 |
-
def
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
return max(int(top_k), cfg)
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def hf_routed_model(model_id: str) -> str:
|
| 210 |
-
return model_id if ":" in model_id else f"{model_id}:groq"
|
| 211 |
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
def get_model_candidates(model_id: str) -> list[str]:
|
| 214 |
-
preferred = normalize_text(model_id) or DEFAULT_MODEL_ID
|
| 215 |
-
raw = (get_secret("RAG_MODEL_CANDIDATES", "") or "").strip()
|
| 216 |
-
if raw:
|
| 217 |
-
candidates = [normalize_text(part) for part in raw.split(",")]
|
| 218 |
-
candidates = [c for c in candidates if c]
|
| 219 |
-
else:
|
| 220 |
-
candidates = [preferred] + [m for m in DEFAULT_MODEL_CANDIDATES if m != preferred]
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
key = candidate.lower()
|
| 226 |
-
if key in seen:
|
| 227 |
-
continue
|
| 228 |
-
seen.add(key)
|
| 229 |
-
deduped.append(candidate)
|
| 230 |
-
return deduped or [DEFAULT_MODEL_ID]
|
| 231 |
|
| 232 |
|
| 233 |
-
def
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
messages: list[dict[str, Any]],
|
| 237 |
temperature: float,
|
| 238 |
max_tokens: int,
|
|
@@ -248,74 +201,51 @@ def openai_chat(
|
|
| 248 |
return content or "", resp
|
| 249 |
|
| 250 |
|
| 251 |
-
def llm_chat_with_fallback(
|
| 252 |
-
model_id: str,
|
| 253 |
messages: list[dict[str, Any]],
|
| 254 |
temperature: float,
|
| 255 |
max_tokens: int,
|
| 256 |
hf_token: Optional[str],
|
| 257 |
groq_key: Optional[str],
|
| 258 |
-
) -> dict[str, Any]:
|
| 259 |
-
result: dict[str, Any] = {
|
| 260 |
-
"content": "",
|
| 261 |
-
"primary_used": False,
|
| 262 |
-
"raw": None,
|
| 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 |
-
if not groq_key:
|
| 297 |
-
result["error_fallback"] = "Missing GROQ_API_KEY (fallback not available)"
|
| 298 |
-
return result
|
| 299 |
-
|
| 300 |
-
fallback_errors: list[str] = []
|
| 301 |
-
try:
|
| 302 |
-
groq_client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
|
| 303 |
-
for candidate in candidate_models:
|
| 304 |
-
try:
|
| 305 |
-
content, raw = openai_chat(groq_client, candidate, messages, temperature, max_tokens)
|
| 306 |
-
if normalize_text(content):
|
| 307 |
-
result.update({"content": content, "raw": raw, "used_model": candidate})
|
| 308 |
-
return result
|
| 309 |
-
fallback_errors.append(f"{candidate}: empty content")
|
| 310 |
-
except Exception as exc: # pragma: no cover
|
| 311 |
-
fallback_errors.append(f"{candidate}: {type(exc).__name__}: {exc}")
|
| 312 |
-
except Exception as exc: # pragma: no cover
|
| 313 |
-
fallback_errors.append(f"groq_client_init: {type(exc).__name__}: {exc}")
|
| 314 |
-
result["error_fallback"] = " | ".join(fallback_errors[:4]) or "Fallback returned empty content."
|
| 315 |
-
return result
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
def load_faiss(embeddings: Any, path: Path = FAISS_DIR) -> Optional[FAISS]:
|
| 319 |
if (path / "index.faiss").exists() and (path / "index.pkl").exists():
|
| 320 |
return FAISS.load_local(str(path), embeddings, allow_dangerous_deserialization=True)
|
| 321 |
return None
|
|
@@ -337,8 +267,8 @@ def init_qdrant_vectorstore() -> tuple[Optional[QdrantVectorStore], Optional[str
|
|
| 337 |
collection_name=collection_name,
|
| 338 |
vectors_config=VectorParams(size=EMBED_DIM, distance=Distance.COSINE),
|
| 339 |
)
|
| 340 |
-
store = QdrantVectorStore(client=client, collection_name=collection_name, embedding=get_embeddings())
|
| 341 |
-
return store, None
|
| 342 |
except Exception as exc:
|
| 343 |
return None, f"{type(exc).__name__}: {exc}"
|
| 344 |
|
|
@@ -355,8 +285,8 @@ def ensure_vectorstore() -> Optional[Any]:
|
|
| 355 |
_VECTORSTORE = store
|
| 356 |
return _VECTORSTORE
|
| 357 |
|
| 358 |
-
_VECTORSTORE = load_faiss(get_embeddings())
|
| 359 |
-
return _VECTORSTORE
|
| 360 |
|
| 361 |
|
| 362 |
def qdrant_filter_for_chunk_types(types: list[str]) -> qmodels.Filter:
|
|
@@ -447,31 +377,31 @@ def dot(a: list[float], b: list[float]) -> float:
|
|
| 447 |
return sum(x * y for x, y in zip(a, b))
|
| 448 |
|
| 449 |
|
| 450 |
-
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
| 451 |
na = math.sqrt(dot(a, a))
|
| 452 |
nb = math.sqrt(dot(b, b))
|
| 453 |
if na == 0.0 or nb == 0.0:
|
| 454 |
return 0.0
|
| 455 |
-
return dot(a, b) / (na * nb)
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
def tokenize_retrieval(text: str) -> list[str]:
|
| 459 |
-
toks = re.findall(r"[A-Za-z0-9][A-Za-z0-9\-_]{1,}", text or "")
|
| 460 |
-
return [t.lower() for t in toks if t.lower() not in STOPWORDS]
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
def lexical_overlap_score(query: str, text: str) -> float:
|
| 464 |
-
q_tokens = tokenize_retrieval(query)
|
| 465 |
-
t_tokens = tokenize_retrieval(text)
|
| 466 |
-
if not q_tokens or not t_tokens:
|
| 467 |
-
return 0.0
|
| 468 |
-
q_count = Counter(q_tokens)
|
| 469 |
-
t_count = Counter(t_tokens)
|
| 470 |
-
overlap = sum(min(cnt, t_count.get(tok, 0)) for tok, cnt in q_count.items())
|
| 471 |
-
if overlap <= 0:
|
| 472 |
-
return 0.0
|
| 473 |
-
# Lightweight score favoring chunks with better query coverage.
|
| 474 |
-
return overlap / float(len(q_tokens) + 0.35 * len(t_tokens))
|
| 475 |
|
| 476 |
|
| 477 |
def split_text_with_offsets(text: str, chunk_size: int, chunk_overlap: int) -> list[dict[str, Any]]:
|
|
@@ -502,7 +432,7 @@ def split_text_with_offsets(text: str, chunk_size: int, chunk_overlap: int) -> l
|
|
| 502 |
return chunks
|
| 503 |
|
| 504 |
|
| 505 |
-
def retrieve_from_uploaded_document(payload: QueryRequest) -> list[dict[str, Any]]:
|
| 506 |
document = payload.document
|
| 507 |
document_text = (document.documentText if document else "") or ""
|
| 508 |
if not document_text.strip():
|
|
@@ -514,22 +444,21 @@ def retrieve_from_uploaded_document(payload: QueryRequest) -> list[dict[str, Any
|
|
| 514 |
if not chunks:
|
| 515 |
return []
|
| 516 |
|
| 517 |
-
ranked: list[dict[str, Any]] = []
|
| 518 |
-
if get_retrieval_mode() == "semantic":
|
| 519 |
-
embeddings = get_embeddings()
|
| 520 |
-
query_vec = embeddings.embed_query(payload.message)
|
| 521 |
-
doc_vecs = embeddings.embed_documents([c["text"] for c in chunks])
|
| 522 |
-
for chunk, vec in zip(chunks, doc_vecs):
|
| 523 |
-
score = cosine_similarity(query_vec, vec)
|
| 524 |
-
ranked.append({**chunk, "score": float(score)})
|
| 525 |
-
else:
|
| 526 |
-
for chunk in chunks:
|
| 527 |
-
score = lexical_overlap_score(payload.message, str(chunk.get("text") or ""))
|
| 528 |
-
ranked.append({**chunk, "score": float(score)})
|
| 529 |
|
| 530 |
-
ranked.sort(key=lambda c: c["score"], reverse=True)
|
| 531 |
-
|
| 532 |
-
top_k = max(1, min(candidate_k, len(ranked)))
|
| 533 |
out: list[dict[str, Any]] = []
|
| 534 |
for idx, item in enumerate(ranked[:top_k], start=1):
|
| 535 |
out.append(
|
|
@@ -559,14 +488,13 @@ def locate_offsets_in_document(text: str, snippet: str) -> tuple[Optional[int],
|
|
| 559 |
return idx, min(len(source), idx + len(needle))
|
| 560 |
|
| 561 |
|
| 562 |
-
def retrieve_from_vectorstore(payload: QueryRequest) -> list[dict[str, Any]]:
|
| 563 |
vectorstore = ensure_vectorstore()
|
| 564 |
if vectorstore is None:
|
| 565 |
return []
|
| 566 |
|
| 567 |
-
prefer_visual = is_visual_question(payload.message)
|
| 568 |
-
|
| 569 |
-
k = max(int(requested_k), 10) if prefer_visual else int(requested_k)
|
| 570 |
docs = retrieve_docs(vectorstore, payload.message, k=k, prefer_visual=prefer_visual)
|
| 571 |
if not docs:
|
| 572 |
return []
|
|
@@ -602,128 +530,10 @@ def retrieve_from_vectorstore(payload: QueryRequest) -> list[dict[str, Any]]:
|
|
| 602 |
"score": None,
|
| 603 |
}
|
| 604 |
)
|
| 605 |
-
return out
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
def extract_json_object(text: str) -> Optional[dict[str, Any]]:
|
| 609 |
-
raw = (text or "").strip()
|
| 610 |
-
if not raw:
|
| 611 |
-
return None
|
| 612 |
-
|
| 613 |
-
try:
|
| 614 |
-
data = json.loads(raw)
|
| 615 |
-
return data if isinstance(data, dict) else None
|
| 616 |
-
except Exception:
|
| 617 |
-
pass
|
| 618 |
-
|
| 619 |
-
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", raw, flags=re.IGNORECASE)
|
| 620 |
-
if fenced:
|
| 621 |
-
block = fenced.group(1).strip()
|
| 622 |
-
try:
|
| 623 |
-
data = json.loads(block)
|
| 624 |
-
return data if isinstance(data, dict) else None
|
| 625 |
-
except Exception:
|
| 626 |
-
pass
|
| 627 |
-
|
| 628 |
-
start = raw.find("{")
|
| 629 |
-
end = raw.rfind("}")
|
| 630 |
-
if start >= 0 and end > start:
|
| 631 |
-
try:
|
| 632 |
-
data = json.loads(raw[start : end + 1])
|
| 633 |
-
return data if isinstance(data, dict) else None
|
| 634 |
-
except Exception:
|
| 635 |
-
return None
|
| 636 |
-
return None
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
def llm_rerank_chunks(
|
| 640 |
-
question: str,
|
| 641 |
-
history: list[dict[str, str]],
|
| 642 |
-
chunks: list[dict[str, Any]],
|
| 643 |
-
model_id: str,
|
| 644 |
-
hf_token: Optional[str],
|
| 645 |
-
groq_key: Optional[str],
|
| 646 |
-
) -> tuple[list[dict[str, Any]], Optional[str]]:
|
| 647 |
-
if not get_rerank_enabled() or len(chunks) < 2:
|
| 648 |
-
return chunks, None
|
| 649 |
-
|
| 650 |
-
candidate_limit = min(len(chunks), get_rerank_candidate_k(len(chunks)))
|
| 651 |
-
candidates = chunks[:candidate_limit]
|
| 652 |
-
by_id = {str(c.get("id")): c for c in candidates if c.get("id")}
|
| 653 |
-
if len(by_id) < 2:
|
| 654 |
-
return chunks, None
|
| 655 |
-
|
| 656 |
-
history_tail = history[-4:] if history else []
|
| 657 |
-
history_text = "\n".join(f"{m.get('role', 'user')}: {(m.get('content') or '')[:240]}" for m in history_tail)
|
| 658 |
-
candidate_lines: list[str] = []
|
| 659 |
-
for c in candidates:
|
| 660 |
-
sid = str(c.get("id") or "")
|
| 661 |
-
page = c.get("page") or 1
|
| 662 |
-
ctype = c.get("chunkType") or "main_text"
|
| 663 |
-
txt = (c.get("text") or "").strip().replace("\n", " ")
|
| 664 |
-
if len(txt) > 800:
|
| 665 |
-
txt = txt[:800] + "..."
|
| 666 |
-
candidate_lines.append(f"[{sid}] page={page} type={ctype} text={txt}")
|
| 667 |
-
|
| 668 |
-
rerank_messages: list[dict[str, str]] = [
|
| 669 |
-
{
|
| 670 |
-
"role": "system",
|
| 671 |
-
"content": (
|
| 672 |
-
"You are a retrieval ranker. Return ONLY JSON with keys: "
|
| 673 |
-
"ordered_ids (array of snippet IDs sorted by relevance), "
|
| 674 |
-
"needs_clarification (boolean), clarification_question (string). "
|
| 675 |
-
"Do not answer the user question directly. "
|
| 676 |
-
"If the user query is ambiguous relative to snippets, set needs_clarification=true and ask one concise question."
|
| 677 |
-
),
|
| 678 |
-
},
|
| 679 |
-
{
|
| 680 |
-
"role": "user",
|
| 681 |
-
"content": (
|
| 682 |
-
f"Conversation tail:\n{history_text or '(none)'}\n\n"
|
| 683 |
-
f"Question:\n{question}\n\n"
|
| 684 |
-
f"Candidate snippets:\n" + "\n".join(candidate_lines)
|
| 685 |
-
),
|
| 686 |
-
},
|
| 687 |
-
]
|
| 688 |
-
|
| 689 |
-
rerank_max_tokens = int(get_secret("RAG_RERANK_MAX_TOKENS", "280") or "280")
|
| 690 |
-
rerank_result = llm_chat_with_fallback(
|
| 691 |
-
model_id=model_id,
|
| 692 |
-
messages=rerank_messages,
|
| 693 |
-
temperature=0.0,
|
| 694 |
-
max_tokens=max(96, min(rerank_max_tokens, 512)),
|
| 695 |
-
hf_token=hf_token,
|
| 696 |
-
groq_key=groq_key,
|
| 697 |
-
)
|
| 698 |
-
rerank_content = normalize_text(rerank_result.get("content", ""))
|
| 699 |
-
parsed = extract_json_object(rerank_content)
|
| 700 |
-
if not parsed:
|
| 701 |
-
return chunks, None
|
| 702 |
-
|
| 703 |
-
needs_clarification = bool(parsed.get("needs_clarification"))
|
| 704 |
-
clarification_question = normalize_text(str(parsed.get("clarification_question") or ""))
|
| 705 |
-
if needs_clarification and clarification_question:
|
| 706 |
-
return chunks, clarification_question
|
| 707 |
-
|
| 708 |
-
ordered_ids_raw = parsed.get("ordered_ids") or []
|
| 709 |
-
if not isinstance(ordered_ids_raw, list):
|
| 710 |
-
return chunks, None
|
| 711 |
-
|
| 712 |
-
ordered_ids: list[str] = []
|
| 713 |
-
for item in ordered_ids_raw:
|
| 714 |
-
sid = str(item or "").strip()
|
| 715 |
-
if sid and sid in by_id and sid not in ordered_ids:
|
| 716 |
-
ordered_ids.append(sid)
|
| 717 |
-
|
| 718 |
-
if not ordered_ids:
|
| 719 |
-
return chunks, None
|
| 720 |
-
|
| 721 |
-
re_ranked_candidates = [by_id[sid] for sid in ordered_ids]
|
| 722 |
-
remaining_candidates = [c for c in candidates if str(c.get("id")) not in ordered_ids]
|
| 723 |
-
return re_ranked_candidates + remaining_candidates + chunks[candidate_limit:], None
|
| 724 |
|
| 725 |
|
| 726 |
-
def build_qa_prompt_with_history(
|
| 727 |
history: list[dict[str, str]],
|
| 728 |
context_blocks: list[str],
|
| 729 |
question: str,
|
|
@@ -733,51 +543,26 @@ def build_qa_prompt_with_history(
|
|
| 733 |
if len(msgs) > max_history_turns * 2:
|
| 734 |
msgs = msgs[-max_history_turns * 2 :]
|
| 735 |
|
| 736 |
-
messages: list[dict[str, str]] = [
|
| 737 |
-
{
|
| 738 |
-
"role": "system",
|
| 739 |
-
"content": (
|
| 740 |
-
"You are a precise assistant.
|
| 741 |
-
"If the
|
| 742 |
-
"
|
| 743 |
-
"
|
| 744 |
-
"
|
| 745 |
-
"For
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
),
|
| 749 |
-
}
|
| 750 |
]
|
| 751 |
messages.extend(msgs)
|
| 752 |
context_blob = "\n\n".join(context_blocks)
|
| 753 |
-
messages.append({"role": "user", "content": f"Snippets:\n{context_blob}\n\nQuestion: {question}\nAnswer:"})
|
| 754 |
-
return messages
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
def
|
| 758 |
-
msgs = [m for m in history if m.get("role") in ("user", "assistant")]
|
| 759 |
-
if len(msgs) > max_history_turns * 2:
|
| 760 |
-
msgs = msgs[-max_history_turns * 2 :]
|
| 761 |
-
messages: list[dict[str, str]] = [
|
| 762 |
-
{
|
| 763 |
-
"role": "system",
|
| 764 |
-
"content": (
|
| 765 |
-
"You are ChatQnA, a concise and helpful assistant. "
|
| 766 |
-
"Answer naturally. If user asks document-specific questions without available context, "
|
| 767 |
-
"ask them to upload/select the relevant document section."
|
| 768 |
-
),
|
| 769 |
-
}
|
| 770 |
-
]
|
| 771 |
-
messages.extend(msgs)
|
| 772 |
-
messages.append({"role": "user", "content": question})
|
| 773 |
-
return messages
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
def is_doc_grounded_query(question: str) -> bool:
|
| 777 |
-
return bool(DOC_GROUNDED_RE.search(question or ""))
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
def build_local_fallback_answer(chunks: list[dict[str, Any]]) -> str:
|
| 781 |
if not chunks:
|
| 782 |
return "No relevant content found in the knowledge base."
|
| 783 |
lines = []
|
|
@@ -789,15 +574,15 @@ def build_local_fallback_answer(chunks: list[dict[str, Any]]) -> str:
|
|
| 789 |
return "Based on retrieved context:\n" + "\n".join(lines)
|
| 790 |
|
| 791 |
|
| 792 |
-
def build_citations(answer: str, chunks: list[dict[str, Any]]) -> list[Citation]:
|
| 793 |
by_id = {str(c["id"]): c for c in chunks}
|
| 794 |
ids: list[str] = []
|
| 795 |
for sid in CITATION_REF_RE.findall(answer or ""):
|
| 796 |
if sid in by_id and sid not in ids:
|
| 797 |
ids.append(sid)
|
| 798 |
|
| 799 |
-
if not ids:
|
| 800 |
-
|
| 801 |
|
| 802 |
citations: list[Citation] = []
|
| 803 |
for sid in ids:
|
|
@@ -818,86 +603,43 @@ def build_citations(answer: str, chunks: list[dict[str, Any]]) -> list[Citation]
|
|
| 818 |
return citations
|
| 819 |
|
| 820 |
|
| 821 |
-
@app.get("/health")
|
| 822 |
-
def health() -> dict[str, str]:
|
| 823 |
-
backend = type(_VECTORSTORE).__name__ if _VECTORSTORE is not None else "lazy_uninitialized"
|
| 824 |
-
return {"status": "ok", "service": "rag-service", "vectorstore": backend}
|
| 825 |
|
| 826 |
|
| 827 |
@app.post("/query", response_model=QueryResponse)
|
| 828 |
-
def query(payload: QueryRequest) -> QueryResponse:
|
| 829 |
-
try:
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
)
|
| 852 |
-
general_answer = normalize_text(general.get("content", ""))
|
| 853 |
-
if general_answer:
|
| 854 |
-
return QueryResponse(answer=general_answer, retrievedChunks=[], citations=[])
|
| 855 |
-
return QueryResponse(
|
| 856 |
-
answer=(
|
| 857 |
-
"I can answer this, but the answer model is currently unavailable. "
|
| 858 |
-
"Please retry shortly."
|
| 859 |
-
),
|
| 860 |
-
retrievedChunks=[],
|
| 861 |
-
citations=[],
|
| 862 |
-
)
|
| 863 |
-
return QueryResponse(answer="No relevant content found in the knowledge base.", retrievedChunks=[], citations=[])
|
| 864 |
-
|
| 865 |
-
retrieved, clarifying_question = llm_rerank_chunks(
|
| 866 |
-
question=payload.message,
|
| 867 |
-
history=history,
|
| 868 |
-
chunks=retrieved,
|
| 869 |
-
model_id=model_id,
|
| 870 |
-
hf_token=hf_token,
|
| 871 |
-
groq_key=groq_key,
|
| 872 |
-
)
|
| 873 |
-
if clarifying_question:
|
| 874 |
-
return QueryResponse(answer=clarifying_question, retrievedChunks=[], citations=[])
|
| 875 |
-
|
| 876 |
-
retrieved = retrieved[: max(1, int(payload.topK))]
|
| 877 |
-
context_blocks = [f"[{c['id']}] {c['text']}" for c in retrieved]
|
| 878 |
-
messages = build_qa_prompt_with_history(history, context_blocks, payload.message, max_history_turns=8)
|
| 879 |
-
|
| 880 |
-
result = llm_chat_with_fallback(
|
| 881 |
-
model_id=model_id,
|
| 882 |
-
messages=messages,
|
| 883 |
-
temperature=temperature,
|
| 884 |
max_tokens=max_tokens,
|
| 885 |
hf_token=hf_token,
|
| 886 |
groq_key=groq_key,
|
| 887 |
-
)
|
| 888 |
-
answer = normalize_text(result.get("content", ""))
|
| 889 |
-
if not answer:
|
| 890 |
-
answer = build_local_fallback_answer(retrieved)
|
| 891 |
-
if result.get("error_primary") or result.get("error_fallback"):
|
| 892 |
-
print(
|
| 893 |
-
"llm_unavailable:",
|
| 894 |
-
{
|
| 895 |
-
"model_candidates": get_model_candidates(model_id),
|
| 896 |
-
"error_primary": result.get("error_primary"),
|
| 897 |
-
"error_fallback": result.get("error_fallback"),
|
| 898 |
-
},
|
| 899 |
-
)
|
| 900 |
-
answer += "\n\n(LLM unavailable right now; showing highest-signal retrieved context.)"
|
| 901 |
|
| 902 |
if "not found in the knowledge base" in answer.lower() and context_blocks:
|
| 903 |
retry_messages = [
|
|
@@ -922,21 +664,21 @@ def query(payload: QueryRequest) -> QueryResponse:
|
|
| 922 |
citations = build_citations(answer, retrieved)
|
| 923 |
response_chunks = [RetrievedChunk(**chunk) for chunk in retrieved]
|
| 924 |
return QueryResponse(answer=answer, retrievedChunks=response_chunks, citations=citations)
|
| 925 |
-
except Exception as exc:
|
| 926 |
-
trace = traceback.format_exc(limit=4)
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
"
|
| 937 |
-
"
|
| 938 |
-
),
|
| 939 |
-
retrievedChunks=[fallback],
|
| 940 |
-
citations=[Citation(id="S1", page=1, chunkType="main_text", text=fallback.text)],
|
| 941 |
-
)
|
| 942 |
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
|
|
|
| 3 |
import math
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import traceback
|
| 7 |
+
from collections import Counter
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI
|
| 12 |
+
from openai import OpenAI
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from langchain_community.vectorstores import FAISS
|
| 16 |
+
from langchain_qdrant import QdrantVectorStore
|
| 17 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 18 |
+
from qdrant_client import QdrantClient
|
| 19 |
+
from qdrant_client.http import models as qmodels
|
| 20 |
+
from qdrant_client.http.models import Distance, VectorParams
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
EMBED_DIM = 384
|
| 24 |
+
FAISS_DIR = Path(os.getenv("FAISS_DIR", "faiss_store"))
|
| 25 |
+
PAGE_RE = re.compile(r"\[PAGE\s+(\d+)\]", re.IGNORECASE)
|
| 26 |
FIGURE_REF_RE = re.compile(r"\b(fig(?:ure)?\.?)\s*(\d+)\b", re.IGNORECASE)
|
| 27 |
TABLE_REF_RE = re.compile(r"\b(tab(?:le)?\.?)\s*(\d+)\b", re.IGNORECASE)
|
| 28 |
CITATION_REF_RE = re.compile(r"\[(S\d+)\]")
|
| 29 |
|
| 30 |
+
VISUAL_TYPES = {
|
| 31 |
"figure_explain",
|
| 32 |
"figure_ocr",
|
| 33 |
"figure_caption",
|
|
|
|
| 38 |
"table_pdfplumber",
|
| 39 |
"table_unstructured",
|
| 40 |
"tesseract_ocr",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
+
_VECTORSTORE: Optional[Any] = None
|
| 44 |
+
_EMBEDDINGS: Optional[Any] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
STOPWORDS = {
|
| 47 |
+
"the",
|
| 48 |
+
"and",
|
| 49 |
+
"for",
|
| 50 |
+
"with",
|
| 51 |
+
"that",
|
| 52 |
+
"this",
|
| 53 |
+
"from",
|
| 54 |
+
"into",
|
| 55 |
+
"your",
|
| 56 |
+
"about",
|
| 57 |
+
"what",
|
| 58 |
+
"which",
|
| 59 |
+
"where",
|
| 60 |
+
"when",
|
| 61 |
+
"how",
|
| 62 |
+
"why",
|
| 63 |
+
"does",
|
| 64 |
+
"did",
|
| 65 |
+
"are",
|
| 66 |
+
"is",
|
| 67 |
+
"was",
|
| 68 |
+
"were",
|
| 69 |
+
"be",
|
| 70 |
+
"been",
|
| 71 |
+
"being",
|
| 72 |
+
"have",
|
| 73 |
+
"has",
|
| 74 |
+
"had",
|
| 75 |
+
"can",
|
| 76 |
+
"could",
|
| 77 |
+
"should",
|
| 78 |
+
"would",
|
| 79 |
+
"may",
|
| 80 |
+
"might",
|
| 81 |
+
"will",
|
| 82 |
+
"shall",
|
| 83 |
+
"a",
|
| 84 |
+
"an",
|
| 85 |
+
"of",
|
| 86 |
+
"to",
|
| 87 |
+
"in",
|
| 88 |
+
"on",
|
| 89 |
+
"at",
|
| 90 |
+
"by",
|
| 91 |
+
"as",
|
| 92 |
+
"it",
|
| 93 |
+
"its",
|
| 94 |
+
"or",
|
| 95 |
+
"if",
|
| 96 |
+
"but",
|
| 97 |
+
"not",
|
| 98 |
+
"we",
|
| 99 |
+
"our",
|
| 100 |
+
"you",
|
| 101 |
+
"they",
|
| 102 |
+
"their",
|
| 103 |
+
"them",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class HistoryMessage(BaseModel):
|
| 108 |
role: str
|
| 109 |
content: str
|
| 110 |
|
|
|
|
| 154 |
app = FastAPI(title="chatqna-rag-service", version="0.2.0")
|
| 155 |
|
| 156 |
|
| 157 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 158 |
+
return os.getenv(name, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
def normalize_text(text: str) -> str:
|
| 162 |
+
return (text or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
+
def get_embeddings() -> Any:
|
| 166 |
+
"""
|
| 167 |
+
Lazy-load sentence-transformers stack to keep startup memory low.
|
| 168 |
+
"""
|
| 169 |
+
global _EMBEDDINGS
|
| 170 |
+
if _EMBEDDINGS is None:
|
| 171 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
+
_EMBEDDINGS = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 174 |
+
return _EMBEDDINGS
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
def get_retrieval_mode() -> str:
|
| 178 |
+
mode = (get_secret("RAG_RETRIEVAL_MODE", "lexical") or "lexical").strip().lower()
|
| 179 |
+
return mode if mode in {"lexical", "semantic"} else "lexical"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
+
def hf_routed_model(model_id: str) -> str:
|
| 183 |
+
return model_id if ":" in model_id else f"{model_id}:groq"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def openai_chat(
|
| 187 |
+
client: OpenAI,
|
| 188 |
+
model: str,
|
| 189 |
messages: list[dict[str, Any]],
|
| 190 |
temperature: float,
|
| 191 |
max_tokens: int,
|
|
|
|
| 201 |
return content or "", resp
|
| 202 |
|
| 203 |
|
| 204 |
+
def llm_chat_with_fallback(
|
| 205 |
+
model_id: str,
|
| 206 |
messages: list[dict[str, Any]],
|
| 207 |
temperature: float,
|
| 208 |
max_tokens: int,
|
| 209 |
hf_token: Optional[str],
|
| 210 |
groq_key: Optional[str],
|
| 211 |
+
) -> dict[str, Any]:
|
| 212 |
+
result: dict[str, Any] = {
|
| 213 |
+
"content": "",
|
| 214 |
+
"primary_used": False,
|
| 215 |
+
"raw": None,
|
| 216 |
+
"error_primary": None,
|
| 217 |
+
"error_fallback": None,
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
if hf_token:
|
| 221 |
+
try:
|
| 222 |
+
hf_client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=hf_token)
|
| 223 |
+
routed = hf_routed_model(model_id)
|
| 224 |
+
content, raw = openai_chat(hf_client, routed, messages, temperature, max_tokens)
|
| 225 |
+
if normalize_text(content):
|
| 226 |
+
result.update({"content": content, "primary_used": True, "raw": raw})
|
| 227 |
+
return result
|
| 228 |
+
result["error_primary"] = "Primary returned empty content."
|
| 229 |
+
except Exception as exc: # pragma: no cover
|
| 230 |
+
result["error_primary"] = f"{type(exc).__name__}: {exc}"
|
| 231 |
+
else:
|
| 232 |
+
result["error_primary"] = "Missing HUGGINGFACE_API_TOKEN"
|
| 233 |
+
|
| 234 |
+
if not groq_key:
|
| 235 |
+
result["error_fallback"] = "Missing GROQ_API_KEY (fallback not available)"
|
| 236 |
+
return result
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
groq_client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
|
| 240 |
+
content, raw = openai_chat(groq_client, model_id, messages, temperature, max_tokens)
|
| 241 |
+
result.update({"content": content, "raw": raw})
|
| 242 |
+
return result
|
| 243 |
+
except Exception as exc: # pragma: no cover
|
| 244 |
+
result["error_fallback"] = f"{type(exc).__name__}: {exc}"
|
| 245 |
+
return result
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def load_faiss(embeddings: Any, path: Path = FAISS_DIR) -> Optional[FAISS]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
if (path / "index.faiss").exists() and (path / "index.pkl").exists():
|
| 250 |
return FAISS.load_local(str(path), embeddings, allow_dangerous_deserialization=True)
|
| 251 |
return None
|
|
|
|
| 267 |
collection_name=collection_name,
|
| 268 |
vectors_config=VectorParams(size=EMBED_DIM, distance=Distance.COSINE),
|
| 269 |
)
|
| 270 |
+
store = QdrantVectorStore(client=client, collection_name=collection_name, embedding=get_embeddings())
|
| 271 |
+
return store, None
|
| 272 |
except Exception as exc:
|
| 273 |
return None, f"{type(exc).__name__}: {exc}"
|
| 274 |
|
|
|
|
| 285 |
_VECTORSTORE = store
|
| 286 |
return _VECTORSTORE
|
| 287 |
|
| 288 |
+
_VECTORSTORE = load_faiss(get_embeddings())
|
| 289 |
+
return _VECTORSTORE
|
| 290 |
|
| 291 |
|
| 292 |
def qdrant_filter_for_chunk_types(types: list[str]) -> qmodels.Filter:
|
|
|
|
| 377 |
return sum(x * y for x, y in zip(a, b))
|
| 378 |
|
| 379 |
|
| 380 |
+
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
| 381 |
na = math.sqrt(dot(a, a))
|
| 382 |
nb = math.sqrt(dot(b, b))
|
| 383 |
if na == 0.0 or nb == 0.0:
|
| 384 |
return 0.0
|
| 385 |
+
return dot(a, b) / (na * nb)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def tokenize_retrieval(text: str) -> list[str]:
|
| 389 |
+
toks = re.findall(r"[A-Za-z0-9][A-Za-z0-9\-_]{1,}", text or "")
|
| 390 |
+
return [t.lower() for t in toks if t.lower() not in STOPWORDS]
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def lexical_overlap_score(query: str, text: str) -> float:
|
| 394 |
+
q_tokens = tokenize_retrieval(query)
|
| 395 |
+
t_tokens = tokenize_retrieval(text)
|
| 396 |
+
if not q_tokens or not t_tokens:
|
| 397 |
+
return 0.0
|
| 398 |
+
q_count = Counter(q_tokens)
|
| 399 |
+
t_count = Counter(t_tokens)
|
| 400 |
+
overlap = sum(min(cnt, t_count.get(tok, 0)) for tok, cnt in q_count.items())
|
| 401 |
+
if overlap <= 0:
|
| 402 |
+
return 0.0
|
| 403 |
+
# Lightweight score favoring chunks with better query coverage.
|
| 404 |
+
return overlap / float(len(q_tokens) + 0.35 * len(t_tokens))
|
| 405 |
|
| 406 |
|
| 407 |
def split_text_with_offsets(text: str, chunk_size: int, chunk_overlap: int) -> list[dict[str, Any]]:
|
|
|
|
| 432 |
return chunks
|
| 433 |
|
| 434 |
|
| 435 |
+
def retrieve_from_uploaded_document(payload: QueryRequest) -> list[dict[str, Any]]:
|
| 436 |
document = payload.document
|
| 437 |
document_text = (document.documentText if document else "") or ""
|
| 438 |
if not document_text.strip():
|
|
|
|
| 444 |
if not chunks:
|
| 445 |
return []
|
| 446 |
|
| 447 |
+
ranked: list[dict[str, Any]] = []
|
| 448 |
+
if get_retrieval_mode() == "semantic":
|
| 449 |
+
embeddings = get_embeddings()
|
| 450 |
+
query_vec = embeddings.embed_query(payload.message)
|
| 451 |
+
doc_vecs = embeddings.embed_documents([c["text"] for c in chunks])
|
| 452 |
+
for chunk, vec in zip(chunks, doc_vecs):
|
| 453 |
+
score = cosine_similarity(query_vec, vec)
|
| 454 |
+
ranked.append({**chunk, "score": float(score)})
|
| 455 |
+
else:
|
| 456 |
+
for chunk in chunks:
|
| 457 |
+
score = lexical_overlap_score(payload.message, str(chunk.get("text") or ""))
|
| 458 |
+
ranked.append({**chunk, "score": float(score)})
|
| 459 |
|
| 460 |
+
ranked.sort(key=lambda c: c["score"], reverse=True)
|
| 461 |
+
top_k = max(1, min(payload.topK, len(ranked)))
|
|
|
|
| 462 |
out: list[dict[str, Any]] = []
|
| 463 |
for idx, item in enumerate(ranked[:top_k], start=1):
|
| 464 |
out.append(
|
|
|
|
| 488 |
return idx, min(len(source), idx + len(needle))
|
| 489 |
|
| 490 |
|
| 491 |
+
def retrieve_from_vectorstore(payload: QueryRequest) -> list[dict[str, Any]]:
|
| 492 |
vectorstore = ensure_vectorstore()
|
| 493 |
if vectorstore is None:
|
| 494 |
return []
|
| 495 |
|
| 496 |
+
prefer_visual = is_visual_question(payload.message)
|
| 497 |
+
k = max(int(payload.topK), 10) if prefer_visual else int(payload.topK)
|
|
|
|
| 498 |
docs = retrieve_docs(vectorstore, payload.message, k=k, prefer_visual=prefer_visual)
|
| 499 |
if not docs:
|
| 500 |
return []
|
|
|
|
| 530 |
"score": None,
|
| 531 |
}
|
| 532 |
)
|
| 533 |
+
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
|
| 535 |
|
| 536 |
+
def build_qa_prompt_with_history(
|
| 537 |
history: list[dict[str, str]],
|
| 538 |
context_blocks: list[str],
|
| 539 |
question: str,
|
|
|
|
| 543 |
if len(msgs) > max_history_turns * 2:
|
| 544 |
msgs = msgs[-max_history_turns * 2 :]
|
| 545 |
|
| 546 |
+
messages: list[dict[str, str]] = [
|
| 547 |
+
{
|
| 548 |
+
"role": "system",
|
| 549 |
+
"content": (
|
| 550 |
+
"You are a precise assistant. Answer using ONLY the provided snippets. "
|
| 551 |
+
"If the answer is not present in snippets, say: 'Not found in the knowledge base.' "
|
| 552 |
+
"Cite snippet IDs like [S1], [S2]. Every answer must include citations. "
|
| 553 |
+
"For TABLE questions, prioritize [TABLE_EXPLAIN], [TABLE_OCR], [TABLE_CAPTION], "
|
| 554 |
+
"[TABLE_PDFPLUMBER], [TABLE_UNSTRUCTURED], [PAGE_OCR] snippets. "
|
| 555 |
+
"For FIGURE questions, prioritize [FIGURE_EXPLAIN], [FIGURE_OCR], [FIGURE_CAPTION], [PAGE_OCR] snippets."
|
| 556 |
+
),
|
| 557 |
+
}
|
|
|
|
|
|
|
| 558 |
]
|
| 559 |
messages.extend(msgs)
|
| 560 |
context_blob = "\n\n".join(context_blocks)
|
| 561 |
+
messages.append({"role": "user", "content": f"Snippets:\n{context_blob}\n\nQuestion: {question}\nAnswer:"})
|
| 562 |
+
return messages
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def build_local_fallback_answer(chunks: list[dict[str, Any]]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
if not chunks:
|
| 567 |
return "No relevant content found in the knowledge base."
|
| 568 |
lines = []
|
|
|
|
| 574 |
return "Based on retrieved context:\n" + "\n".join(lines)
|
| 575 |
|
| 576 |
|
| 577 |
+
def build_citations(answer: str, chunks: list[dict[str, Any]]) -> list[Citation]:
|
| 578 |
by_id = {str(c["id"]): c for c in chunks}
|
| 579 |
ids: list[str] = []
|
| 580 |
for sid in CITATION_REF_RE.findall(answer or ""):
|
| 581 |
if sid in by_id and sid not in ids:
|
| 582 |
ids.append(sid)
|
| 583 |
|
| 584 |
+
if not ids:
|
| 585 |
+
ids = [str(c["id"]) for c in chunks[: min(3, len(chunks))]]
|
| 586 |
|
| 587 |
citations: list[Citation] = []
|
| 588 |
for sid in ids:
|
|
|
|
| 603 |
return citations
|
| 604 |
|
| 605 |
|
| 606 |
+
@app.get("/health")
|
| 607 |
+
def health() -> dict[str, str]:
|
| 608 |
+
backend = type(_VECTORSTORE).__name__ if _VECTORSTORE is not None else "lazy_uninitialized"
|
| 609 |
+
return {"status": "ok", "service": "rag-service", "vectorstore": backend}
|
| 610 |
|
| 611 |
|
| 612 |
@app.post("/query", response_model=QueryResponse)
|
| 613 |
+
def query(payload: QueryRequest) -> QueryResponse:
|
| 614 |
+
try:
|
| 615 |
+
retrieved = retrieve_from_uploaded_document(payload)
|
| 616 |
+
if not retrieved:
|
| 617 |
+
retrieved = retrieve_from_vectorstore(payload)
|
| 618 |
+
|
| 619 |
+
if not retrieved:
|
| 620 |
+
return QueryResponse(answer="No relevant content found in the knowledge base.", retrievedChunks=[], citations=[])
|
| 621 |
+
|
| 622 |
+
context_blocks = [f"[{c['id']}] {c['text']}" for c in retrieved]
|
| 623 |
+
history = [m.model_dump() for m in payload.history]
|
| 624 |
+
messages = build_qa_prompt_with_history(history, context_blocks, payload.message, max_history_turns=8)
|
| 625 |
+
|
| 626 |
+
model_id = get_secret("RAG_MODEL_ID", "openai/gpt-oss-20b") or "openai/gpt-oss-20b"
|
| 627 |
+
temperature = float(get_secret("RAG_TEMPERATURE", "0.2") or "0.2")
|
| 628 |
+
max_tokens = int(get_secret("RAG_MAX_TOKENS", "512") or "512")
|
| 629 |
+
hf_token = get_secret("HUGGINGFACE_API_TOKEN")
|
| 630 |
+
groq_key = get_secret("GROQ_API_KEY")
|
| 631 |
+
|
| 632 |
+
result = llm_chat_with_fallback(
|
| 633 |
+
model_id=model_id,
|
| 634 |
+
messages=messages,
|
| 635 |
+
temperature=temperature,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
max_tokens=max_tokens,
|
| 637 |
hf_token=hf_token,
|
| 638 |
groq_key=groq_key,
|
| 639 |
+
)
|
| 640 |
+
answer = normalize_text(result.get("content", ""))
|
| 641 |
+
if not answer:
|
| 642 |
+
answer = build_local_fallback_answer(retrieved)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
|
| 644 |
if "not found in the knowledge base" in answer.lower() and context_blocks:
|
| 645 |
retry_messages = [
|
|
|
|
| 664 |
citations = build_citations(answer, retrieved)
|
| 665 |
response_chunks = [RetrievedChunk(**chunk) for chunk in retrieved]
|
| 666 |
return QueryResponse(answer=answer, retrievedChunks=response_chunks, citations=citations)
|
| 667 |
+
except Exception as exc:
|
| 668 |
+
trace = traceback.format_exc(limit=4)
|
| 669 |
+
fallback = RetrievedChunk(
|
| 670 |
+
id="S1",
|
| 671 |
+
page=1,
|
| 672 |
+
chunkType="main_text",
|
| 673 |
+
text=f"rag-service exception: {type(exc).__name__}: {exc}",
|
| 674 |
+
)
|
| 675 |
+
return QueryResponse(
|
| 676 |
+
answer=(
|
| 677 |
+
"RAG query failed and returned a guarded fallback response. "
|
| 678 |
+
"Check rag-service logs for details.\n\n"
|
| 679 |
+
f"{trace}"
|
| 680 |
+
),
|
| 681 |
+
retrievedChunks=[fallback],
|
| 682 |
+
citations=[Citation(id="S1", page=1, chunkType="main_text", text=fallback.text)],
|
| 683 |
+
)
|
| 684 |
|
requirements.txt
CHANGED
|
@@ -2,7 +2,6 @@ fastapi==0.115.5
|
|
| 2 |
uvicorn[standard]==0.32.0
|
| 3 |
pydantic==2.9.2
|
| 4 |
openai==1.54.4
|
| 5 |
-
httpx==0.27.2
|
| 6 |
langchain-text-splitters==0.3.2
|
| 7 |
langchain-huggingface==0.1.0
|
| 8 |
langchain-community==0.3.7
|
|
|
|
| 2 |
uvicorn[standard]==0.32.0
|
| 3 |
pydantic==2.9.2
|
| 4 |
openai==1.54.4
|
|
|
|
| 5 |
langchain-text-splitters==0.3.2
|
| 6 |
langchain-huggingface==0.1.0
|
| 7 |
langchain-community==0.3.7
|