.env.example CHANGED
@@ -1,16 +1,12 @@
1
  KB_BACKEND=qdrant
2
- RAG_RETRIEVAL_MODE=semantic
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=llama-3.1-8b-instant
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
- DOC_GROUNDED_RE = re.compile(
108
- r"\b(document|doc|pdf|page|citation|snippet|source|context|table|figure|selected)\b",
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
- class HistoryMessage(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _EMBEDDINGS = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
191
- return _EMBEDDINGS
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 get_rerank_enabled() -> bool:
200
- return get_bool_secret("RAG_LLM_RERANK", True)
201
-
202
-
203
- def get_rerank_candidate_k(top_k: int) -> int:
204
- cfg = int(get_secret("RAG_RERANK_CANDIDATES", "8") or "8")
205
- cfg = max(3, min(cfg, 16))
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
- deduped: list[str] = []
223
- seen: set[str] = set()
224
- for candidate in candidates:
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 openai_chat(
234
- client: OpenAI,
235
- model: str,
 
 
 
 
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
- "used_model": None,
264
- "error_primary": None,
265
- "error_fallback": None,
266
- }
267
- candidate_models = get_model_candidates(model_id)
268
-
269
- if hf_token:
270
- primary_errors: list[str] = []
271
- try:
272
- hf_client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=hf_token)
273
- for candidate in candidate_models:
274
- try:
275
- routed = hf_routed_model(candidate)
276
- content, raw = openai_chat(hf_client, routed, messages, temperature, max_tokens)
277
- if normalize_text(content):
278
- result.update(
279
- {
280
- "content": content,
281
- "primary_used": True,
282
- "raw": raw,
283
- "used_model": candidate,
284
- }
285
- )
286
- return result
287
- primary_errors.append(f"{candidate}: empty content")
288
- except Exception as exc: # pragma: no cover
289
- primary_errors.append(f"{candidate}: {type(exc).__name__}: {exc}")
290
- except Exception as exc: # pragma: no cover
291
- primary_errors.append(f"hf_client_init: {type(exc).__name__}: {exc}")
292
- result["error_primary"] = " | ".join(primary_errors[:4]) or "Primary returned empty content."
293
- else:
294
- result["error_primary"] = "Missing HUGGINGFACE_API_TOKEN"
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
- candidate_k = get_rerank_candidate_k(payload.topK) if get_rerank_enabled() else int(payload.topK)
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
- requested_k = get_rerank_candidate_k(payload.topK) if get_rerank_enabled() else int(payload.topK)
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. Prefer answers grounded in the provided snippets. "
741
- "If the query is ambiguous (for example, 'explain this') and target passage is unclear, "
742
- "ask one concise clarifying question instead of guessing. "
743
- "If the answer is not present in snippets, say: 'Not found in the knowledge base.' "
744
- "When you use snippets, cite snippet IDs like [S1], [S2]. "
745
- "For TABLE questions, prioritize [TABLE_EXPLAIN], [TABLE_OCR], [TABLE_CAPTION], "
746
- "[TABLE_PDFPLUMBER], [TABLE_UNSTRUCTURED], [PAGE_OCR] snippets. "
747
- "For FIGURE questions, prioritize [FIGURE_EXPLAIN], [FIGURE_OCR], [FIGURE_CAPTION], [PAGE_OCR] snippets."
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 build_general_chat_prompt(history: list[dict[str, str]], question: str, max_history_turns: int = 8) -> list[dict[str, str]]:
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
- return []
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
- model_id = get_secret("RAG_MODEL_ID", DEFAULT_MODEL_ID) or DEFAULT_MODEL_ID
831
- temperature = float(get_secret("RAG_TEMPERATURE", "0.2") or "0.2")
832
- max_tokens = int(get_secret("RAG_MAX_TOKENS", "512") or "512")
833
- hf_token = get_secret("HUGGINGFACE_API_TOKEN")
834
- groq_key = get_secret("GROQ_API_KEY")
835
- history = [m.model_dump() for m in payload.history]
836
-
837
- retrieved = retrieve_from_uploaded_document(payload)
838
- if not retrieved:
839
- retrieved = retrieve_from_vectorstore(payload)
840
-
841
- if not retrieved:
842
- if not is_doc_grounded_query(payload.message):
843
- general_messages = build_general_chat_prompt(history, payload.message, max_history_turns=8)
844
- general = llm_chat_with_fallback(
845
- model_id=model_id,
846
- messages=general_messages,
847
- temperature=temperature,
848
- max_tokens=max_tokens,
849
- hf_token=hf_token,
850
- groq_key=groq_key,
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
- print("query_exception:", trace)
928
- fallback = RetrievedChunk(
929
- id="S1",
930
- page=1,
931
- chunkType="main_text",
932
- text=f"rag-service exception: {type(exc).__name__}: {exc}",
933
- )
934
- return QueryResponse(
935
- answer=(
936
- "RAG query failed and returned a guarded fallback response. "
937
- "Check rag-service logs for details."
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