Đây là ví dụ điển hình của sự phức tạp trong văn bản pháp luật (đặc biệt là các Nghị định, Thông tư hướng dẫn về Thuế/Hóa đơn). Cấu trúc này được gọi là Deeply Nested Structure (Cấu trúc lồng ghép sâu).
Nếu bạn chunking hời hợt (chỉ dừng ở a, b), chunk sẽ quá dài và chứa quá nhiều logic (điện, nước, ngân hàng, xăng dầu...) lẫn lộn. Khi người dùng hỏi "Quy định về hóa đơn xăng dầu", hệ thống sẽ trả về cả đoạn về "ngân hàng", làm giảm độ chính xác.
Giải pháp là Chunking theo ngữ nghĩa phân cấp (Semantic Hierarchical Chunking).
1. Chiến lược xử lý: "Breadcrumb Context" (Ngữ cảnh vụn bánh mì)
Chúng ta sẽ cắt nhỏ đến cấp độ Đoạn văn (Paragraph) hoặc Gạch đầu dòng (Bullet), nhưng mỗi mảnh nhỏ đó phải "gánh" trên lưng toàn bộ gia phả của nó.
Công thức cho mỗi Chunk sẽ là:
[Luật] + [Điều] + [Lời dẫn Khoản 3] + [Lời dẫn Điểm a] + [Lời dẫn Tiết a.1] + [Nội dung cụ thể]
2. Code Python xử lý chuyên sâu (Deep Recursive Splitter)
Dưới đây là đoạn code được thiết kế riêng để xử lý cấu trúc: Khoản $\rightarrow$ Điểm (a,b) $\rightarrow$ Tiết (a.1, b.1) $\rightarrow$ Gạch đầu dòng/Đoạn văn.
import re
def clean_text(text):
return re.sub(r'\s+', ' ', text).strip()
def recursive_legal_chunking(text, metadata_root):
"""
Hàm đệ quy để đào sâu vào cấu trúc: Khoản -> Điểm -> Tiết -> Ý
metadata_root: Chứa context cha (Luật, Điều...)
"""
chunks = []
# 1. Tách các KHOẢN (3. ...)
# Regex: Bắt đầu dòng, số, dấu chấm, khoảng trắng
clause_pattern = re.compile(r'(^|\n)(\d+\.\s)', re.MULTILINE)
clause_splits = list(clause_pattern.finditer(text))
if not clause_splits:
# Nếu không chia Khoản, trả về text gốc (Base Case)
return [{"content": text, "metadata": metadata_root}]
# Duyệt qua từng Khoản
for i, match in enumerate(clause_splits):
start = match.start()
end = clause_splits[i+1].start() if i+1 < len(clause_splits) else len(text)
clause_full_text = text[start:end].strip()
clause_id = match.group(2).strip().replace(".", "") # Lấy số "3"
# Tách Lời dẫn Khoản (Phần text trước khi bắt đầu điểm a/b)
# Regex tìm điểm a), b) hoặc a., b.
point_pattern = re.compile(r'(^|\n)([a-zđ])[\)\.]\s', re.IGNORECASE | re.MULTILINE)
point_match = point_pattern.search(clause_full_text)
if point_match:
# Có chia điểm a, b -> Tách context
clause_header_text = clause_full_text[:point_match.start()].strip()
clause_body = clause_full_text[point_match.start():]
# Cập nhật Context
current_context = f"{metadata_root['context']}\n{clause_header_text}"
# --- ĐỆ QUY CẤP 2: XỬ LÝ ĐIỂM (a, b...) ---
chunks.extend(process_points(clause_body, {
**metadata_root,
"clause_id": clause_id,
"context": current_context
}))
else:
# Khoản không chia điểm -> Chunk luôn
full_content = f"{metadata_root['context']}\n{clause_full_text}"
chunks.append({
"content": full_content,
"metadata": {**metadata_root, "clause_id": clause_id, "type": "clause_full"}
})
return chunks
def process_points(text, metadata_parent):
""" Xử lý cấp Điểm (a, b, c...) """
chunks = []
point_pattern = re.compile(r'(^|\n)([a-zđ])[\)\.]\s', re.IGNORECASE | re.MULTILINE)
point_splits = list(point_pattern.finditer(text))
for i, match in enumerate(point_splits):
start = match.start()
end = point_splits[i+1].start() if i+1 < len(point_splits) else len(text)
point_full_text = text[start:end].strip()
point_id = match.group(2) # a, b
# Kiểm tra xem có TIẾT (a.1, a.2) không
# Regex: a.1), a.1., b.1)
subpoint_pattern = re.compile(r'(^|\n)([a-zđ]\.\d+)[\)\.]\s', re.IGNORECASE | re.MULTILINE)
sub_match = subpoint_pattern.search(point_full_text)
if sub_match:
# Có tiết a.1 -> Tách Header điểm a
point_header = point_full_text[:sub_match.start()].strip()
point_body = point_full_text[sub_match.start():]
current_context = f"{metadata_parent['context']}\n{point_header}"
# --- ĐỆ QUY CẤP 3: XỬ LÝ TIẾT (a.1, b.1...) ---
chunks.extend(process_subpoints(point_body, {
**metadata_parent,
"point_id": point_id,
"context": current_context
}))
else:
# Điểm a thường (không có a.1) -> Cắt theo đoạn văn/gạch đầu dòng
# Ví dụ: Điểm b trong bài (có đoạn văn)
point_header_match = match.group(0) # "b) "
point_content_only = point_full_text.replace(point_header_match.strip(), "", 1).strip()
# Header đầy đủ của điểm b: "b) Doanh nghiệp..."
# Ở đây ta cần tách câu đầu tiên làm header nếu nó là câu dẫn
# Tuy nhiên để đơn giản, ta coi toàn bộ điểm b là context nếu nó ngắn,
# hoặc tách paragraphs. Ở đây ta gọi hàm tách paragraph.
chunks.extend(split_by_paragraphs(point_content_only, {
**metadata_parent,
"point_id": point_id,
"context": f"{metadata_parent['context']}\n{point_full_text.splitlines()[0]}" # Lấy dòng đầu làm context
}))
return chunks
def process_subpoints(text, metadata_parent):
""" Xử lý cấp Tiết (a.1, a.2...) """
chunks = []
# Regex: a.1), a.1.
sub_pattern = re.compile(r'(^|\n)([a-zđ]\.\d+)[\)\.]\s', re.IGNORECASE | re.MULTILINE)
sub_splits = list(sub_pattern.finditer(text))
for i, match in enumerate(sub_splits):
start = match.start()
end = sub_splits[i+1].start() if i+1 < len(sub_splits) else len(text)
sub_full_text = text[start:end].strip()
sub_id = match.group(2) # a.1
# Tách Header của a.1 (Dòng đầu tiên)
# VD: "a.1) Phương thức... đối với các trường hợp sau:"
lines = sub_full_text.split('\n')
sub_header = lines[0].strip()
sub_body = "\n".join(lines[1:]).strip()
current_context = f"{metadata_parent['context']}\n{sub_header}"
# --- CẤP CUỐI: CẮT THEO GẠCH ĐẦU DÒNG HOẶC ĐOẠN VĂN ---
chunks.extend(split_by_paragraphs(sub_body, {
**metadata_parent,
"subpoint_id": sub_id,
"context": current_context
}))
return chunks
def split_by_paragraphs(text, metadata):
""" Cắt text thành các ý nhỏ (Bullet points hoặc Paragraphs) """
final_chunks = []
# Tách theo dòng mới
lines = text.split('\n')
current_buffer = []
for line in lines:
line = line.strip()
if not line: continue
# Nếu là gạch đầu dòng "-" hoặc "+" -> Đây là một ý riêng biệt -> Chunk ngay
if line.startswith("-") or line.startswith("+"):
# Nếu buffer cũ có nội dung (đoạn văn trước đó), lưu lại trước
if current_buffer:
content = " ".join(current_buffer)
final_chunks.append({
"page_content": f"{metadata['context']}\n{content}",
"metadata": {**metadata, "type": "paragraph"}
})
current_buffer = []
# Chunk gạch đầu dòng này riêng
final_chunks.append({
"page_content": f"{metadata['context']}\n{line}",
"metadata": {**metadata, "type": "bullet"}
})
else:
# Là đoạn văn thường (Ví dụ: "Người bán lập bảng...", "Riêng đối với...")
# Những đoạn này thường dài và chứa quy định riêng -> Nên tách riêng
# Nếu đoạn văn dài > 20 từ -> Coi là 1 chunk riêng
if len(line.split()) > 20:
if current_buffer: # Flush buffer cũ
content = " ".join(current_buffer)
final_chunks.append({
"page_content": f"{metadata['context']}\n{content}",
"metadata": {**metadata, "type": "paragraph"}
})
current_buffer = []
# Chunk đoạn này
final_chunks.append({
"page_content": f"{metadata['context']}\n{line}",
"metadata": {**metadata, "type": "paragraph"}
})
else:
# Dòng ngắn (có thể là bị ngắt dòng) -> Gom vào buffer
current_buffer.append(line)
# Flush buffer cuối cùng
if current_buffer:
content = " ".join(current_buffer)
final_chunks.append({
"page_content": f"{metadata['context']}\n{content}",
"metadata": {**metadata, "type": "paragraph"}
})
return final_chunks
# --- DỮ LIỆU ĐẦU VÀO TỪ CÂU HỎI CỦA BẠN ---
input_text = """
3. Chuyển dữ liệu hóa đơn điện tử không có mã... (như bạn cung cấp) ...
... (toàn bộ text bạn đã paste) ...
"""
# Cấu hình Context gốc
root_meta = {
"law_name": "Nghị định 123/2020/NĐ-CP",
"article": "Điều 22", # Giả sử
"context": "Nghị định 123/2020/NĐ-CP\nĐiều 22. Trách nhiệm của người bán"
}
# Chạy hàm
result_chunks = recursive_legal_chunking(input_text, root_meta)
# --- IN KẾT QUẢ ---
print(f"Tạo được: {len(result_chunks)} chunks.\n")
for i, c in enumerate(result_chunks):
print(f"--- CHUNK {i+1} [{c['metadata'].get('type')}] ---")
print(c['page_content'])
print("-" * 20)
3. Kết quả bạn sẽ nhận được (Minh họa)
Nhờ hàm split_by_paragraphs và việc xếp chồng Context, hệ thống sẽ tạo ra các chunks cực kỳ chất lượng như sau.
Chunk A (Quy định về điện nước):
Nội dung: Nghị định 123... Điều 22... 3. Chuyển dữ liệu hóa đơn... (lời dẫn Khoản 3) a) Phương thức và thời điểm... (lời dẫn Điểm a) a.1) Phương thức chuyển dữ liệu... áp dụng đối với các trường hợp sau: (lời dẫn Tiết a.1) - Bán hàng hóa là điện, nước sạch nếu có thông tin về mã khách hàng hoặc mã số thuế của khách hàng.
Nhận xét: Khi user search "hóa đơn tiền điện", chunk này khớp 100% và có đủ ngữ cảnh là "chuyển dữ liệu theo bảng tổng hợp" (do nằm trong a.1).
Chunk B (Quy định về Bảng tổng hợp - Đoạn văn riêng):
Nội dung: ... (Context như trên) ... a.1) Phương thức chuyển dữ liệu... Người bán lập Bảng tổng hợp dữ liệu hóa đơn điện tử hàng hóa, cung cấp dịch vụ phát sinh trong tháng hoặc quý... để gửi cơ quan thuế cùng với thời gian gửi Tờ khai thuế...
Chunk C (Quy định đặc thù về Xăng dầu - Đoạn văn riêng):
Nội dung: ... (Context như trên) ... a.1) Phương thức chuyển dữ liệu... Riêng đối với trường hợp bán xăng dầu cho khách hàng thì người bán tổng hợp dữ liệu tất cả các hóa đơn bán xăng dầu trong ngày... và chuyển bảng tổng hợp dữ liệu hóa đơn điện tử này ngay trong ngày.
Nhận xét: Đây là chunk "vàng". Nếu bạn không tách đoạn văn này ra, nó sẽ bị trộn lẫn với đoạn "Người bán lập bảng...". Khi user hỏi "Xăng dầu chuyển dữ liệu khi nào?", hệ thống có thể trả lời sai là "theo tháng/quý". Nhưng khi tách riêng chunk này, hệ thống sẽ trả lời đúng là "ngay trong ngày".
4. Tóm tắt kỹ thuật
Với cấu trúc Khoản 3 như trên:
Context Stack (Ngăn xếp ngữ cảnh): Chúng ta đã "bơm" (inject) toàn bộ đường dẫn từ
Điều -> Khoản -> Điểm a -> Tiết a.1vào đầu mỗi đoạn văn con.Paragraph Splitting (Tách đoạn): Trong
a.1, chúng ta không coi cả cụm là 1 khối. Chúng ta coi Mỗi đoạn văn (newline) là một đơn vị kiến thức độc lập nếu nó đủ dài.Bullets as Entities: Mỗi gạch đầu dòng
-được coi là một chunk riêng biệt để đảm bảo sự trong sáng của dữ liệu (Atomicity).