Spaces:
Sleeping
Sleeping
File size: 21,044 Bytes
7337389 a0739e8 7337389 b8740b1 7337389 a0739e8 7337389 15102c9 7337389 15102c9 7337389 15102c9 7337389 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 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 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 | # -*- coding: utf-8 -*-
"""
Lõi của demo NamBoQA: truy hồi + đọc hiểu, bám sát thực nghiệm trong bài.
Truy hồi BM25 (pyvi) · Bi-encoder · Hybrid (alpha * norm(BM25) + (1-alpha) * norm(dense))
Đọc hiểu Extractive (XLM-R SQuAD2, ngưỡng từ chối) · Generative (Gemini, có grounding)
Code tokenize, chuẩn hoá điểm, prompt Gemini, nhận diện câu từ chối được lấy
nguyên từ nambo_run/code/run_experiments.py và run_reading.py (bộ thực nghiệm
25/10/2026, dữ liệu vòng 3) để demo cho ra đúng hành vi đã báo cáo trong bài.
Cách chia train/dev/test dùng chung chia_tap.py: theo địa điểm, phân tầng theo tỉnh.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
import unicodedata
from pathlib import Path
import numpy as np
log = logging.getLogger("namboqa")
NO_ANSWER = "Tài liệu không có thông tin để trả lời."
# chỉ dùng để chọn từ tô sáng trên giao diện, không ảnh hưởng truy hồi
STOPWORDS = {"người", "sau", "khi", "nơi", "được", "của", "những", "các", "này", "đã", "và",
"là", "có", "cho", "với", "trong", "vào", "nào", "gì", "đến", "năm", "bao", "nhiêu",
"đâu", "không", "từ", "theo", "tại", "một", "như", "thì", "mà", "nên", "hay", "hoặc",
"đó", "ở", "để", "về", "ra", "lại", "còn", "rằng", "thời", "gian", "bởi", "do"}
DATA_FILES = ("passages.jsonl", "places_clean.json", "qa_final.jsonl")
# ------------------------------------------------------------------ tiện ích
def load_jsonl(p: Path):
with open(p, encoding="utf-8") as f:
return [json.loads(l) for l in f if l.strip()]
def vi_tokens(s: str):
from pyvi import ViTokenizer
s = unicodedata.normalize("NFC", str(s).lower())
s = re.sub(r"[^\w\s]", " ", s)
return ViTokenizer.tokenize(s).split()
def norm(x):
x = np.asarray(x, dtype=float)
return (x - x.min()) / (x.max() - x.min() + 1e-9)
REFUSAL_RE = re.compile(
r"(không|chưa)\s+(\w+\s+){0,2}"
r"(có|nêu|đề\s*cập|nhắc|nói|cung\s*cấp|chứa|tìm\s*thấy|xác\s*định|ghi|thể)"
r"|no[_\s]?answer", re.I)
def is_refusal(pred: str) -> bool:
p = (pred or "").strip().lower()
return (not p) or bool(REFUSAL_RE.search(p))
PROMPT_GEN = """Bạn trả lời câu hỏi CHỈ dựa vào tài liệu dưới đây, không dùng kiến thức bên ngoài.
Tài liệu:
<<<
{context}
>>>
Câu hỏi: {question}
Nếu tài liệu không chứa thông tin để trả lời, hãy trả lời đúng một câu: "{no_answer}"
Nếu có, trả lời NGẮN GỌN (tên riêng, năm, con số, cụm từ), không giải thích thêm.
Đáp án:"""
def cpu_threads() -> int:
"""Số luồng CPU thật sự được cấp. os.cpu_count() trong container HF trả về số nhân
của cả máy chủ (vài chục) trong khi Space CPU basic chỉ có 2 vCPU: đặt theo đó làm
torch tranh chấp luồng, mỗi câu hỏi chậm 5–10 giây. Đọc hạn mức cgroup thay vào."""
if os.environ.get("TORCH_NUM_THREADS"):
return max(1, int(os.environ["TORCH_NUM_THREADS"]))
try:
quota, period = open("/sys/fs/cgroup/cpu.max").read().split()[:2]
if quota != "max":
return max(1, int(int(quota) / int(period)))
except Exception:
pass
try:
return max(1, len(os.sched_getaffinity(0)))
except Exception:
return max(1, os.cpu_count() or 1)
# ------------------------------------------------------------------ dữ liệu
def ensure_data(data_dir: Path):
"""Dữ liệu KHÔNG nằm trong repo Space (passages.jsonl không được phát hành
nguyên văn). Nếu thiếu thì tải từ một HF Dataset private bằng HF_TOKEN."""
missing = [f for f in DATA_FILES if not (data_dir / f).exists()]
if not missing:
return
repo = os.environ.get("DATA_REPO")
if not repo:
raise RuntimeError(f"Thiếu {missing} trong {data_dir} và chưa đặt DATA_REPO")
from huggingface_hub import snapshot_download
log.info(f"Tải dữ liệu từ dataset private {repo} ...")
snapshot_download(repo_id=repo, repo_type="dataset", local_dir=str(data_dir),
token=os.environ.get("HF_TOKEN") or None)
class Engine:
def __init__(self, data_dir: str | Path):
self.data_dir = Path(data_dir)
self.retriever_name = os.environ.get(
"RETRIEVER_MODEL", "bkai-foundation-models/vietnamese-bi-encoder")
self.reader_name = os.environ.get("READER_MODEL", "deepset/xlm-roberta-base-squad2")
self.gemini_model = os.environ.get("GEMINI_MODEL", "gemini-3.5-flash")
self.alpha = float(os.environ.get("HYBRID_ALPHA", "0.5")) # chọn trên dev trong bài
self.reject_th = float(os.environ.get("REJECT_THRESHOLD", "0.0")) # chọn trên dev trong bài
self.max_ctx = int(os.environ.get("MAX_CONTEXT_CHARS", "3000"))
# Số đoạn truy hồi ghép làm ngữ cảnh cho từng bộ đọc. Bài (Bảng 11): bộ trích
# xuất đạt F1 cao nhất với 1–3 đoạn, ghép 5 đoạn thì F1 và tỉ lệ từ chối đúng
# đều giảm; mô hình sinh chịu nhiễu tốt nên giữ 5 như thực nghiệm chính.
self.ctx_k = {"extractive": int(os.environ.get("EXTRACTIVE_CONTEXT_K", "3")),
"generative": int(os.environ.get("GENERATIVE_CONTEXT_K", "5"))}
self.status = {"data": "pending", "bm25": "pending", "dense": "pending",
"extractive": "pending", "generative": "pending"}
self.errors: dict[str, str] = {}
self._lock = threading.Lock()
# ------------------------------------------------------------- khởi động
def load_fast(self):
"""Phần nhẹ, chạy đồng bộ: dữ liệu + BM25 (~5 giây)."""
ensure_data(self.data_dir)
d = self.data_dir
self.passages = load_jsonl(d / "passages.jsonl")
self.places = {p["place_id"]: p for p in
json.loads((d / "places_clean.json").read_text(encoding="utf-8"))}
self.qa = load_jsonl(d / "qa_final.jsonl")
self.place_split = self._split()
self.ids = [p["id"] for p in self.passages]
self.txts = [p["text"] for p in self.passages]
self.pid2idx = {pid: i for i, pid in enumerate(self.ids)}
self.q_index = {self._qkey(q["question"]): q for q in self.qa}
self.status["data"] = "ready"
from rank_bm25 import BM25Okapi
t0 = time.time()
self.bm25 = BM25Okapi([vi_tokens(t) for t in self.txts])
self.status["bm25"] = "ready"
log.info(f"BM25 xong: {len(self.txts)} passage, {time.time() - t0:.1f}s")
has_key = bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
self.status["generative"] = "ready" if has_key else "disabled"
def _split(self):
"""Tái lập đúng lần chia trong bài: chia_tap.chia_theo_tinh, seed 42 (1.416 / 321 / 708 câu)."""
import chia_tap
pids = {p["id"] for p in self.passages}
qa = [{**q, "gold": [g for g in q["gold_passage_ids"] if g in pids]} for q in self.qa]
qa = [q for q in qa if q["gold"]]
prov = {pid: p.get("province") or "?" for pid, p in self.places.items()}
_, _, _, tk = chia_tap.chia_theo_tinh(qa, prov)
out = {}
for name in ("train", "dev", "test"):
for pid in tk[f"{name}_p"]:
out[pid] = name
return out
def _sources(self, p):
"""Nguồn tư liệu để người xem tự kiểm: link cổng du lịch (theo đoạn) hoặc bài Wikipedia (theo địa điểm)."""
m = p.get("meta") or {}
out = []
for u in (m.get("portal_urls") or ([m["source_url"]] if m.get("source_url") else []))[:2]:
out.append({"label": u.split("/")[2] if "//" in u else u, "url": u})
for t in (m.get("wiki_titles") or [])[:2]:
out.append({"label": f"Wikipedia: {t}",
"url": "https://vi.wikipedia.org/wiki/" + t.replace(" ", "_")})
return out
def load_heavy(self):
"""Phần nặng, chạy nền: bi-encoder + mô hình extractive."""
self._load_dense()
if os.environ.get("DISABLE_EXTRACTIVE", "0") == "1":
self.status["extractive"] = "disabled"
else:
self._load_extractive()
def _load_dense(self):
try:
self.status["dense"] = "loading"
import torch
from sentence_transformers import SentenceTransformer
torch.set_num_threads(cpu_threads())
self.encoder = SentenceTransformer(self.retriever_name, device="cpu")
safe = re.sub(r"[^\w.-]", "_", self.retriever_name)
cache = self.data_dir / f"emb_{safe}.npy"
if cache.exists():
emb = np.load(cache)
if emb.shape[0] != len(self.txts):
emb = None
else:
emb = None
if emb is None:
t0 = time.time()
log.info("Mã hoá corpus bằng bi-encoder (lần đầu, vài phút trên CPU)...")
emb = self.encoder.encode(self.txts, batch_size=32, normalize_embeddings=True,
convert_to_numpy=True, show_progress_bar=False)
try:
np.save(cache, emb.astype(np.float32))
except Exception:
pass
log.info(f"Mã hoá xong trong {time.time() - t0:.0f}s")
self.emb = emb.astype(np.float32)
self.status["dense"] = "ready"
except Exception as e:
log.exception("không nạp được bi-encoder")
self.status["dense"] = "error"
self.errors["dense"] = str(e)[:300]
def _load_extractive(self):
try:
self.status["extractive"] = "loading"
import torch
from transformers import AutoModelForQuestionAnswering, AutoTokenizer
self.qa_tok = AutoTokenizer.from_pretrained(self.reader_name)
self.qa_model = AutoModelForQuestionAnswering.from_pretrained(self.reader_name).eval()
self._torch = torch
self.status["extractive"] = "ready"
log.info(f"Mô hình extractive: {self.reader_name}")
except Exception as e:
log.exception("không nạp được mô hình extractive")
self.status["extractive"] = "error"
self.errors["extractive"] = str(e)[:300]
# ------------------------------------------------------------- truy hồi
@staticmethod
def _qkey(q: str) -> str:
return re.sub(r"\s+", " ", unicodedata.normalize("NFC", q or "").strip().lower())
def retrieve(self, question: str, method: str = "hybrid", k: int = 5, alpha: float | None = None):
"""Trả về (danh sách (idx, điểm), phương án thực sự dùng)."""
alpha = self.alpha if alpha is None else alpha
if method in ("dense", "hybrid") and self.status["dense"] != "ready":
method = "bm25" # bi-encoder chưa nạp xong -> lùi về BM25
bm = self.bm25.get_scores(vi_tokens(question)) if method != "dense" else None
if method != "bm25":
qv = self.encoder.encode([question], normalize_embeddings=True, convert_to_numpy=True)[0]
dn = self.emb @ qv.astype(np.float32)
if method == "bm25":
s = bm
elif method == "dense":
s = dn
else:
s = alpha * norm(bm) + (1 - alpha) * norm(dn)
top = np.argsort(-s)[:k]
return [(int(i), float(s[i])) for i in top], method
# ------------------------------------------------------------- đọc hiểu
def extract(self, question: str, context: str, max_len=384, stride=128, max_answer_len=40):
"""Rút span kiểu SQuAD v2 (so với xác suất token CLS). Giống make_extractor
trong run_reading.py nhưng vector hoá phép tìm cặp (start, end) cho nhanh trên CPU."""
torch = self._torch
tok = self.qa_tok
enc = tok(question, context, truncation="only_second", max_length=max_len, stride=stride,
return_overflowing_tokens=True, return_offsets_mapping=True,
padding="max_length", return_tensors="pt")
offsets = enc.pop("offset_mapping")
enc.pop("overflow_to_sample_mapping", None)
dev = next(self.qa_model.parameters()).device # CPU trên Space, GPU khi đánh giá
with torch.no_grad():
out = self.qa_model(**{k: v.to(dev) for k, v in enc.items()})
best = {"answer": "", "score": 0.0, "start": -1, "end": -1}
for w in range(out.start_logits.size(0)):
ps = torch.softmax(out.start_logits[w], -1).float().cpu().numpy()
pe = torch.softmax(out.end_logits[w], -1).float().cpu().numpy()
null = float(ps[0] * pe[0])
sid = enc.sequence_ids(w)
valid = np.array([i for i in range(len(sid)) if sid[i] == 1])
if not len(valid):
continue
lo, hi = valid[0], valid[-1]
m = np.outer(ps[lo:hi + 1], pe[lo:hi + 1])
m = np.triu(m) - np.triu(m, max_answer_len) # j >= i và j < i + max_answer_len
i, j = np.unravel_index(np.argmax(m), m.shape)
sc = float(m[i, j])
if sc > best["score"] and sc > null:
a, b = offsets[w][lo + i][0].item(), offsets[w][lo + j][1].item()
best = {"answer": context[a:b], "score": sc, "start": a, "end": b}
return best
def generate(self, question: str, context: str) -> str:
from google import genai
from google.genai import types
client = genai.Client()
r = client.models.generate_content(
model=self.gemini_model,
contents=PROMPT_GEN.format(context=context, question=question, no_answer=NO_ANSWER),
config=types.GenerateContentConfig(temperature=0.0, max_output_tokens=2048),
)
txt = ""
try:
for part in r.candidates[0].content.parts:
if getattr(part, "thought", False):
continue
txt += getattr(part, "text", "") or ""
except Exception:
txt = r.text or ""
txt = (txt or "").strip()
txt = re.sub(r"^(đáp án|answer)\s*[::]\s*", "", txt, flags=re.I)
return txt.split("\n")[0].strip().strip('"').rstrip(".")
# ------------------------------------------------------------- pipeline
def ask(self, question: str, method="hybrid", reader="generative", k=5, alpha=None):
t0 = time.time()
hits, used = self.retrieve(question, method, k, alpha)
t_ret = time.time() - t0
# ghép ngữ cảnh đúng như thực nghiệm: nối top-k rồi cắt max_ctx ký tự
n_ctx = min(len(hits), self.ctx_k.get(reader, len(hits)))
bounds, parts, pos = [], [], 0
for idx, _ in hits[:n_ctx]:
t = self.txts[idx]
bounds.append((pos, pos + len(t), idx))
parts.append(t)
pos += len(t) + 1
context = " ".join(parts)[: self.max_ctx]
answer, score, src, span = None, None, None, None
reader_used = reader
t1 = time.time()
if reader == "extractive":
if self.status["extractive"] != "ready":
raise RuntimeError(f"Mô hình extractive chưa sẵn sàng ({self.status['extractive']})")
r = self.extract(question, context)
score = r["score"]
if r["answer"] and score >= self.reject_th:
answer = r["answer"].strip()
for a, b, idx in bounds:
if a <= r["start"] < b:
src, span = self.ids[idx], [r["start"] - a, r["end"] - a]
break
else:
answer = NO_ANSWER
elif reader == "generative":
if self.status["generative"] != "ready":
raise RuntimeError("Chưa cấu hình GEMINI_API_KEY trên back-end")
try:
answer = self.generate(question, context)
except Exception as e:
raise RuntimeError(f"Gemini không trả lời được (kiểm tra GEMINI_API_KEY, quota, "
f"tên mô hình GEMINI_MODEL): {str(e)[:160]}")
if not is_refusal(answer):
for _, _, idx in bounds: # tìm đoạn chứa nguyên văn đáp án để tô sáng
p = self.txts[idx].lower().find(answer.lower())
if p >= 0 and len(answer) >= 2:
src, span = self.ids[idx], [p, p + len(answer)]
break
else:
reader_used = "none"
t_read = time.time() - t1
qwords = sorted({w.replace("_", " ") for w in vi_tokens(question)
if len(w) > 2 and w not in STOPWORDS},
key=len, reverse=True)
max_chars = int(os.environ.get("MAX_PASSAGE_CHARS", "0"))
passages = []
for rank, (idx, sc) in enumerate(hits, 1):
p = self.passages[idx]
pl = self.places.get(p["place_id"], {})
text = p["text"] if not max_chars else p["text"][:max_chars]
passages.append({
"rank": rank, "id": p["id"], "place_id": p["place_id"],
"title": p.get("title") or pl.get("name"), "province": pl.get("province"),
"heritage_level": pl.get("heritage_level"), "score": round(sc, 4),
"text": text, "sources": self._sources(p),
"in_context": reader != "none" and rank <= n_ctx and bounds[rank - 1][0] < self.max_ctx,
})
gold = None
q = self.q_index.get(self._qkey(question))
if q:
gold = {"id": q["id"], "type": q["type"], "answer": q["answer"],
"gold_passage_ids": q["gold_passage_ids"],
"split": self.place_split.get(q["place_id"], "?"),
"round": "vòng 3" if q.get("vong_duyet") == 3 else "vòng 1–2",
"evaluated": q["type"] != "compare"}
return {
"question": question, "answer": answer,
"refused": bool(answer is not None and is_refusal(answer)),
"reader": reader_used, "reader_model": (self.reader_name if reader_used == "extractive"
else self.gemini_model if reader_used == "generative"
else None),
"score": score, "source_passage_id": src, "answer_span": span,
"retriever": used, "retriever_requested": method,
"alpha": (self.alpha if alpha is None else alpha) if used == "hybrid" else None,
"context_k": n_ctx if reader_used != "none" else 0,
"passages": passages, "query_terms": qwords, "gold": gold,
"timing_ms": {"retrieval": round(t_ret * 1000), "reading": round(t_read * 1000)},
}
# ------------------------------------------------------------- tiện ích UI
def samples(self, n=6, qtype=None, split="test", seed=None):
import random
# mặc định bỏ "compare": bài không chấm loại này (13 câu test, quá ít)
pool = [q for q in self.qa
if (q["type"] == qtype if qtype else q["type"] != "compare")
and (split == "all" or self.place_split.get(q["place_id"]) == split)]
rnd = random.Random(seed)
pick = rnd.sample(pool, min(n, len(pool)))
return [{"id": q["id"], "type": q["type"], "question": q["question"],
"round": "vòng 3" if q.get("vong_duyet") == 3 else "vòng 1–2",
"place": self.places.get(q["place_id"], {}).get("name"),
"province": self.places.get(q["place_id"], {}).get("province")} for q in pick]
def stats(self):
from collections import Counter
return {
"n_passages": len(self.passages), "n_places": len(self.places), "n_qa": len(self.qa),
"qa_by_type": dict(Counter(q["type"] for q in self.qa)),
"qa_by_split": dict(Counter(self.place_split.get(q["place_id"], "?") for q in self.qa)),
"places_by_province": dict(Counter(p.get("province") for p in self.places.values())),
"models": {"retriever": self.retriever_name, "reader": self.reader_name,
"generative": self.gemini_model, "alpha": self.alpha,
"context_k": self.ctx_k},
}
|