thaidinhz1 Claude Sonnet 4.6 commited on
Commit
af92b4d
·
1 Parent(s): 25622cc

feat: add ColPali visual retrieval + contextual retrieval + PDF eval

Browse files

- src/contextualizer.py: Contextual Retrieval with Gemini Flash, retry on rate limit
- ingest.py: --contextual flag for contextual ingest
- src/colpali_retriever.py: ColPali v1.2 visual retrieval with Qdrant MaxSim
- ingest_colpali.py: PDF pages -> images -> ColPali embeddings pipeline
- compare_retrieval.py: side-by-side comparison ColPali vs text RAG
- evaluate.py: accept golden set path as CLI arg
- data/golden_set_pdf.json: PDF financial report evaluation set
- eval_results.json: baseline results (Faithfulness 0.633 / Relevancy 0.767 / Recall 0.400)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.env.example CHANGED
@@ -2,5 +2,6 @@
2
  # File .env KHÔNG được commit lên Git
3
 
4
  GOOGLE_API_KEY=your_google_api_key_here
5
- # Hoặc nếu dùng OpenAI
6
- OPENAI_API_KEY=your_openai_api_key_here
 
 
2
  # File .env KHÔNG được commit lên Git
3
 
4
  GOOGLE_API_KEY=your_google_api_key_here
5
+ GROQ_API_KEY=your_groq_api_key_here
6
+ QDRANT_URL=https://xxx.us-east4.gcp.cloud.qdrant.io
7
+ QDRANT_API_KEY=your_qdrant_api_key_here
compare_retrieval.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ So sánh ColPali (visual) vs Text RAG (pypdf + Gemini embed) trên cùng câu hỏi.
3
+ Usage: python compare_retrieval.py
4
+ """
5
+ from dotenv import load_dotenv
6
+ load_dotenv()
7
+
8
+ TEST_QUESTIONS = [
9
+ "Doanh thu thuần của công ty trong quý 1 năm 2026 là bao nhiêu?",
10
+ "Lợi nhuận sau thuế quý 1 năm 2026?",
11
+ "Chi phí tài chính của FPT trong nửa đầu năm 2016?",
12
+ "Tổng tài sản của TIG tại cuối quý 1 năm 2026?",
13
+ "FPT hoạt động trong những lĩnh vực kinh doanh nào?",
14
+ ]
15
+
16
+
17
+ def run_text_retrieval(question: str, top_k: int = 3) -> list[dict]:
18
+ from src.rag import retrieve
19
+ from src.embedder import embed_query
20
+ contexts, sources = retrieve(question, top_k=top_k)
21
+ return sources
22
+
23
+
24
+ def run_colpali_retrieval(question: str, top_k: int = 3) -> list[dict]:
25
+ from src.colpali_retriever import query
26
+ return query(question, top_k=top_k)
27
+
28
+
29
+ def main():
30
+ print("=" * 60)
31
+ print("RETRIEVAL COMPARISON: ColPali vs Text RAG")
32
+ print("=" * 60)
33
+
34
+ results = []
35
+ for q in TEST_QUESTIONS:
36
+ print(f"\nQ: {q}")
37
+ print("-" * 50)
38
+
39
+ print("[Text RAG]")
40
+ try:
41
+ text_hits = run_text_retrieval(q, top_k=3)
42
+ for h in text_hits:
43
+ print(f" {h['source']} p.{h['page']} | rerank={h['rerank_score']:.3f}")
44
+ except Exception as e:
45
+ print(f" ERROR: {e}")
46
+ text_hits = []
47
+
48
+ print("[ColPali]")
49
+ try:
50
+ colpali_hits = run_colpali_retrieval(q, top_k=3)
51
+ for h in colpali_hits:
52
+ print(f" {h['source']} p.{h['page']} | score={h['score']:.3f}")
53
+ except Exception as e:
54
+ print(f" ERROR: {e}")
55
+ colpali_hits = []
56
+
57
+ results.append({
58
+ "question": q,
59
+ "text_rag": text_hits,
60
+ "colpali": colpali_hits,
61
+ })
62
+
63
+ import json
64
+ with open("comparison_results.json", "w", encoding="utf-8") as f:
65
+ json.dump(results, f, ensure_ascii=False, indent=2)
66
+ print("\n\nResults saved to comparison_results.json")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
data/golden_set_pdf.json ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "p001",
4
+ "type": "lookup",
5
+ "source": "000000016333831_CBTT_BCTC_Q1_2026_signed.pdf",
6
+ "question": "Doanh thu thuần của công ty trong quý 1 năm 2026 là bao nhiêu?",
7
+ "answer": "Doanh thu thuần quý 1/2026 được ghi nhận trong báo cáo kết quả hoạt động kinh doanh của công ty."
8
+ },
9
+ {
10
+ "id": "p002",
11
+ "type": "lookup",
12
+ "source": "000000016333831_CBTT_BCTC_Q1_2026_signed.pdf",
13
+ "question": "Lợi nhuận sau thuế của công ty quý 1 năm 2026 là bao nhiêu?",
14
+ "answer": "Lợi nhuận sau thuế quý 1/2026 được thể hiện trong báo cáo kết quả kinh doanh."
15
+ },
16
+ {
17
+ "id": "p003",
18
+ "type": "lookup",
19
+ "source": "FPT_16Q2_BCTC_MSX.pdf",
20
+ "question": "Doanh thu của FPT trong quý 2 năm 2016 là bao nhiêu?",
21
+ "answer": "Doanh thu FPT quý 2/2016 được ghi nhận trong báo cáo tài chính giữa niên độ."
22
+ },
23
+ {
24
+ "id": "p004",
25
+ "type": "lookup",
26
+ "source": "FPT_16Q2_BCTC_MSX.pdf",
27
+ "question": "Chi phí tài chính của FPT trong nửa đầu năm 2016 là bao nhiêu?",
28
+ "answer": "Chi phí tài chính được thể hiện trong báo cáo kết quả hoạt động kinh doanh của FPT."
29
+ },
30
+ {
31
+ "id": "p005",
32
+ "type": "lookup",
33
+ "source": "UPCOM_quy1.pdf",
34
+ "question": "Báo cáo UPCOM quý 1 trình bày thông tin về những công ty nào?",
35
+ "answer": "Báo cáo UPCOM quý 1 trình bày thông tin giao dịch và tài chính của các công ty niêm yết trên sàn UPCOM."
36
+ },
37
+ {
38
+ "id": "p006",
39
+ "type": "general",
40
+ "source": "FPT_16Q2_BCTC_MSX.pdf",
41
+ "question": "FPT hoạt động trong những lĩnh vực kinh doanh nào?",
42
+ "answer": "FPT hoạt động trong các lĩnh vực công nghệ thông tin, viễn thông, giáo dục và các dịch vụ liên quan."
43
+ },
44
+ {
45
+ "id": "p007",
46
+ "type": "general",
47
+ "source": "000000016402354_EN_Disclosure_Financial_Statements_Q1.2026_TIG.pdf",
48
+ "question": "Tổng tài sản của TIG tại thời điểm cuối quý 1 năm 2026 là bao nhiêu?",
49
+ "answer": "Tổng tài sản của TIG được ghi nhận trên bảng cân đối kế toán tại ngày 31/03/2026."
50
+ },
51
+ {
52
+ "id": "p008",
53
+ "type": "comparison",
54
+ "source": "FPT_16Q2_BCTC_MSX.pdf",
55
+ "question": "Doanh thu của FPT quý 2 năm 2016 so với cùng kỳ năm trước tăng hay giảm?",
56
+ "answer": "Sự biến động doanh thu được thể hiện qua cột so sánh cùng kỳ trong báo cáo tài chính."
57
+ },
58
+ {
59
+ "id": "p009",
60
+ "type": "lookup",
61
+ "source": "000000016402354_EN_Disclosure_Financial_Statements_Q1.2026_TIG.pdf",
62
+ "question": "Vốn chủ sở hữu của TIG tại quý 1 năm 2026 là bao nhiêu?",
63
+ "answer": "Vốn chủ sở hữu của TIG được trình bày trong phần nguồn vốn của bảng cân đối kế toán."
64
+ },
65
+ {
66
+ "id": "p010",
67
+ "type": "filter",
68
+ "source": "FPT_16Q2_BCTC_MSX.pdf",
69
+ "question": "Trong báo cáo FPT quý 2 năm 2016, khoản mục nào có giá trị lớn nhất trong tài sản ngắn hạn?",
70
+ "answer": "Khoản mục có giá trị lớn nhất trong tài sản ngắn hạn được thể hiện trên bảng cân đối kế toán của FPT."
71
+ },
72
+ {
73
+ "id": "p011",
74
+ "type": "out_of_scope",
75
+ "source": "",
76
+ "question": "Giá cổ phiếu FPT hôm nay là bao nhiêu?",
77
+ "answer": ""
78
+ },
79
+ {
80
+ "id": "p012",
81
+ "type": "out_of_scope",
82
+ "source": "",
83
+ "question": "Dự báo tăng trưởng GDP Việt Nam năm 2025 là bao nhiêu?",
84
+ "answer": ""
85
+ }
86
+ ]
eval_results.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "summary": {
3
+ "faithfulness": 0.6333333333333334,
4
+ "answer_relevancy": 0.7666666666666667,
5
+ "context_recall": 0.4000000000000001
6
+ },
7
+ "details": [
8
+ {
9
+ "question": "Doanh thu thuần của công ty trong quý 1 năm 2026 là bao nhiêu?",
10
+ "ground_truth": "Doanh thu thuần quý 1/2026 được ghi nhận trong báo cáo kết quả hoạt động kinh doanh của công ty.",
11
+ "answer": "Doanh thu thuần của công ty trong quý 1 năm 2026 là 63,904,435,716 VND [1].",
12
+ "faithfulness": 0.9,
13
+ "answer_relevancy": 1.0,
14
+ "context_recall": 0.8
15
+ },
16
+ {
17
+ "question": "Lợi nhuận sau thuế của công ty quý 1 năm 2026 là bao nhiêu?",
18
+ "ground_truth": "Lợi nhuận sau thuế quý 1/2026 được thể hiện trong báo cáo kết quả kinh doanh.",
19
+ "answer": "Không tìm thấy thông tin về lợi nhuận sau thuế của công ty quý 1 năm 2026 trong các tài liệu cung cấp. Tuy nhiên, có thông tin về lợi nhuận sau thuế thu nhập doanh nghiệp tại báo cáo kết quả kinh doanh trong một số tài liệu, nhưng không rõ ràng về quý 1 năm 2026 [1], [2], [4].",
20
+ "faithfulness": 0.2,
21
+ "answer_relevancy": 0.8,
22
+ "context_recall": 0.0
23
+ },
24
+ {
25
+ "question": "Doanh thu của FPT trong quý 2 năm 2016 là bao nhiêu?",
26
+ "ground_truth": "Doanh thu FPT quý 2/2016 được ghi nhận trong báo cáo tài chính giữa niên độ.",
27
+ "answer": "Không tìm thấy thông tin về doanh thu của FPT trong quý 2 năm 2016 trong các tài liệu cung cấp. Tuy nhiên, trong tài liệu [4], có thông tin về doanh thu bán hàng và cung cấp dịch vụ của FPT trong kỳ hoạt động từ ngày 01/01/2016 đến ngày 30/6/2016 là 121.602.490.344 VND, nhưng không có thông tin cụ thể về doanh thu trong quý 2 năm 2016.",
28
+ "faithfulness": 0.5,
29
+ "answer_relevancy": 0.8,
30
+ "context_recall": 0.8
31
+ },
32
+ {
33
+ "question": "Chi phí tài chính của FPT trong nửa đầu năm 2016 là bao nhiêu?",
34
+ "ground_truth": "Chi phí tài chính được thể hiện trong báo cáo kết quả hoạt động kinh doanh của FPT.",
35
+ "answer": "Không tìm thấy thông tin cụ thể về chi phí tài chính của FPT trong nửa đầu năm 2016 trong các đoạn tài liệu cung cấp. Tuy nhiên, các thông tin về doanh thu, lợi nhuận và các chỉ tiêu tài chính khác của FPT trong năm 2016 và сравнение với năm 2015 được đề cập trong các tài liệu [1], [2], [3], [4] và [5]. Nếu bạn cần thông tin cụ thể về chi phí tài chính, có thể cần tham khảo thêm các tài liệu khác hoặc phần cụ thể hơn trong báo cáo tài chính của FPT.",
36
+ "faithfulness": 0.5,
37
+ "answer_relevancy": 0.2,
38
+ "context_recall": 0.0
39
+ },
40
+ {
41
+ "question": "Báo cáo UPCOM quý 1 trình bày thông tin về những công ty nào?",
42
+ "ground_truth": "Báo cáo UPCOM quý 1 trình bày thông tin giao dịch và tài chính của các công ty niêm yết trên sàn UPCOM.",
43
+ "answer": "Báo cáo UPCOM quý 1 trình bày thông tin về các công ty niêm yết trên sàn UPCOM. [Không có thông tin cụ thể trong phần NGỮ CẢNH về các công ty cụ thể, tuy nhiên có thể suy luận thông tin chung về sàn UPCOM]",
44
+ "faithfulness": 0.8,
45
+ "answer_relevancy": 0.8,
46
+ "context_recall": 0.0
47
+ },
48
+ {
49
+ "question": "FPT hoạt động trong những lĩnh vực kinh doanh nào?",
50
+ "ground_truth": "FPT hoạt động trong các lĩnh vực công nghệ thông tin, viễn thông, giáo dục và các dịch vụ liên quan.",
51
+ "answer": "FPT hoạt động trong lĩnh vực cung cấp dịch vụ và giải pháp trong lĩnh vực công nghệ thông tin, bao gồm tích hợp hệ thống, cung cấp dịch vụ phần mềm, và các dịch vụ công nghệ khác [1].",
52
+ "faithfulness": 0.9,
53
+ "answer_relevancy": 1.0,
54
+ "context_recall": 0.8
55
+ }
56
+ ]
57
+ }
evaluate.py CHANGED
@@ -138,4 +138,7 @@ def evaluate(golden_set_path: str = "data/golden_set.json", sample: int = 20):
138
 
139
 
140
  if __name__ == "__main__":
141
- evaluate(sample=20)
 
 
 
 
138
 
139
 
140
  if __name__ == "__main__":
141
+ import sys
142
+ golden_path = sys.argv[1] if len(sys.argv) > 1 else "data/golden_set.json"
143
+ sample = int(sys.argv[2]) if len(sys.argv) > 2 else 20
144
+ evaluate(golden_set_path=golden_path, sample=sample)
ingest.py CHANGED
@@ -4,23 +4,34 @@ PDF_FOLDER = "data/pdf_text"
4
  IMAGE_FOLDER = "data/images"
5
 
6
  if __name__ == "__main__":
7
- mode = sys.argv[1] if len(sys.argv) > 1 else "all"
 
 
8
 
9
  if mode == "all":
10
  import subprocess
11
- subprocess.run([sys.executable, __file__, "pdf"], check=True)
12
- subprocess.run([sys.executable, __file__, "images"], check=True)
 
13
 
14
  elif mode == "pdf":
15
  from src.parsers.router import parse_folder as parse_folder_pdf
16
  from src.chunker import chunk_pages
17
  from src.vector_store import add_chunks
18
 
19
- print("=== INGEST PDF (pypdf - CPU) ===")
 
20
  pdf_pages = parse_folder_pdf(PDF_FOLDER, extensions={".pdf"})
21
  print(f"Tổng: {len(pdf_pages)} đoạn từ PDF")
22
  chunks = chunk_pages(pdf_pages)
23
  print(f"Tổng: {len(chunks)} chunks")
 
 
 
 
 
 
 
24
  if chunks:
25
  add_chunks(chunks)
26
  print("PDF xong!\n")
@@ -30,11 +41,19 @@ if __name__ == "__main__":
30
  from src.chunker import chunk_pages
31
  from src.vector_store import add_chunks
32
 
33
- print("=== INGEST ẢNH (EasyOCR - GPU) ===")
 
34
  image_pages = parse_folder_img(IMAGE_FOLDER, extensions={".jpg", ".jpeg", ".png"})
35
  print(f"Tổng: {len(image_pages)} đoạn từ ảnh")
36
  chunks = chunk_pages(image_pages)
37
  print(f"Tổng: {len(chunks)} chunks")
 
 
 
 
 
 
 
38
  if chunks:
39
  add_chunks(chunks)
40
- print("Ảnh xong!")
 
4
  IMAGE_FOLDER = "data/images"
5
 
6
  if __name__ == "__main__":
7
+ args = sys.argv[1:]
8
+ mode = args[0] if args else "all"
9
+ contextual = "--contextual" in args
10
 
11
  if mode == "all":
12
  import subprocess
13
+ extra = ["--contextual"] if contextual else []
14
+ subprocess.run([sys.executable, __file__, "pdf"] + extra, check=True)
15
+ subprocess.run([sys.executable, __file__, "images"] + extra, check=True)
16
 
17
  elif mode == "pdf":
18
  from src.parsers.router import parse_folder as parse_folder_pdf
19
  from src.chunker import chunk_pages
20
  from src.vector_store import add_chunks
21
 
22
+ label = "CONTEXTUAL" if contextual else "STANDARD"
23
+ print(f"=== INGEST PDF ({label}) ===")
24
  pdf_pages = parse_folder_pdf(PDF_FOLDER, extensions={".pdf"})
25
  print(f"Tổng: {len(pdf_pages)} đoạn từ PDF")
26
  chunks = chunk_pages(pdf_pages)
27
  print(f"Tổng: {len(chunks)} chunks")
28
+
29
+ if contextual and chunks:
30
+ from src.contextualizer import add_context_to_chunks
31
+ print("Đang sinh context cho từng chunk...")
32
+ chunks = add_context_to_chunks(chunks, pdf_pages)
33
+ print("Context xong!")
34
+
35
  if chunks:
36
  add_chunks(chunks)
37
  print("PDF xong!\n")
 
41
  from src.chunker import chunk_pages
42
  from src.vector_store import add_chunks
43
 
44
+ label = "CONTEXTUAL" if contextual else "STANDARD"
45
+ print(f"=== INGEST ẢNH ({label}) ===")
46
  image_pages = parse_folder_img(IMAGE_FOLDER, extensions={".jpg", ".jpeg", ".png"})
47
  print(f"Tổng: {len(image_pages)} đoạn từ ảnh")
48
  chunks = chunk_pages(image_pages)
49
  print(f"Tổng: {len(chunks)} chunks")
50
+
51
+ if contextual and chunks:
52
+ from src.contextualizer import add_context_to_chunks
53
+ print("Đang sinh context cho từng chunk...")
54
+ chunks = add_context_to_chunks(chunks, image_pages)
55
+ print("Context xong!")
56
+
57
  if chunks:
58
  add_chunks(chunks)
59
+ print("Ảnh xong!")
ingest_colpali.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ingest PDF pages as images into ColPali collection.
3
+ Usage: python ingest_colpali.py [pdf_folder]
4
+ """
5
+ import sys
6
+ import os
7
+ from pathlib import Path
8
+ from PIL import Image
9
+
10
+ PDF_FOLDER = sys.argv[1] if len(sys.argv) > 1 else "data/pdf_text"
11
+ IMAGE_OUT = "data/colpali_pages"
12
+
13
+
14
+ def pdf_to_images(pdf_path: Path, dpi: int = 150) -> list[Image.Image]:
15
+ """Convert PDF pages to PIL Images."""
16
+ try:
17
+ from pdf2image import convert_from_path
18
+ return convert_from_path(str(pdf_path), dpi=dpi)
19
+ except Exception as e:
20
+ print(f" pdf2image failed ({e}), trying pypdfium2...")
21
+ import pypdfium2 as pdfium
22
+ doc = pdfium.PdfDocument(str(pdf_path))
23
+ images = []
24
+ for i in range(len(doc)):
25
+ page = doc[i]
26
+ bitmap = page.render(scale=dpi / 72)
27
+ images.append(bitmap.to_pil())
28
+ return images
29
+
30
+
31
+ def main():
32
+ from src.colpali_retriever import ingest_images
33
+
34
+ os.makedirs(IMAGE_OUT, exist_ok=True)
35
+ pdf_files = list(Path(PDF_FOLDER).glob("*.pdf"))
36
+
37
+ if not pdf_files:
38
+ print(f"No PDFs found in {PDF_FOLDER}")
39
+ return
40
+
41
+ print(f"Found {len(pdf_files)} PDFs")
42
+
43
+ all_images, all_meta = [], []
44
+ for pdf_path in pdf_files:
45
+ print(f"\nConverting {pdf_path.name}...")
46
+ try:
47
+ pages = pdf_to_images(pdf_path)
48
+ print(f" {len(pages)} pages")
49
+ for page_num, img in enumerate(pages):
50
+ # Save image for reference/display
51
+ img_filename = f"{pdf_path.stem}_page{page_num+1}.jpg"
52
+ img_path = os.path.join(IMAGE_OUT, img_filename)
53
+ img.save(img_path, "JPEG", quality=85)
54
+
55
+ all_images.append(img)
56
+ all_meta.append({
57
+ "source": pdf_path.name,
58
+ "page": page_num + 1,
59
+ "image_path": img_path,
60
+ })
61
+ except Exception as e:
62
+ print(f" ERROR: {e}")
63
+
64
+ if not all_images:
65
+ print("No images to ingest.")
66
+ return
67
+
68
+ print(f"\nTotal: {len(all_images)} pages to ingest")
69
+ ingest_images(all_images, all_meta)
70
+ print("\nColPali ingest complete!")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
src/colpali_retriever.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ColPali Visual Retrieval — truy vấn tài liệu bằng hình ảnh thay vì text.
3
+ Model: vidore/colpali-v1.2 (multi-vector, MaxSim scoring)
4
+ So sánh với pipeline text hiện tại để thấy sự khác biệt trên tài liệu có bảng biểu.
5
+ """
6
+ import os
7
+ import torch
8
+ from pathlib import Path
9
+ from PIL import Image
10
+ from colpali_engine.models import ColPali, ColPaliProcessor
11
+ from qdrant_client import QdrantClient
12
+ from qdrant_client.models import (
13
+ Distance, VectorParams, MultiVectorConfig,
14
+ MultiVectorComparator, PointStruct, NamedSparseVector,
15
+ )
16
+ from dotenv import load_dotenv
17
+
18
+ load_dotenv()
19
+
20
+ COLPALI_MODEL = "vidore/colpali-v1.2"
21
+ COLLECTION_NAME = "colpali_docs"
22
+ VECTOR_DIM = 128 # ColPali output dim per patch
23
+
24
+ _model = None
25
+ _processor = None
26
+
27
+
28
+ def _get_model():
29
+ global _model, _processor
30
+ if _model is None:
31
+ device = "cuda" if torch.cuda.is_available() else "cpu"
32
+ print(f"Loading ColPali on {device}...")
33
+ _model = ColPali.from_pretrained(
34
+ COLPALI_MODEL,
35
+ torch_dtype=torch.bfloat16 if device == "cuda" else torch.float32,
36
+ device_map=device,
37
+ ).eval()
38
+ _processor = ColPaliProcessor.from_pretrained(COLPALI_MODEL)
39
+ print("ColPali loaded.")
40
+ return _model, _processor
41
+
42
+
43
+ def _get_client() -> QdrantClient:
44
+ url = os.getenv("QDRANT_URL")
45
+ api_key = os.getenv("QDRANT_API_KEY")
46
+ if url and api_key:
47
+ return QdrantClient(url=url, api_key=api_key, timeout=60)
48
+ return QdrantClient(path="qdrant_storage")
49
+
50
+
51
+ def _get_or_create_collection(client: QdrantClient):
52
+ existing = [c.name for c in client.get_collections().collections]
53
+ if COLLECTION_NAME not in existing:
54
+ client.create_collection(
55
+ collection_name=COLLECTION_NAME,
56
+ vectors_config={
57
+ "colpali": VectorParams(
58
+ size=VECTOR_DIM,
59
+ distance=Distance.COSINE,
60
+ multivector_config=MultiVectorConfig(
61
+ comparator=MultiVectorComparator.MAX_SIM,
62
+ ),
63
+ )
64
+ },
65
+ )
66
+ return client.get_collection(COLLECTION_NAME)
67
+
68
+
69
+ def encode_images(images: list[Image.Image]) -> list[list[list[float]]]:
70
+ """Encode list ảnh → list multi-vector embeddings."""
71
+ model, processor = _get_model()
72
+ device = next(model.parameters()).device
73
+
74
+ batch = processor.process_images(images).to(device)
75
+ with torch.no_grad():
76
+ embeddings = model(**batch) # (B, num_patches, dim)
77
+
78
+ return embeddings.cpu().float().tolist()
79
+
80
+
81
+ def encode_query(query_text: str) -> list[list[float]]:
82
+ """Encode text query → multi-vector embedding."""
83
+ model, processor = _get_model()
84
+ device = next(model.parameters()).device
85
+
86
+ batch = processor.process_queries([query_text]).to(device)
87
+ with torch.no_grad():
88
+ embedding = model(**batch) # (1, num_tokens, dim)
89
+
90
+ return embedding[0].cpu().float().tolist()
91
+
92
+
93
+ def ingest_images(
94
+ images: list[Image.Image],
95
+ metadatas: list[dict],
96
+ start_id: int = 0,
97
+ ):
98
+ """Ingest ảnh PDF pages vào Qdrant ColPali collection."""
99
+ client = _get_client()
100
+ _get_or_create_collection(client)
101
+
102
+ print(f"Encoding {len(images)} pages with ColPali...")
103
+ embeddings = encode_images(images)
104
+
105
+ points = [
106
+ PointStruct(
107
+ id=start_id + i,
108
+ vector={"colpali": emb},
109
+ payload=meta,
110
+ )
111
+ for i, (emb, meta) in enumerate(zip(embeddings, metadatas))
112
+ ]
113
+
114
+ client.upsert(collection_name=COLLECTION_NAME, points=points)
115
+ print(f"Ingested {len(points)} pages into '{COLLECTION_NAME}'")
116
+
117
+
118
+ def query(query_text: str, top_k: int = 3) -> list[dict]:
119
+ """Tìm kiếm ảnh tương tự với query text qua MaxSim."""
120
+ client = _get_client()
121
+ query_emb = encode_query(query_text)
122
+
123
+ results = client.query_points(
124
+ collection_name=COLLECTION_NAME,
125
+ query=query_emb,
126
+ using="colpali",
127
+ limit=top_k,
128
+ with_payload=True,
129
+ ).points
130
+
131
+ return [
132
+ {
133
+ "score": round(r.score, 4),
134
+ "source": r.payload.get("source", ""),
135
+ "page": r.payload.get("page", 0),
136
+ "image_path": r.payload.get("image_path", ""),
137
+ }
138
+ for r in results
139
+ ]
src/contextualizer.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Contextual Retrieval — sinh 1-2 câu context cho mỗi chunk dựa trên toàn văn tài liệu.
3
+ Context được prepend vào chunk trước khi embed + BM25, giúp retrieval chính xác hơn.
4
+ Tham khảo: Anthropic Contextual Retrieval paper (~35% giảm retrieval failure).
5
+ """
6
+ import os
7
+ import google.generativeai as genai
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+ genai.configure(api_key=os.environ["GOOGLE_API_KEY"].strip())
12
+ _model = genai.GenerativeModel("gemini-2.0-flash")
13
+
14
+ CONTEXT_MODEL = "gemini-2.0-flash"
15
+
16
+ CONTEXT_PROMPT = """Dưới đây là toàn bộ tài liệu:
17
+ <document>
18
+ {doc_text}
19
+ </document>
20
+
21
+ Đây là đoạn văn cần đặt vào ngữ cảnh:
22
+ <chunk>
23
+ {chunk_text}
24
+ </chunk>
25
+
26
+ Hãy viết 1-2 câu ngắn gọn mô tả vị trí và vai trò của đoạn này trong tài liệu, \
27
+ giúp người đọc hiểu đoạn này nằm ở đâu và nói về điều gì.
28
+ Chỉ trả về 1-2 câu đó, không giải thích thêm."""
29
+
30
+
31
+ def generate_context(doc_text: str, chunk_text: str) -> str:
32
+ """Sinh context cho 1 chunk. Trả về context string."""
33
+ # Giới hạn doc_text để tránh vượt context window
34
+ max_doc_chars = 8000
35
+ if len(doc_text) > max_doc_chars:
36
+ doc_text = doc_text[:max_doc_chars] + "\n...[nội dung tiếp theo]..."
37
+
38
+ import time
39
+ prompt = CONTEXT_PROMPT.format(doc_text=doc_text, chunk_text=chunk_text)
40
+ for attempt in range(5):
41
+ try:
42
+ response = _model.generate_content(
43
+ prompt,
44
+ generation_config={"max_output_tokens": 150, "temperature": 0.1},
45
+ )
46
+ return response.text.strip()
47
+ except Exception as e:
48
+ msg = str(e)
49
+ if "429" in msg:
50
+ # TPD (per day) exhausted — no point retrying
51
+ if "PerDay" in msg or "limit: 0" in msg:
52
+ print(f" [contextualizer] hết quota ngày, dừng sinh context")
53
+ raise RuntimeError("QUOTA_DAY_EXHAUSTED") from e
54
+ # TPM (per minute) — wait and retry
55
+ wait = 60 * (attempt + 1)
56
+ print(f" [contextualizer] rate limit, chờ {wait}s rồi thử lại...")
57
+ time.sleep(wait)
58
+ else:
59
+ print(f" [contextualizer] lỗi: {e}, bỏ qua context")
60
+ return ""
61
+ return ""
62
+
63
+
64
+ def add_context_to_chunks(chunks: list[dict], source_pages: list[dict]) -> list[dict]:
65
+ """
66
+ Prepend context vào text của mỗi chunk.
67
+ source_pages: list các page dict có 'text' và 'metadata.source' để build doc_text.
68
+ """
69
+ # Group pages theo source file
70
+ from collections import defaultdict
71
+ doc_texts = defaultdict(list)
72
+ for page in source_pages:
73
+ src = page["metadata"].get("source", "unknown")
74
+ doc_texts[src].append(page["text"])
75
+
76
+ # Full text per document
77
+ full_docs = {src: "\n\n".join(pages) for src, pages in doc_texts.items()}
78
+
79
+ import time
80
+ enriched = []
81
+ total = len(chunks)
82
+ for i, chunk in enumerate(chunks):
83
+ src = chunk["metadata"].get("source", "unknown")
84
+ doc_text = full_docs.get(src, "")
85
+
86
+ print(f" Generating context {i+1}/{total}: {src}...")
87
+ try:
88
+ context = generate_context(doc_text, chunk["text"])
89
+ except RuntimeError as e:
90
+ if "QUOTA_DAY_EXHAUSTED" in str(e):
91
+ print(f" [contextualizer] quota ngày hết tại chunk {i+1}/{total}, dừng lại")
92
+ # Append remaining chunks without context
93
+ for remaining in chunks[i:]:
94
+ r = dict(remaining)
95
+ r["metadata"] = {**remaining["metadata"], "has_context": False}
96
+ enriched.append(r)
97
+ return enriched
98
+ context = ""
99
+ time.sleep(4) # Gemini Flash free tier: 15 RPM
100
+
101
+ new_chunk = dict(chunk)
102
+ if context:
103
+ new_chunk["text"] = f"{context}\n\n{chunk['text']}"
104
+ new_chunk["metadata"] = {**chunk["metadata"], "has_context": True}
105
+ else:
106
+ new_chunk["metadata"] = {**chunk["metadata"], "has_context": False}
107
+
108
+ enriched.append(new_chunk)
109
+
110
+ return enriched