minh-4T commited on
Commit
95e3d4a
·
1 Parent(s): 628deed

update big big

Browse files
core/chunking.py CHANGED
@@ -1,38 +1,14 @@
1
  import re
2
- from typing import List, Tuple
3
- import logging
4
-
5
  from langchain_text_splitters import RecursiveCharacterTextSplitter
6
-
7
  from .config import CHUNK_SIZE, CHUNK_OVERLAP
8
 
9
- logger = logging.getLogger(__name__)
10
-
11
- STRUCTURE_PATTERNS = [
12
- r"(?m)^\s*(Điều\s+\d+[\.:]?)",
13
- r"(?m)^\s*(Chương\s+[IVXLC\d]+[\.:]?)",
14
- r"(?m)^\s*(Mục\s+\d+[\.:]?)",
15
- r"(?m)^\s*(Khoản\s+\d+[\.:]?)",
16
- r"(?m)^\s*(Điểm\s+[a-zA-Z0-9]+[\.:]?)",
17
- r"(?m)^\s*(\d+(?:\.\d+)*[\)\.])",
18
- r"(?m)^\s*([a-zA-Z][\)\.])",
19
- ]
20
-
21
- LIST_PATTERNS = [
22
- (r"(?m)^\s*a\.", "<LIST_A>"),
23
- (r"(?m)^\s*b\.", "<LIST_B>"),
24
- (r"(?m)^\s*c\.", "<LIST_C>"),
25
- (r"(?m)^\s*\d+\.", "<LIST_NUM>"),
26
- (r"(?m)^\s*\d+\)", "<LIST_NUM_PAREN>"),
27
- (r"(?m)^\s*-\s+", "<LIST_DASH>"),
28
- (r"(?m)^\s*•\s+", "<LIST_BULLET>"),
29
- ]
30
-
31
-
32
- def extract_and_protect_tables(text: str) -> Tuple[str, dict]:
33
- table_pattern = re.compile(r"(?:\|.*\|[\r\n]+)+")
34
  tables = {}
35
-
36
  def replace_table(match):
37
  table_id = f"<TABLE_{len(tables)}>"
38
  tables[table_id] = match.group(0)
@@ -41,94 +17,47 @@ def extract_and_protect_tables(text: str) -> Tuple[str, dict]:
41
  protected_text = re.sub(table_pattern, replace_table, text)
42
  return protected_text, tables
43
 
44
-
45
- def protect_lists(text: str) -> Tuple[str, dict]:
46
- placeholders = {}
47
- protected = text
48
-
49
- for pattern, token in LIST_PATTERNS:
50
- matches = list(re.finditer(pattern, protected))
51
- for index, match in enumerate(matches):
52
- placeholder = f"{token}_{index}"
53
- placeholders[placeholder] = match.group(0)
54
- protected = protected.replace(match.group(0), placeholder, 1)
55
-
56
- return protected, placeholders
57
-
58
-
59
- def restore_placeholders(text: str, placeholders: dict) -> str:
60
- restored = text
61
- for placeholder, original in placeholders.items():
62
- restored = restored.replace(placeholder, original)
63
- return restored
64
-
65
-
66
- def split_by_structure(text: str) -> List[str]:
67
- parts = [text]
68
-
69
- for pattern in STRUCTURE_PATTERNS:
70
- next_parts = []
71
- for part in parts:
72
- matches = list(re.finditer(pattern, part))
73
- if len(matches) <= 1:
74
- next_parts.append(part)
75
- continue
76
-
77
- last_pos = 0
78
- for idx, match in enumerate(matches):
79
- start = match.start()
80
- if idx > 0 and start > last_pos:
81
- chunk = part[last_pos:start].strip()
82
- if chunk:
83
- next_parts.append(chunk)
84
- last_pos = start
85
-
86
- tail = part[last_pos:].strip()
87
- if tail:
88
- next_parts.append(tail)
89
-
90
- parts = next_parts or parts
91
-
92
- return [part for part in parts if part.strip()]
93
-
94
-
95
  def smart_chunking(docs: List) -> List:
96
- logger.info("Chunking theo cau truc + do dai...")
97
- length_splitter = RecursiveCharacterTextSplitter(
98
  chunk_size=CHUNK_SIZE,
99
  chunk_overlap=CHUNK_OVERLAP,
100
- separators=["\n\n", "\n", ". ", " ", ""],
 
 
 
101
  length_function=len,
102
- is_separator_regex=False,
103
  )
104
-
105
  chunks = []
106
-
107
  for doc in docs:
108
- text = doc.page_content or ""
109
- protected_text, tables = extract_and_protect_tables(text)
110
- protected_text, list_placeholders = protect_lists(protected_text)
111
-
112
- structure_parts = split_by_structure(protected_text)
113
-
114
- for part in structure_parts:
115
- if len(part) <= CHUNK_SIZE:
116
- sub_parts = [part]
117
- else:
118
- sub_parts = length_splitter.split_text(part)
119
-
120
- for chunk_text in sub_parts:
121
- restored = restore_placeholders(chunk_text, list_placeholders)
122
- restored = restore_placeholders(restored, tables)
123
-
124
- if not restored.strip():
125
- continue
126
-
127
- new_doc = type(doc)(
128
- page_content=restored.strip(),
129
- metadata=doc.metadata.copy() if isinstance(doc.metadata, dict) else {},
130
- )
131
- chunks.append(new_doc)
132
-
133
- logger.info("Da tao %s chunks.", len(chunks))
 
 
134
  return chunks
 
1
  import re
2
+ from typing import List
 
 
3
  from langchain_text_splitters import RecursiveCharacterTextSplitter
 
4
  from .config import CHUNK_SIZE, CHUNK_OVERLAP
5
 
6
+ def extract_and_protect_tables(text: str) -> tuple[str, dict]:
7
+ """Tìm và bọc các bảng Markdown để bảo vệ chúng khỏi việc bị cắt gãy."""
8
+ # Pattern tìm bảng Markdown (các dòng bắt đầu và chứa ký tự | liên tiếp)
9
+ table_pattern = re.compile(r'(?:\|.*\|[\r\n]+)+')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  tables = {}
11
+
12
  def replace_table(match):
13
  table_id = f"<TABLE_{len(tables)}>"
14
  tables[table_id] = match.group(0)
 
17
  protected_text = re.sub(table_pattern, replace_table, text)
18
  return protected_text, tables
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def smart_chunking(docs: List) -> List:
21
+ print("Đang áp dụng Smart Chunking (Bảo toàn Bảng & Danh sách)...")
22
+ legal_splitter = RecursiveCharacterTextSplitter(
23
  chunk_size=CHUNK_SIZE,
24
  chunk_overlap=CHUNK_OVERLAP,
25
+ separators=[
26
+ "\nĐiều ", "\nChương ", "\nMục ", "\nKhoản ",
27
+ "\n\n", "\n", ". ", " ", ""
28
+ ],
29
  length_function=len,
30
+ is_separator_regex=False
31
  )
32
+
33
  chunks = []
 
34
  for doc in docs:
35
+ # 1. Bảo vệ List đang có
36
+ protected_text = doc.page_content.replace('\na.', '<LIST_a>') \
37
+ .replace('\nb.', '<LIST_b>') \
38
+ .replace('\nc.', '<LIST_c>')
39
+
40
+ # 2. Bảo vệ Table
41
+ protected_text, tables = extract_and_protect_tables(protected_text)
42
+
43
+ # 3. Tiến hành cắt
44
+ doc_chunks = legal_splitter.split_text(protected_text)
45
+
46
+ # 4. Phục hồi dữ liệu
47
+ for chunk_text in doc_chunks:
48
+ restored = chunk_text.replace('<LIST_a>', '\na.') \
49
+ .replace('<LIST_b>', '\nb.') \
50
+ .replace('<LIST_c>', '\nc.')
51
+
52
+ for table_id, table_content in tables.items():
53
+ if table_id in restored:
54
+ restored = restored.replace(table_id, table_content)
55
+
56
+ new_doc = type(doc)(
57
+ page_content=restored,
58
+ metadata=doc.metadata.copy()
59
+ )
60
+ chunks.append(new_doc)
61
+
62
+ print(f" Đã tạo {len(chunks)} chunks thông minh (giữ nguyên cấu trúc bảng)")
63
  return chunks
core/collection_router_retriever.py CHANGED
@@ -3,6 +3,7 @@ import logging
3
  from typing import List
4
 
5
  from langchain_core.documents import Document as LangChainDocument
 
6
 
7
  from .collection_utils import collection_matches_cohort
8
  from .document_db import SessionLocal, list_active_collection_names
@@ -22,6 +23,7 @@ class CollectionRouterRetriever:
22
  self.qdrant_client = qdrant_client
23
  self.embeddings_model = embeddings_model
24
  self.top_n_collections = max(1, int(top_n_collections or 3))
 
25
 
26
  @staticmethod
27
  def _doc_key(doc) -> str:
@@ -61,7 +63,57 @@ class CollectionRouterRetriever:
61
 
62
  return active_collections[: self.top_n_collections]
63
 
64
- def _search_target_collections(self, query: str, collections: List[str], limit: int) -> List:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  if not collections:
66
  return []
67
 
@@ -71,7 +123,11 @@ class CollectionRouterRetriever:
71
  logger.exception("Failed to embed query for collection routing")
72
  return []
73
 
74
- scored_docs = []
 
 
 
 
75
  for collection_name in collections:
76
  try:
77
  points = self.qdrant_client.search(
@@ -101,15 +157,73 @@ class CollectionRouterRetriever:
101
  "chunk_index": payload.get("chunk_index"),
102
  "page_number": payload.get("page_number"),
103
  }
104
- scored_docs.append(
105
- (
106
- float(getattr(point, "score", 0.0) or 0.0),
107
- LangChainDocument(page_content=content, metadata=metadata),
108
- )
109
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
- scored_docs.sort(key=lambda row: row[0], reverse=True)
112
- return [doc for _, doc in scored_docs]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  def search(self, query: str, k: int = 10, alpha: float = 0.6, cohort_key: str | None = None) -> List:
115
  if k <= 0:
@@ -126,6 +240,7 @@ class CollectionRouterRetriever:
126
  query=query,
127
  collections=target_collections,
128
  limit=candidate_k,
 
129
  )
130
 
131
  if cohort_scoped:
 
3
  from typing import List
4
 
5
  from langchain_core.documents import Document as LangChainDocument
6
+ from rank_bm25 import BM25Okapi
7
 
8
  from .collection_utils import collection_matches_cohort
9
  from .document_db import SessionLocal, list_active_collection_names
 
23
  self.qdrant_client = qdrant_client
24
  self.embeddings_model = embeddings_model
25
  self.top_n_collections = max(1, int(top_n_collections or 3))
26
+ self._bm25_cache = {} # {collection_name -> BM25Okapi instance}
27
 
28
  @staticmethod
29
  def _doc_key(doc) -> str:
 
63
 
64
  return active_collections[: self.top_n_collections]
65
 
66
+ def _ensure_bm25_loaded(self, collection_name: str) -> BM25Okapi | None:
67
+ """Lazy load and cache BM25 index for a collection.
68
+
69
+ First time: fetch all docs from Qdrant, build BM25, cache it (~0.3s)
70
+ Subsequent times: reuse from cache (~0.001s)
71
+ """
72
+ # Check if already cached
73
+ if collection_name in self._bm25_cache:
74
+ return self._bm25_cache[collection_name]
75
+
76
+ try:
77
+ # Fetch ALL documents from collection (no query vector, get full corpus)
78
+ all_points = self.qdrant_client.scroll(
79
+ collection_name=collection_name,
80
+ limit=10000, # Batch size
81
+ )
82
+
83
+ points_list = all_points[0] if isinstance(all_points, tuple) else all_points
84
+
85
+ if not points_list:
86
+ logger.warning("No documents found in collection=%s for BM25 indexing", collection_name)
87
+ return None
88
+
89
+ # Extract documents and tokenize for BM25
90
+ docs_for_bm25 = []
91
+ for point in points_list:
92
+ payload = point.payload if isinstance(point.payload, dict) else {}
93
+ content = str(payload.get("content") or "").strip()
94
+ if content:
95
+ docs_for_bm25.append(content)
96
+
97
+ if not docs_for_bm25:
98
+ logger.warning("No valid content found in collection=%s for BM25 indexing", collection_name)
99
+ return None
100
+
101
+ # Build BM25 index
102
+ tokenized_docs = [doc.lower().split() for doc in docs_for_bm25]
103
+ bm25 = BM25Okapi(tokenized_docs, k1=1.5, b=0.5)
104
+
105
+ # Cache it
106
+ self._bm25_cache[collection_name] = bm25
107
+ logger.info("BM25 index built and cached for collection=%s (docs=%d)", collection_name, len(docs_for_bm25))
108
+
109
+ return bm25
110
+
111
+ except Exception:
112
+ logger.exception("Failed to build BM25 index for collection=%s", collection_name)
113
+ return None
114
+
115
+ def _search_target_collections(self, query: str, collections: List[str], limit: int, alpha: float = 0.6) -> List:
116
+ """Hybrid search: BM25 + Vector + RRF (Option 2 with cached BM25)"""
117
  if not collections:
118
  return []
119
 
 
123
  logger.exception("Failed to embed query for collection routing")
124
  return []
125
 
126
+ # Step 1: Vector search (từ Qdrant)
127
+ all_docs_dict = {} # {doc_key -> LangChainDocument}
128
+ vector_ranked = {} # {doc_key -> rank}
129
+
130
+ vector_rank = 0
131
  for collection_name in collections:
132
  try:
133
  points = self.qdrant_client.search(
 
157
  "chunk_index": payload.get("chunk_index"),
158
  "page_number": payload.get("page_number"),
159
  }
160
+ doc = LangChainDocument(page_content=content, metadata=metadata)
161
+ doc_key = self._doc_key(doc)
162
+
163
+ all_docs_dict[doc_key] = doc
164
+ if doc_key not in vector_ranked:
165
+ vector_rank += 1
166
+ vector_ranked[doc_key] = vector_rank
167
+
168
+ # Step 2: BM25 search (lexical) - using CACHED index
169
+ bm25_ranked = {} # {doc_key -> rank}
170
+ if all_docs_dict:
171
+ try:
172
+ tokenized_query = query.lower().split()
173
+
174
+ # For each collection, use cached BM25 index
175
+ for collection_name in collections:
176
+ # Load cached BM25 (or build if first time)
177
+ bm25 = self._ensure_bm25_loaded(collection_name)
178
+ if bm25 is None:
179
+ continue
180
+
181
+ # Get BM25 scores for vector results
182
+ docs_from_collection = [
183
+ doc for doc in all_docs_dict.values()
184
+ if doc.metadata.get("collection_name") == collection_name
185
+ ]
186
+
187
+ if not docs_from_collection:
188
+ continue
189
+
190
+ # Get BM25 ranks
191
+ bm25_results = bm25.get_top_n(tokenized_query, docs_from_collection, n=len(docs_from_collection))
192
+
193
+ bm25_rank = 0
194
+ for doc in bm25_results:
195
+ doc_key = self._doc_key(doc)
196
+ if doc_key not in bm25_ranked:
197
+ bm25_rank += 1
198
+ bm25_ranked[doc_key] = bm25_rank
199
+
200
+ except Exception:
201
+ logger.exception("BM25 search failed, falling back to vector-only")
202
 
203
+ # Step 3: RRF combination (Reciprocal Rank Fusion)
204
+ alpha = max(0.0, min(1.0, float(alpha)))
205
+ bm25_weight = 1.0 - alpha
206
+ vector_weight = alpha
207
+ rrf_c = 60
208
+
209
+ rrf_scores = {}
210
+ for doc_key, doc in all_docs_dict.items():
211
+ score = 0.0
212
+
213
+ # Vector score
214
+ if doc_key in vector_ranked:
215
+ score += vector_weight / (rrf_c + vector_ranked[doc_key])
216
+
217
+ # BM25 score
218
+ if doc_key in bm25_ranked:
219
+ score += bm25_weight / (rrf_c + bm25_ranked[doc_key])
220
+
221
+ if score > 0:
222
+ rrf_scores[doc_key] = score
223
+
224
+ # Sort by RRF score
225
+ sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
226
+ return [all_docs_dict[doc_key] for doc_key, _ in sorted_results[:limit]]
227
 
228
  def search(self, query: str, k: int = 10, alpha: float = 0.6, cohort_key: str | None = None) -> List:
229
  if k <= 0:
 
240
  query=query,
241
  collections=target_collections,
242
  limit=candidate_k,
243
+ alpha=alpha,
244
  )
245
 
246
  if cohort_scoped:
core/config.py CHANGED
@@ -39,8 +39,8 @@ GEMINI_API_KEYS = os.getenv('GEMINI_API_KEYS', '').strip()
39
  # Name models
40
  LLM_MODEL = os.getenv('LLM_MODEL', 'llama-3.1-70b-versatile')
41
  FAST_LLM_MODEL = os.getenv('FAST_LLM_MODEL', 'llama-3.1-8b-instant')
42
- EMBED_MODEL = os.getenv('EMBED_MODEL', 'BAAI/bge-m3')
43
- CROSS_ENCODER_MODEL = os.getenv('CROSS_ENCODER_MODEL', 'BAAI/bge-reranker-v2-m3')
44
 
45
  # Chunking and retrieval settings
46
  CHUNK_SIZE = int(os.getenv('CHUNK_SIZE', '800'))
 
39
  # Name models
40
  LLM_MODEL = os.getenv('LLM_MODEL', 'llama-3.1-70b-versatile')
41
  FAST_LLM_MODEL = os.getenv('FAST_LLM_MODEL', 'llama-3.1-8b-instant')
42
+ EMBED_MODEL = os.getenv('EMBED_MODEL', 'bkai-foundation-models/vietnamese-bi-encoder')
43
+ CROSS_ENCODER_MODEL = os.getenv('CROSS_ENCODER_MODEL', 'itdainb/PhoRanker')
44
 
45
  # Chunking and retrieval settings
46
  CHUNK_SIZE = int(os.getenv('CHUNK_SIZE', '800'))
core/qa_pipeline.py CHANGED
@@ -4,6 +4,8 @@ import logging
4
  import groq
5
  import google.generativeai as genai
6
  import json
 
 
7
 
8
  from .models import llm
9
  from .config import TOP_K_RESULTS, FINAL_TOP_K
@@ -239,6 +241,53 @@ def ask_ai_stream_delta(message: str, history: List, hybrid_retriever, cohort_ke
239
  yield "Chào bạn 👋 Mình hỗ trợ tra cứu quy chế đào tạo. Bạn cần hỏi điều gì?"
240
  return
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  logger.info(f" CÂU HỎI GỐC: {message}")
243
  question = generate_standalone_query(message, history)
244
 
@@ -255,23 +304,33 @@ def ask_ai_stream_delta(message: str, history: List, hybrid_retriever, cohort_ke
255
 
256
  all_docs: List = []
257
  seen = set()
 
258
  if cohort_key:
259
  logger.info(f"Sử dụng cohort_key: {cohort_key}")
260
 
261
- for query in queries:
262
- #Giữ nguyên logic alpha ngành CNTT của Minh
263
  current_alpha = 0.4 if "CNTT" in query.upper() else 0.5
264
- docs = hybrid_retriever.search(
265
  query,
266
  k=TOP_K_RESULTS,
267
  alpha=current_alpha,
268
  cohort_key=cohort_key,
269
  )
270
- for doc in docs:
271
- content_hash = hashlib.sha256(doc.page_content.encode("utf-8")).hexdigest()
272
- if content_hash not in seen:
273
- all_docs.append(doc)
274
- seen.add(content_hash)
 
 
 
 
 
 
 
 
 
275
 
276
  logger.info(f"Tìm thấy tổng {len(all_docs)} documents.")
277
  if not all_docs:
@@ -300,10 +359,12 @@ def ask_ai_stream_delta(message: str, history: List, hybrid_retriever, cohort_ke
300
  logger.info("Đang tạo câu trả lời cuối cùng ...")
301
 
302
  success = False
303
- # Thử với Groq
304
- for _ in range(len(api_manager.groq_keys)):
305
  try:
306
  client = api_manager.get_groq_client()
 
 
307
  stream = client.chat.completions.create(
308
  model="llama-3.3-70b-versatile",
309
  messages=[{"role": "user", "content": prompt}],
@@ -316,16 +377,16 @@ def ask_ai_stream_delta(message: str, history: List, hybrid_retriever, cohort_ke
316
  success = True
317
  break
318
  except Exception as e:
319
- if "429" in str(e): # Lỗi Rate Limit
320
  api_manager.rotate_groq()
321
  continue
322
  logger.error(f"Lỗi Groq: {e}")
323
  break
324
-
325
- # Dự phòng sang Gemini (nếu Groq lỗi hoặc hết key)
326
  if not success:
327
  logger.warning("Chuyển sang Gemini ...")
328
- for _ in range(max(1, len(api_manager.gemini_keys))):
329
  try:
330
  genai.configure(api_key=api_manager.get_gemini_key())
331
  model = genai.GenerativeModel('gemini-2.5-flash')
@@ -338,6 +399,6 @@ def ask_ai_stream_delta(message: str, history: List, hybrid_retriever, cohort_ke
338
  except Exception as e:
339
  api_manager.rotate_gemini()
340
  logger.error(f"Lỗi Gemini: {e}")
341
-
342
  if not success:
343
  yield "Đã xảy ra lỗi hệ thống hoặc quá tải. Vui lòng thử lại sau giây lát!"
 
4
  import groq
5
  import google.generativeai as genai
6
  import json
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from threading import Lock
9
 
10
  from .models import llm
11
  from .config import TOP_K_RESULTS, FINAL_TOP_K
 
241
  yield "Chào bạn 👋 Mình hỗ trợ tra cứu quy chế đào tạo. Bạn cần hỏi điều gì?"
242
  return
243
 
244
+ # [SKIP LLM] Nếu đây là câu hỏi đầu tiên (history trống), bỏ qua LLM, chỉ trả về các tài liệu liên quan
245
+ if not history or len(history) == 0:
246
+ logger.info(f"[FIRST TURN] CÂU HỎI GỐC: {message}")
247
+ question = message.strip()
248
+ processed_data = analyze_and_expand_query(question)
249
+ queries = processed_data.get('expanded_queries', [question])
250
+
251
+ # Chỉ tìm kiếm docs, không gọi LLM
252
+ all_docs: List = []
253
+ seen = set()
254
+ seen_lock = Lock()
255
+
256
+ def search_query(query: str):
257
+ current_alpha = 0.4 if "CNTT" in query.upper() else 0.5
258
+ return hybrid_retriever.search(
259
+ query,
260
+ k=TOP_K_RESULTS,
261
+ alpha=current_alpha,
262
+ cohort_key=cohort_key,
263
+ )
264
+
265
+ with ThreadPoolExecutor(max_workers=min(3, len(queries))) as executor:
266
+ futures = {executor.submit(search_query, q): q for q in queries}
267
+ for future in futures:
268
+ try:
269
+ docs = future.result(timeout=30)
270
+ for doc in docs:
271
+ content_hash = hashlib.sha256(doc.page_content.encode("utf-8")).hexdigest()
272
+ with seen_lock:
273
+ if content_hash not in seen:
274
+ seen.add(content_hash)
275
+ all_docs.append(doc)
276
+ except Exception:
277
+ logger.exception("Search error")
278
+
279
+ # Format documents thành văn bản trả về
280
+ if all_docs:
281
+ result_text = "📚 **Các tài liệu liên quan:**\n\n"
282
+ for i, doc in enumerate(all_docs[:FINAL_TOP_K], 1):
283
+ source = doc.metadata.get("source") or "Không rõ"
284
+ content_preview = doc.page_content[:300] + ("..." if len(doc.page_content) > 300 else "")
285
+ result_text += f"{i}. **Nguồn:** {source}\n{content_preview}\n\n"
286
+ yield result_text + "\n💡 *Hãy đặt câu hỏi cụ thể hơn để được hỗ trợ tốt hơn!*"
287
+ else:
288
+ yield "❌ Không tìm thấy tài liệu liên quan. Vui lòng hãy đặt câu hỏi cụ thể hơn!"
289
+ return
290
+
291
  logger.info(f" CÂU HỎI GỐC: {message}")
292
  question = generate_standalone_query(message, history)
293
 
 
304
 
305
  all_docs: List = []
306
  seen = set()
307
+ seen_lock = Lock()
308
  if cohort_key:
309
  logger.info(f"Sử dụng cohort_key: {cohort_key}")
310
 
311
+ # Gửi song song các truy vấn đến Qdrant
312
+ def search_query(query: str):
313
  current_alpha = 0.4 if "CNTT" in query.upper() else 0.5
314
+ return hybrid_retriever.search(
315
  query,
316
  k=TOP_K_RESULTS,
317
  alpha=current_alpha,
318
  cohort_key=cohort_key,
319
  )
320
+
321
+ with ThreadPoolExecutor(max_workers=min(3, len(queries))) as executor:
322
+ futures = {executor.submit(search_query, q): q for q in queries}
323
+ for future in futures:
324
+ try:
325
+ docs = future.result(timeout=30)
326
+ for doc in docs:
327
+ content_hash = hashlib.sha256(doc.page_content.encode("utf-8")).hexdigest()
328
+ with seen_lock:
329
+ if content_hash not in seen:
330
+ all_docs.append(doc)
331
+ seen.add(content_hash)
332
+ except Exception as e:
333
+ logger.error(f"Lỗi khi search query '{futures[future]}': {e}")
334
 
335
  logger.info(f"Tìm thấy tổng {len(all_docs)} documents.")
336
  if not all_docs:
 
359
  logger.info("Đang tạo câu trả lời cuối cùng ...")
360
 
361
  success = False
362
+ # Ưu tiên Groq (tiết kiệm token)
363
+ for _ in range(len(api_manager.groq_keys) if api_manager.groq_keys else 1):
364
  try:
365
  client = api_manager.get_groq_client()
366
+ if not client:
367
+ break
368
  stream = client.chat.completions.create(
369
  model="llama-3.3-70b-versatile",
370
  messages=[{"role": "user", "content": prompt}],
 
377
  success = True
378
  break
379
  except Exception as e:
380
+ if "429" in str(e): # Rate Limit
381
  api_manager.rotate_groq()
382
  continue
383
  logger.error(f"Lỗi Groq: {e}")
384
  break
385
+
386
+ # Fallback sang Gemini nếu Groq lỗi
387
  if not success:
388
  logger.warning("Chuyển sang Gemini ...")
389
+ for _ in range(len(api_manager.gemini_keys) if api_manager.gemini_keys else 1):
390
  try:
391
  genai.configure(api_key=api_manager.get_gemini_key())
392
  model = genai.GenerativeModel('gemini-2.5-flash')
 
399
  except Exception as e:
400
  api_manager.rotate_gemini()
401
  logger.error(f"Lỗi Gemini: {e}")
402
+
403
  if not success:
404
  yield "Đã xảy ra lỗi hệ thống hoặc quá tải. Vui lòng thử lại sau giây lát!"
core/rerank.py CHANGED
@@ -1,15 +1,21 @@
1
  from typing import List
2
  from .models import cross_encoder
3
-
4
- MAX_RERANK_CHARS = 1200
 
5
 
6
  def advanced_rerank(question: str, docs: List, top_k: int = 5) -> List:
7
  if not docs:
8
  return []
9
- print(f"Đang rerank {len(docs)} documents với Cross-Encoder...")
10
- pairs = [(question, (doc.page_content or "")[:MAX_RERANK_CHARS]) for doc in docs]
11
- scores = cross_encoder.predict(pairs)
12
- ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
13
- print(f" Top 3 scores: {[f'{s:.3f}' for s, _ in ranked[:3]]}")
 
 
 
 
 
14
  return [doc for score, doc in ranked[:top_k]]
15
 
 
1
  from typing import List
2
  from .models import cross_encoder
3
+ import logging
4
+ logger = logging.getLogger(__name__)
5
+ MAX_RERANK_CHARS = 800
6
 
7
  def advanced_rerank(question: str, docs: List, top_k: int = 5) -> List:
8
  if not docs:
9
  return []
10
+ MAX_DOCS_TO_RERANK = 15
11
+ pruned_docs = docs[:MAX_DOCS_TO_RERANK]
12
+
13
+ logger.info("Đang rerank %s tài liệu với Cross-Encoder...", len(pruned_docs))
14
+ pairs = [(question, (doc.page_content or "")[:MAX_RERANK_CHARS]) for doc in pruned_docs]
15
+
16
+ scores = cross_encoder.predict(pairs, show_progress_bar=False)
17
+ ranked = sorted(zip(scores, pruned_docs), key=lambda x: x[0], reverse=True)
18
+
19
+ logger.info("Top 3 điểm: %s", [f"{s:.3f}" for s, _ in ranked[:3]])
20
  return [doc for score, doc in ranked[:top_k]]
21
 
main.py CHANGED
@@ -348,6 +348,10 @@ async def chat_endpoint(payload: ChatRequest, request: Request):
348
 
349
  history = await get_history_async(db_pool, session_id)
350
 
 
 
 
 
351
  # Tập hợp toàn bộ response từ generator
352
  full_response = ""
353
  try:
@@ -380,6 +384,10 @@ async def chat_stream_endpoint(payload: ChatRequest, request: Request):
380
 
381
  history = await get_history_async(db_pool, session_id)
382
 
 
 
 
 
383
  async def event_stream_generator():
384
  """Generator SSE - yield mỗi delta chunk và cuối cùng done=true"""
385
  full_response = ""
 
348
 
349
  history = await get_history_async(db_pool, session_id)
350
 
351
+ # Nếu lịch sử < 2 messages, bỏ qua (không dùng context)
352
+ if len(history) < 2:
353
+ history = []
354
+
355
  # Tập hợp toàn bộ response từ generator
356
  full_response = ""
357
  try:
 
384
 
385
  history = await get_history_async(db_pool, session_id)
386
 
387
+ # Nếu lịch sử < 2 messages, bỏ qua (không dùng context)
388
+ if len(history) < 2:
389
+ history = []
390
+
391
  async def event_stream_generator():
392
  """Generator SSE - yield mỗi delta chunk và cuối cùng done=true"""
393
  full_response = ""