YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- ⚖️ Legal RAG Chatbot — Vietnamese Legal Q&A API
⚖️ Legal RAG Chatbot — Vietnamese Legal Q&A API
RAG (Retrieval-Augmented Generation) chatbot API cho lĩnh vực pháp luật Việt Nam, hỗ trợ hội thoại nhiều lượt (multi-turn) và streaming theo thời gian thực từng bước xử lý của pipeline: phân tích câu hỏi → tìm kiếm tài liệu → tra cứu chéo văn bản → sinh câu trả lời có trích dẫn.
Demo · Kiến trúc · Cài đặt · API · Kết quả thi đấu
Demo
https://github.com/user-attachments/assets/dccc32c0-e9b4-414a-aecb-b64a7bd6383b
Giới thiệu
Dự án cung cấp một API chatbot hỏi đáp pháp luật Việt Nam, xây dựng trên kiến trúc RAG (Retrieval-Augmented Generation) với:
- Sub-query decomposition — tách câu hỏi phức hợp thành các truy vấn con đầy đủ ngữ cảnh trước khi tìm kiếm.
- Semantic search trên FAISS, kết hợp reranking (tùy chọn) để tăng độ chính xác.
- Tra cứu chéo văn bản theo yêu cầu (tool-calling) — LLM tự gọi tool để lấy thêm nội dung từ một văn bản/Điều/Khoản cụ thể khi cần.
- Streaming SSE — trả kết quả theo từng bước xử lý (sub-query, retrieval, context, tool-call, answer) thay vì đợi toàn bộ pipeline chạy xong.
- Trích dẫn nguồn — mọi câu trả lời đều đi kèm citation
[N]trỏ về đúng Điều/Khoản/văn bản pháp luật gốc.
Dự án được phát triển trong khuôn khổ cuộc thi R2AI 2026 — Build AI Legal Assistant, tổ chức bởi BM25 Baseline / AI Guru.
Kết quả trên bảng xếp hạng
Trích từ leaderboard chính thức của BTC (hạng mục Kiểm thử riêng, top 10 tại thời điểm chốt), dùng để đối chiếu vị trí và các chỉ số của đội Bee IT so với các đội dẫn đầu khác.
| # | Người tham gia | Ngày | ID | FINAL SCORE | Articles F2-Macro | Docs F2-Macro | Avg QA | Articles Precision | Articles Recall | Docs Precision | Docs Recall | Chính xác nội dung | Đầy đủ & toàn diện | Thực tiễn & áp dụng | Rõ ràng & dễ hiểu |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Hung&Fong | 2026-07-02 22:21 | 2210 | 0.6437 | 0.6552 | 0.763 | 0.513 | 0.5654 | 0.722 | 0.6856 | 0.8184 | 0.3836 | 0.3739 | 0.3581 | 0.9366 |
| 2 | Trần Anh Tú | 2026-07-02 17:51 | 2198 | 0.6408 | 0.6117 | 0.7076 | 0.6029 | 0.4548 | 0.7202 | 0.5371 | 0.8217 | 0.4883 | 0.4368 | 0.5166 | 0.9701 |
| 3 | thanhkhauson | 2026-07-02 21:30 | 2204 | 0.6299 | 0.5774 | 0.6062 | 0.7062 | 0.4724 | 0.6629 | 0.4598 | 0.7152 | 0.5886 | 0.543 | 0.7248 | 0.9685 |
| 4 | mscai | 2026-07-02 22:39 | 2212 | 0.6291 | 0.5733 | 0.734 | 0.5799 | 0.5561 | 0.6317 | 0.6944 | 0.7825 | 0.5082 | 0.3812 | 0.4573 | 0.9727 |
| 5 | Bee IT | 2026-07-01 13:14 | 2155 | 0.6053 | 0.5612 | 0.6737 | 0.581 | 0.3443 | 0.7694 | 0.4795 | 0.8465 | 0.5517 | 0.4681 | 0.3397 | 0.9643 |
| 6 | Nguyễn Văn Nghiêm | 2026-07-01 09:49 | 2144 | 0.5983 | 0.6008 | 0.6759 | 0.5182 | 0.5473 | 0.6639 | 0.5782 | 0.7573 | 0.437 | 0.4044 | 0.2586 | 0.9727 |
| 7 | Next Gen 2026 | 2026-07-01 22:46 | 2162 | 0.554 | 0.54 | 0.6656 | 0.4565 | 0.4989 | 0.5826 | 0.5639 | 0.7287 | 0.4124 | 0.2868 | 0.2444 | 0.8823 |
| 8 | Agentic Builders Lê Trí Luận | 2026-07-02 19:37 | 2200 | 0.5391 | 0.5065 | 0.6679 | 0.4428 | 0.5721 | 0.5268 | 0.6887 | 0.6841 | 0.4001 | 0.2693 | 0.227 | 0.875 |
| 9 | FAI Team | 2026-07-02 17:24 | 2195 | 0.5211 | 0.5547 | 0.5333 | 0.4752 | 0.5279 | 0.6069 | 0.4729 | 0.5837 | 0.3921 | 0.2981 | 0.3004 | 0.9103 |
| 10 | tqd | 2026-07-01 11:55 | 2150 | 0.5182 | 0.51 | 0.5921 | 0.4524 | 0.5617 | 0.521 | 0.6358 | 0.5981 | 0.4203 | 0.3109 | 0.1998 | 0.8789 |
Đội Bee IT (dự án này) xếp hạng 5/54 ở thời điểm chốt bảng trên, với
FINAL SCORE = 0.6053. Điểm mạnh nằm ở Docs Recall (0.8465) và Articles Recall (0.7694).
Cấu trúc dự án
project/
├── app.py # FastAPI application (endpoint /chat, /health, /)
├── main.py # Entry point chạy server (uvicorn)
├── templates/
│ └── index.html # Giao diện chatbot web (vanilla JS + SSE + sidebar tài liệu)
├── services/
│ ├── __init__.py
│ ├── Chat.py # Client Chat (OpenAI-compatible), embedding, rerank
│ ├── OpenAIExtended.py # OpenAI client mở rộng (thêm endpoint reranker)
│ ├── Search.py # Semantic search (FAISS) + tra cứu theo doc_ref
│ └── RAGPipeline.py # Pipeline RAG chính: sub-query → retrieval → context_ready → tool-call → answer
├── pipeline/
│ ├── crawl_preprocess.py # Phase 1-2: crawl dữ liệu VBPL + lọc/chuẩn hóa
│ └── chunk_embedding.py # Phase 3-4: chunking theo Điều/Khoản + embedding FAISS
├── data/ # FAISS index và các map dữ liệu (tải riêng, xem mục Cài đặt)
│ ├── faiss.index
│ ├── faiss_id_map.json
│ ├── chunk_map.json
│ ├── article_index_map.json
│ └── chunks.json
├── requirements.txt
├── .env.example # Mẫu file cấu hình biến môi trường
└── README.md # Tài liệu này
Pipeline xử lý dữ liệu (Crawl → Chunk → Embedding)
Quy trình tổng quan
Dữ liệu được xử lý qua 4 phase liên tiếp: crawl từ API → lọc và chuẩn hóa → chia chunk theo cấu trúc pháp luật → embedding với FAISS.
Dữ liệu VBPL API
│
▼
[PHASE 1: CRAWL]
└─► Fetch dữ liệu từ https://vbpl-bientap-gateway.moj.gov.vn/api/qtdc/public/doc/all
└─► Lấy detail + HTML content (documentContent.content) từ từng doc
└─► Lấy metadata cấu trúc (Điều/Khoản/Điểm) từ index API
└─► Lưu: data.jsonl + index_data.jsonl (hỗ trợ resume với checkpoint.json)
│
▼
[PHASE 2: PREPROCESS]
└─► Đọc data.jsonl + index_data.jsonl
└─► Lọc docs:
* effStatus phải là: "Còn hiệu lực", "Hết hiệu lực một phần", "Chưa có hiệu lực"
* Loại bỏ: "Hết hiệu lực toàn bộ", "Không còn phù hợp", "Ngưng hiệu lực"
* Phải có index metadata
└─► Merge dữ liệu cleaned + metadata → processed_data.json
└─► Output: error_items.json (lý do lọc)
│
▼
[PHASE 3: CHUNKING]
└─► Parse HTML content từ processed_data.json bằng BeautifulSoup
└─► Tách theo cấu trúc pháp luật: Phần → Chương → Điều → Khoản → Điểm
└─► Mỗi leaf node (Điều/Khoản/Điểm) = 1 chunk
└─► Tạo embed_text: [Tên văn bản] - [Số Điều] - [Hierarchy] : [Nội dung]
└─► Lưu:
* chunks.json: danh sách tất cả chunks + metadata + embed_text
* faiss_id_map.json: map FAISS index ID → chunk ID
* chunk_map.json: chunk ID → metadata
* doc_index_map.json: doc ID → danh sách chunk IDs
* article_index_map.json: doc ID + Điều số → danh sách chunk IDs
│
▼
[PHASE 4: EMBEDDING]
└─► Embed mỗi chunk.embed_text bằng Qwen3-Embedding-0.6B
└─► Build FAISS IndexIDMap (inner product, dimension=1024)
└─► Lưu: faiss.index + indexed_ids.json (hỗ trợ resume)
│
▼
Ready cho RAG Pipeline (semantic search)
Chi tiết các Phase
Phase 1: Crawl (pipeline/crawl_preprocess.py)
python pipeline/crawl_preprocess.py
- Crawl 36,916 trang từ VBPL API (10 docs/trang)
- Mỗi doc lấy:
- Detail API (
/api/qtdc/public/doc/{doc_id}): docNum, title, effStatus, effFrom, documentContent.content (HTML) - Index API (qua form POST tới vbpl.vn): metadata cấu trúc pháp luật (tree của các node Phần/Chương/Điều/Khoản/Điểm)
- Detail API (
- Checkpoint: lưu lại trang cuối cùng xử lý thành công → có thể resume lại nếu gián đoạn
- Output:
data.jsonl: 1 dòng = 1 doc (JSON object)index_data.jsonl: 1 dòng = {doc_id, index_data}checkpoint.json: {last_page, failed_docs}
Phase 2: Preprocess (pipeline/crawl_preprocess.py phase2)
python pipeline/crawl_preprocess.py phase2
- Đọc data.jsonl + index_data.jsonl
- Lọc effStatus:
- ✅ GIỮ:
"Còn hiệu lực"(Still in effect),"Hết hiệu lực một phần"(Partially expired),"Chưa có hiệu lực"(Not yet in effect) - ❌ LOẠI:
"Hết hiệu lực toàn bộ"(Fully expired),"Không còn phù hợp"(No longer applicable),"Ngưng hiệu lực"(Suspended)
- ✅ GIỮ:
- Merge: kết hợp detail + metadata → 1 doc object hoàn chỉnh
- Output:
processed_data.json: dữ liệu được chấp nhận (JSON array)error_items.json: dữ liệu bị lọc (kèm lý do: missing_effStatus, no_index_metadata, unsupported_effStatus, v.v.)
Phase 3: Chunking (pipeline/chunk_embedding.py phase3)
python pipeline/chunk_embedding.py phase3
- Parse HTML từ
processed_data.jsonbằng BeautifulSoup - Xây dựng tree map từ metadata index, flatten thành dict
id → node_info - Duyệt tất cả leaf nodes (các node không có children hoặc được đánh dấu
isLeaf=true):- Mỗi leaf = 1 chunk
- Lấy nội dung text từ HTML element tương ứng
- Tạo hierarchy string: danh sách tiêu đề từ root tới node hiện tại (bỏ qua Part/Chapter)
- Tạo embed_text:
[Tên văn bản] - [Số Điều] - [Hierarchy] : [Nội dung] - Metadata: title, doc_num, doc_id, article (số Điều), clause (khoản nếu có)
- Deduplication: không lưu lại doc lỗi (missing content, metadata)
- Output:
data/chunks.json: danh sách tất cả chunksdata/faiss_id_map.json: FAISS index → chunk_iddata/chunk_map.json: chunk_id → metadatadata/doc_index_map.json: doc_id → [chunk indices]data/article_index_map.json: doc_id|article → [chunk indices] (cho toolsearch_referenced_document)data/skipped.json: danh sách docs bị skip (lý do lỗi)
Phase 4: Embedding (pipeline/chunk_embedding.py phase4)
python pipeline/chunk_embedding.py phase4
- Load chunks từ
data/chunks.json - Khởi tạo hoặc resume FAISS IndexIDMap (Inner Product, 1024 dimensions)
- Embed batch từng chunk.embed_text:
- Gọi embedding API (Qwen3-Embedding-0.6B) với batch size=5
- Normalize vectors (L2 norm)
- Thêm vào FAISS index với ID = chunk index
- Resume capability: lưu indexed_ids.json → có thể chạy lại nếu gián đoạn
- Flush: lưu FAISS index + indexed_ids mỗi 500 vectors
- Output:
data/faiss.index: FAISS binary index (phục vụ semantic search)data/indexed_ids.json: danh sách ID đã embed (dùng resume)
Chạy toàn bộ pipeline
# Chạy toàn bộ 4 phase tuần tự
python pipeline/crawl_preprocess.py # Phase 1 + 2
python pipeline/chunk_embedding.py # Phase 3 + 4
# Hoặc chạy riêng từng phase
python pipeline/crawl_preprocess.py phase2 # Chỉ preprocess (đã có data.jsonl)
python pipeline/chunk_embedding.py phase3 # Chỉ chunking
python pipeline/chunk_embedding.py phase4 # Chỉ embedding (đã có chunks.json)
Lưu ý khi xử lý dữ liệu
- Quy mô dữ liệu: ~36k docs, ~810k+ chunks, FAISS index ~3.2GB. Khuyến nghị:
- SSD/NVMe cho lưu trữ dữ liệu tạm
- CPU: ≥8 cores cho parallel processing
- RAM: ≥16GB
- GPU (tùy chọn): tăng tốc chunking/embedding
- Offline processing: toàn bộ 4 phase có thể chạy offline (sau khi crawl dữ liệu lần đầu)
- Incremental update: nếu muốn cập nhật dữ liệu mới:
- Chạy phase 1 từ
checkpoint.json(nếu cần) - Hoặc chỉ crawl phần mới, merge vào processed_data.json trước phase 3
- Chạy phase 1 từ
Kiến trúc pipeline
Khác với cách xử lý 2 lượt gọi LLM tách rời (1 lần quyết định tool call, 1 lần sinh câu trả lời), RAGPipeline gộp bước quyết định tool-call và sinh câu trả lời vào một luồng streaming duy nhất: LLM vừa có thể trả lời trực tiếp (delta.content) vừa có thể gọi tool (delta.tool_calls) ngay trong cùng 1 lần gọi API, giảm độ trễ và giữ nguyên ngữ cảnh hội thoại giữa các bước.
Hội thoại (messages: [user, assistant, user, ...])
│
▼
[Bước 1] Sub-query
└─► Gộp toàn bộ hội thoại thành 1 khối text
└─► LLM (non-stream, structured output, /no_think) tách câu hỏi thành các sub-query
* Mỗi sub-query PHẢI tự đầy đủ ngữ cảnh (giữ nguyên chủ thể/điều kiện của câu hỏi gốc)
* Nếu các ý không thể tách rời mà không mất nghĩa → trả về đúng 1 sub-query
│
▼
[Bước 2] Retrieval
└─► Semantic search (FAISS) cho từng sub-query
└─► Deduplicate theo chunk_id, giới hạn MAX_CONTEXT_CHUNKS
│
▼
[Bước 2.5] Context Ready
└─► Phát ngay citation_map + danh sách context_docs vừa retrieval được
└─► Cho phép frontend hiển thị sidebar "Tài liệu tham khảo" TRƯỚC khi LLM bắt đầu sinh câu trả lời
│
▼
[Bước 3+4] Answer (streaming, gộp làm 1 lần gọi LLM, /no_think trên mọi user message)
└─► Đưa [system prompt, context + quy tắc trích dẫn, ...toàn bộ hội thoại gốc] vào LLM (stream=True)
└─► Nếu LLM sinh nội dung (delta.content) → stream trực tiếp ra client
└─► Nếu LLM gọi tool `search_referenced_document` (delta.tool_calls)
→ tra cứu thêm chunk theo doc_ref/điều/khoản cụ thể
→ cập nhật lại context, phát lại "context_ready" với citation_map mới
→ gọi lại LLM (tối đa MAX_TOOL_ITERATIONS lần)
│
▼
Trả về: câu trả lời kèm citation [N] + citation_map (map số thứ tự → chunk nguồn)
Toàn bộ các lệnh gọi LLM trong bước 3+4 chạy nối tiếp trong cùng một danh sách messages (đúng chuẩn tool-calling của OpenAI: assistant message chứa tool_calls → tool message chứa kết quả) để LLM giữ được ngữ cảnh xuyên suốt qua các vòng tra cứu.
/no_think — tắt chế độ suy luận (thinking) của model
Các model dòng Qwen3 chạy qua LM Studio/llama.cpp hỗ trợ chế độ "thinking" (sinh ra khối suy luận trước khi trả lời), có thể bật/tắt bằng hậu tố đặc biệt trong prompt. Pipeline tự động thêm /no_think vào cuối content của mọi message có role="user" trước khi gửi lên LLM (áp dụng cho cả bước sub-query lẫn bước sinh câu trả lời chính), thông qua helper _with_no_think():
- Không sửa đổi message gốc trong lịch sử hội thoại (
llm_messages) — chỉ áp dụng lên bản sao dùng để gọi API, tránh/no_thinkbị lặp lại nhiều lần qua các vòng lặp tool-call. - Tự kiểm tra để không thêm trùng nếu content đã kết thúc sẵn bằng
/no_think. - Không áp dụng cho
system,assistant,toolmessages.
Mục đích: giảm độ trễ và tránh model sinh ra khối suy luận dài dòng không cần thiết trong một pipeline vốn đã nhiều bước tuần tự (sub-query → retrieval → tool-call → answer).
Cài đặt
1. Clone và cài dependencies
git clone https://github.com/antrc2/R2AI2026-BUILD-AI-LEGAL-ASSISTANT
cd R2AI2026-BUILD-AI-LEGAL-ASSISTANT
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
2. Yêu cầu hệ thống
- Python 3.10+
- Các service bên ngoài tương thích OpenAI API:
- Chat LLM — hỗ trợ streaming + function calling (khuyến nghị dòng Qwen3 để tương thích
/no_think) - Embedding model
- Reranker (tùy chọn — nếu không cấu hình,
Chat.pygiữ nguyên thứ tự kết quả semantic search)
- Chat LLM — hỗ trợ streaming + function calling (khuyến nghị dòng Qwen3 để tương thích
3. Tải dữ liệu (thư mục data/)
Repo không chứa sẵn thư mục data/ (FAISS index, chunks, metadata). Tải archive đã đóng gói từ Hugging Face và giải nén vào thư mục gốc của project.
Linux / macOS:
curl -L https://huggingface.co/AnTrc2/R2AI2026-BUILD-AI-LEGAL-ASSISTANT/resolve/main/data.zip -o data.zip
unzip -o data.zip -d .
Windows PowerShell:
Invoke-WebRequest -Uri "https://huggingface.co/AnTrc2/R2AI2026-BUILD-AI-LEGAL-ASSISTANT/resolve/main/data.zip" -OutFile "data.zip"
Expand-Archive -Path ".\data.zip" -DestinationPath "." -Force
Kiểm tra sau khi giải nén — cần thấy đủ 4 file: faiss.index, faiss_id_map.json, chunk_map.json, article_index_map.json (và chunks.json nếu muốn dùng embed_text gốc thay vì rebuild từ metadata).
ls data
# hoặc Windows:
Get-ChildItem .\data
Nguồn dữ liệu: kho ngữ liệu được crawl và xử lý từ văn bản pháp luật Việt Nam (luật, nghị định, thông tư...), được chunk theo cấu trúc Điều/Khoản/Điểm rồi embed bằng Qwen3-Embedding-0.6B. Toàn bộ archive data.zip ở trên là dữ liệu đã qua xử lý (post-processing), sẵn sàng nạp trực tiếp vào FAISS.
4. Cấu hình biến môi trường
Copy .env.example thành .env ở thư mục gốc và điền thông tin service:
# --- Chat model ---
CHAT_MODEL_NAME=qwen3-4b
CHAT_BASE_URL=http://localhost:1234/v1
CHAT_API_KEY=none
# --- Embedding model ---
EMBEDDING_MODEL_NAME=text-embedding-qwen3-embedding-0.6b
EMBEDDING_BASE_URL=http://localhost:1234/v1
EMBEDDING_API_KEY=none
# --- Reranker (tùy chọn, để trống nếu không dùng) ---
RERANKER_MODEL_NAME=
RERANKER_BASE_URL=
RERANKER_API_KEY=
Nếu để trống 1 trong 3 biến
RERANKER_*,ChatServicesẽ không khởi tạorerank_clientvà tự động giữ nguyên thứ tự tài liệu từ semantic search thay vì rerank.
5. Tự host model (tùy chọn) — llama.cpp / vLLM
Nếu chưa có sẵn service LLM/embedding tương thích OpenAI, có thể tự host bằng llama-server (llama.cpp):
# Chat model
./llama-server \
-m /path/to/model-chat.gguf \
--host 0.0.0.0 --port 1234 \
--ctx-size 32768 \
--n-gpu-layers 999
# Embedding model (cổng riêng, thêm cờ --embedding)
./llama-server \
-m /path/to/model-embedding.gguf \
--host 0.0.0.0 --port 1235 \
--embedding --ctx-size 4096
Reranker (tùy chọn) có thể host bằng vLLM, ví dụ với Qwen3-Reranker-0.6B:
docker run --rm --gpus all \
--name reranker \
-p 1236:8000 \
vllm/vllm-openai:latest \
--model Qwen/Qwen3-Reranker-0.6B \
--hf-overrides '{"architectures":["Qwen3ForSequenceClassification"],"classifier_from_token":["no","yes"],"is_original_qwen3_reranker":true}' \
--dtype float16 --max-model-len 2048 \
--gpu-memory-utilization 0.4 --enforce-eager
Sau đó cập nhật .env cho khớp cổng/model đang chạy. Kiểm tra nhanh:
curl http://localhost:1234/v1/models
curl http://localhost:1235/v1/models
Chạy server
python main.py
Hoặc chạy trực tiếp bằng uvicorn:
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
Server chạy tại http://localhost:8000.
API Endpoints
1. GET / — Trang chủ chatbot
Trả về giao diện web (templates/index.html) để tương tác trực tiếp qua trình duyệt, hiển thị pipeline steps theo thời gian thực và sidebar tài liệu tham khảo.
2. POST /chat — Endpoint chat chính
Request:
{
"messages": [
{ "role": "user", "content": "Doanh nghiệp nhỏ và vừa được ưu đãi thuế TNDN như thế nào?" },
{ "role": "assistant", "content": "Theo Luật Hỗ trợ DNNVV..." },
{ "role": "user", "content": "Vậy còn thuế VAT thì sao?" }
],
"stream": true
}
Parameters:
| Field | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
messages |
List[ChatMessage] |
có | Toàn bộ lịch sử hội thoại, mỗi phần tử {"role": "user"|"assistant", "content": "..."}. Không cần gửi kèm system message — pipeline tự thêm. |
stream |
bool |
không (mặc định true) |
true: trả về Server-Sent Events; false: trả JSON một lần khi xử lý xong toàn bộ. |
Client cần tự lưu và gửi lại toàn bộ messages ở mỗi lượt gọi (bao gồm cả các câu trả lời assistant trước đó) để giữ ngữ cảnh hội thoại nhiều lượt. Frontend đi kèm (templates/index.html) luôn gọi với stream: true.
Response khi stream=true (Server-Sent Events):
Mỗi dòng có định dạng data: {...}\n\n, kết thúc bằng data: [DONE]\n\n. Các event theo đúng thứ tự pipeline:
{"step": "sub_queries", "status": "processing", "data": null}
{"step": "sub_queries", "status": "done", "data": {"queries": ["...", "..."]}}
{"step": "retrieval", "status": "processing", "data": null}
{"step": "retrieval", "status": "done", "data": {"count": 18}}
{"step": "context_ready", "status": "done", "data": {
"citations": {"1": {"content": "...", "metadata": {}}, "2": {}},
"sources": [{"chunk_id": "...", "content": "...", "metadata": {}}]
}}
{"step": "tool_call", "status": "processing", "data": null}
{"step": "answer", "status": "start", "data": null}
{"step": "tool_call", "status": "detected", "data": {"args": {"doc_ref": "04/2017/QH14", "content_query": "ưu đãi thuế VAT"}}}
{"step": "tool_call", "status": "executed", "data": {"found_count": 4}}
{"step": "context_ready", "status": "done", "data": {"citations": {"...": {}}, "sources": [{}]}}
{"step": "tool_call", "status": "done", "data": null}
{"step": "answer", "status": "streaming", "data": {"chunk": "Theo quy định tại", "citations": {"1": {"content": "...", "metadata": {}}, "2": {}}}}
{"step": "answer", "status": "streaming", "data": {"chunk": " Điều 5 [1]...", "citations": {}}}
{"step": "answer", "status": "done", "data": {
"text": "Toàn bộ câu trả lời hoàn chỉnh, có trích dẫn [1], [2]...",
"citations": {"1": {"content": "...", "metadata": {}}, "2": {}}
}}
Nếu xảy ra lỗi ở bất kỳ bước nào: {"step": "answer", "status": "error", "data": {"error": "..."}} (hoặc {"step": "tool_call", "status": "error", "data": {"error": "..."}} nếu lỗi xảy ra riêng khi thực thi tool tra cứu chéo văn bản).
citationslà map{"số thứ tự (string)": chunk object}tương ứng đúng với các số[N]xuất hiện trong text câu trả lời — dùng để frontend render tooltip/sidebar tham chiếu nguồn. Lưu ý: eventanswer/donecuối cùng chỉ trả vềtext+citations(không cósources) — nếu cần danh sáchsourcesđầy đủ, lấy từ eventcontext_readygần nhất trong luồng.
Response khi stream=false:
{
"steps": [
{"step": "sub_queries", "status": "done", "data": {"queries": ["..."]}},
{"step": "retrieval", "status": "done", "data": {"count": 18}},
{"step": "context_ready", "status": "done", "data": {"citations": {}, "sources": []}},
{"step": "tool_call", "status": "executed", "data": {"found_count": 4}},
{"step": "answer", "status": "done", "data": {"text": "...", "citations": {}}}
],
"final_answer": "Toàn bộ câu trả lời hoàn chỉnh...",
"citations": {"1": {}, "2": {}},
"sources": []
}
Ở nhánh
stream=false,app.pychỉ đọcsourcestừ eventanswer/done(hiện luôn rỗng vì event này không còn mangsources) — nếu cầnsourcesđầy đủ trong response non-stream, nên sửaapp.pyđể lấysourcestừ eventcontext_readycuối cùng trong danh sáchstepsthay vì từanswer/done.
3. GET /health — Health check
{ "status": "ok" }
Giao diện web
Truy cập http://localhost:8000 để dùng giao diện chat có sẵn (templates/index.html):
- Khung chat — hiển thị lịch sử hội thoại (frontend tự lưu
messagesvà gửi lại đầy đủ mỗi lượt), luôn dùng streaming. - Nhật ký các bước (step log) — mỗi bước xử lý (
sub_queries,retrieval,context_ready,tool_call,answer) được append thành 1 dòng riêng, không ghi đè lên dòng trước đó, để người dùng xem lại được toàn bộ quá trình xử lý kể cả khi có nhiều vòng lặp tool-call. Sau khi câu trả lời hoàn tất, nhật ký được làm mờ (không ẩn hẳn) để vẫn xem lại được. - Sidebar "Tài liệu tham khảo" — cập nhật ngay khi có event
context_ready(trước khi LLM trả lời xong), hiển thị danh sách văn bản/điều khoản kèm số hiệu[N]tương ứng. - Citation badge — số trích dẫn
[N]trong câu trả lời được render thành nút tròn màu vàng đồng; bấm vào sẽ cuộn tới thẻ tài liệu tương ứng trong sidebar. - Nút "Cuộc trò chuyện mới" — xóa lịch sử hội thoại phía client và reset sidebar.
Xử lý streaming ở tầng server
RAGPipeline.process() là một generator đồng bộ (chứa các lời gọi HTTP blocking tới LLM/search). Để tránh việc các bước xử lý ban đầu (sub-query, retrieval) bị "kẹt" lại và chỉ được đẩy ra client dồn cục cùng lúc với streaming câu trả lời, app.py chạy pipeline trong một thread riêng (_run_pipeline_in_thread), đẩy từng event qua queue.Queue, và phía consumer (async def event_generator) đọc queue qua run_in_executor — nhờ vậy event loop của FastAPI/Uvicorn không bị block, mỗi bước được flush ra SSE ngay khi hoàn thành.
Response SSE cũng được gửi kèm header Cache-Control: no-cache và X-Accel-Buffering: no để tránh bị buffer nếu sau này triển khai sau reverse proxy (nginx).
Cấu hình tham số pipeline
Trong services/RAGPipeline.py:
self.MAX_CONTEXT_CHUNKS = 50 # Số chunk tối đa đưa vào context mỗi lượt
self.MAX_TOOL_ITERATIONS = 3 # Số vòng tối đa LLM được gọi lại tool search_referenced_document
Tool tra cứu chéo văn bản
Khi LLM phát hiện ngữ cảnh nhắc tới một văn bản khác (ví dụ "theo Luật X", "hướng dẫn tại Thông tư Y") mà cần chi tiết cụ thể để trả lời chính xác, nó có thể tự gọi tool:
{
"name": "search_referenced_document",
"arguments": {
"doc_ref": "36/2015/QĐ-TTg",
"dieu_filter": "Điều 74",
"khoan_filter": "Khoản 3",
"content_query": "điều kiện áp dụng"
}
}
Pipeline gọi SearchService.doc_ref_search(...) để lấy thêm chunk liên quan, cập nhật vào context, phát lại context_ready với citation_map mới, rồi gọi lại LLM với ngữ cảnh mới — lặp lại tối đa MAX_TOOL_ITERATIONS lần trước khi buộc phải tổng hợp câu trả lời cuối cùng.
Định nghĩa tool (function calling schema)
Tool được khai báo theo chuẩn tools của OpenAI Chat Completions API và truyền vào ChatService.generate_response(..., tools=SEARCH_TOOLS). Mô tả (description) của tool và từng tham số được viết rất tường minh để LLM chỉ gọi tool khi thật sự cần thiết, tránh gọi tràn lan làm tăng độ trễ:
# Định nghĩa Tool cho LLM
SEARCH_TOOLS = [
{
"type": "function",
"function": {
"name": "search_referenced_document",
"description": (
"Tìm kiếm nội dung cụ thể trong một văn bản pháp luật được trích dẫn. "
"Sử dụng KHI VÀ CHỈ KHI ngữ cảnh hiện tại nhắc đến một văn bản khác (vd: Luật X, Thông tư Y) "
"và bạn BẮT BUỘC cần chi tiết từ văn bản đó để trả lời chính xác câu hỏi."
),
"parameters": {
"type": "object",
"properties": {
"doc_ref": {
"type": "string",
"description": "Số hiệu văn bản pháp luật ĐẦY ĐỦ (ví dụ: '36/2015/QĐ-TTg'). KHÔNG điền [1], [2].",
},
"dieu_filter": {
"type": "string",
"description": "(Tùy chọn) Chỉ ghi số điều, ví dụ 'Điều 74'.",
},
"khoan_filter": {
"type": "string",
"description": "(Tùy chọn) Chỉ ghi số khoản, ví dụ 'Khoản 3'.",
},
"content_query": {
"type": "string",
"description": "(Bắt buộc) Từ khóa hoặc chủ đề cần tìm trong văn bản đó.",
},
},
"required": ["doc_ref", "content_query"],
},
},
}
]
Ghi chú các tham số:
| Tham số | Bắt buộc | Mô tả |
|---|---|---|
doc_ref |
✅ | Số hiệu văn bản đầy đủ, ví dụ 36/2015/QĐ-TTg. LLM được yêu cầu rõ không điền số citation [N] vào đây — tránh nhầm lẫn giữa số thứ tự trích dẫn hiển thị cho người dùng và số hiệu văn bản thật. |
content_query |
✅ | Từ khóa/chủ đề cần tìm trong văn bản đó, dùng làm query cho bước semantic ranking trong doc_ref_search. |
dieu_filter |
⛔ tùy chọn | Lọc theo Điều cụ thể, ví dụ Điều 74. Được map sang tham số article_filter khi gọi SearchService.doc_ref_search. |
khoan_filter |
⛔ tùy chọn | Lọc theo Khoản cụ thể, ví dụ Khoản 3. Được map sang tham số clause_filter. |
Khi model trả về delta.tool_calls với tên hàm search_referenced_document, RAGPipeline parse JSON arguments, gọi SearchService.doc_ref_search(query=content_query, doc_ref=doc_ref, article_filter=dieu_filter, clause_filter=khoan_filter), rồi đưa kết quả trở lại messages dưới dạng tool message trước khi gọi lại LLM.
Lưu ý
- Ngữ cảnh hội thoại nhiều lượt: mỗi lượt gọi
/chat, backend chỉ đính kèm context (tài liệu RAG) tương ứng cho câu hỏi mới nhất — các câu trả lờiassistantở lượt trước vẫn nằm trongmessagesnhưng không kèm theo nguồn trích dẫn cũ. Nếu người dùng hỏi tiếp về một trích dẫn[N]ở lượt trước, model có thể không còn "nhìn thấy" đúng nguồn đó trừ khi client tự giữ và gửi lại citation map liên quan. - LLM API: cần hỗ trợ streaming (
stream=True) và function calling (tools) theo chuẩn OpenAI Chat Completions. Nếu model không thuộc dòng Qwen3 (không hỗ trợ hậu tố/no_think), chuỗi này sẽ được model coi như văn bản thường và không gây lỗi, nhưng cũng không có tác dụng tắt thinking. - Embedding/Reranker: cần endpoint tương thích OpenAI; reranker là tùy chọn.
- Dữ liệu: cần chuẩn bị đầy đủ FAISS index và các file map trong
data/trước khi chạy.
Tech stack
| Thành phần | Công nghệ |
|---|---|
| Web framework | FastAPI + Uvicorn |
| Vector search | FAISS (IndexIDMap, Inner Product, 1024-dim) |
| LLM / Embedding / Rerank | Bất kỳ endpoint tương thích OpenAI API (Qwen3 qua llama.cpp/vLLM/LM Studio, ...) |
| Streaming | Server-Sent Events (SSE) qua sse-starlette |
| Data parsing | BeautifulSoup4 |
| Templating | Jinja2 |
Đóng góp
Issue và pull request đều được hoan nghênh. Với các thay đổi lớn, vui lòng mở issue trước để thảo luận hướng tiếp cận.
Tác giả
Được phát triển bởi AnTrc2 — team Bee IT — trong khuôn khổ cuộc thi R2AI 2026 — hạng mục xây dựng trợ lý AI hỏi đáp pháp luật Việt Nam.
- Facebook: facebook.com/antrc2
- GitHub: github.com/antrc2
- Hugging Face: huggingface.co/AnTrc2


