Spaces:
Sleeping
Sleeping
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 +3 -2
- compare_retrieval.py +70 -0
- data/golden_set_pdf.json +86 -0
- eval_results.json +57 -0
- evaluate.py +4 -1
- ingest.py +25 -6
- ingest_colpali.py +74 -0
- src/colpali_retriever.py +139 -0
- src/contextualizer.py +110 -0
.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 |
-
|
| 6 |
-
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 8 |
|
| 9 |
if mode == "all":
|
| 10 |
import subprocess
|
| 11 |
-
|
| 12 |
-
subprocess.run([sys.executable, __file__, "
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|