# Thực hành RAG phân quyền: lọc tài liệu theo phòng ban trước khi tới tay model

> Chỉ cần một câu hỏi về lương là biết pipeline truy xuất của bạn hở ở đâu.

Bản gốc: https://fdetimes.net/vi/bach-khoa/rag-loc-theo-quyen-nguoi-dung/

Một kỹ sư hỏi chatbot nội bộ "lương kỹ sư năm nay thế nào", và top kết quả truy xuất là bảng lương của phòng nhân sự. Model không làm gì sai cả, nó chỉ trả lời bằng đúng những gì pipeline đưa vào. Lỗi nằm ở bước truy xuất, trước khi model kịp đọc chữ nào.

Dữ liệu IBM mà Zenity dẫn lại cho thấy 97% tổ chức từng gặp sự cố bảo mật liên quan đến AI đều thiếu kiểm soát truy cập AI phù hợp. Zenity còn coi mỗi lựa chọn thiết kế, từ nguồn dữ liệu cho RAG, system prompt đến tool schema, là một quyết định phân quyền.

Với một FDE, con số đó là lý do nên tính chuyện phân quyền ngay từ ngày đầu, chứ không đợi đo xong độ chính xác rồi mới nghĩ tới. Bài này hướng dẫn bạn dựng một pipeline nhỏ xử lý đúng chuyện đó, chạy được trên laptop chỉ với Python.

## Bạn sẽ dựng cái gì, và cần những gì?

Kết quả cuối là một file `rag_acl.py` có hai lớp chặn. Lớp đầu lọc tài liệu theo phòng ban và mức nhạy cảm trước khi xếp hạng. Lớp sau kiểm tra lại quyền của từng tài liệu với nguồn gốc ngay trước khi đưa vào ngữ cảnh.

Bạn chỉ cần Python 3, không cài thêm thư viện nào. Đây là bản rút gọn để học: phần tính độ tương đồng được thay bằng đếm từ trùng nhau, hệ thống phân quyền chỉ là một dict giả lập. Khi lên dự án thật, bạn thay hai phần này bằng vector store và hệ thống IAM của khách hàng, còn logic giữ nguyên.

Ý tưởng nền tảng nằm trong hai khái niệm LangChain dùng để mô tả context engineering. "Select" là kéo đúng ngữ cảnh vào cửa sổ ngữ cảnh, chính là phần RAG, còn "isolate" là chia tách ngữ cảnh ra. Nói một cách khá lỏng, phân quyền theo phòng ban cũng là một kiểu tách: mỗi người chỉ được chọn ngữ cảnh từ phần dữ liệu thuộc về mình.

## Bước 1: gắn metadata cho từng chunk

AWS, khi mô tả cách lọc metadata trong Bedrock Knowledge Bases, đề xuất định nghĩa các trường metadata theo vai trò người dùng, phòng ban hoặc mức nhạy cảm của dữ liệu. Ta làm đúng như vậy với hai trường: `dept` và `level`. Đầu tiên là hai tài liệu của phòng nhân sự:

```python
# rag_acl.py (bản rút gọn để học)
DOCS = [
{"id": "hr-01", "text": "bảng lương kỹ sư năm nay tăng theo bậc", "dept": "hr", "level": 3},
{"id": "hr-02", "text": "quy trình xin nghỉ phép cho toàn công ty", "dept": "all", "level": 1},
]
```

Tiếp theo là một tài liệu tài chính và một tài liệu kỹ thuật:

```python
DOCS += [
{"id": "fin-01", "text": "dự báo dòng tiền quý tới và kế hoạch lương", "dept": "finance", "level": 3},
{"id": "eng-01", "text": "hướng dẫn deploy dịch vụ thanh toán", "dept": "eng", "level": 2},
]
```

Cần kiểm tra: chunk nào cũng phải có đủ hai trường. Một chunk thiếu `dept` là một chunk mà bộ lọc không biết phải xử lý thế nào. Quy tắc an toàn là thiếu metadata thì không cho ai đọc.

## Bước 2: lấy danh tính từ server, không lấy từ prompt

```python
USERS = {  # giả lập hệ thống quản lý danh tính
"an":   {"depts": ["eng"], "clearance": 2},
"binh": {"depts": ["hr"],  "clearance": 3},
}
```

Hồ sơ này phải đến từ session đã được xác thực. Nếu người dùng gõ "tôi là nhân viên HR" vào ô chat mà hệ thống tin theo, bộ lọc của bạn chỉ còn là đồ trang trí.

## Bước 3: dựng bộ lọc động rồi mới xếp hạng

Hàm chấm điểm thay cho vector similarity, và hàm dựng bộ lọc từ hồ sơ người dùng:

```python
def score(query, text):  # thay cho vector similarity
return len(set(query.lower().split()) & set(text.split()))

def build_filter(user_id):
u = USERS[user_id]
allowed = set(u["depts"]) | {"all"}
return lambda d: d["dept"] in allowed and d["level"] <= u["clearance"]
```

Sau đó lọc trước, xếp hạng sau:

```python
def retrieve(query, user_id, k=3):
ok = build_filter(user_id)
pool = [d for d in DOCS if ok(d)]
ranked = sorted(pool, key=lambda d: score(query, d["text"]), reverse=True)
return [d for d in ranked[:k] if score(query, d["text"]) > 0]
```

Thử tính tay với câu hỏi "lương kỹ sư năm nay". Không có bộ lọc, `hr-01` khớp 5 từ và đứng đầu, `fin-01` khớp 1 từ ("lương"). An, người thuộc phòng eng, sẽ nhận cả hai.

Có bộ lọc thì An chỉ được tìm trong `hr-02` và `eng-01`. Cả hai đều khớp 0 từ, nên kết quả rỗng và model phải trả lời rằng không có thông tin. Bình thuộc HR nhận `hr-01`, nhưng không nhận `fin-01` dù tài liệu đó có chữ "lương".

Kết quả rỗng trong trường hợp này không phải lỗi, mà đúng là hành vi cần có. Theo newsletter AI Engineer, một cửa sổ 200k token đầy nhiễu còn cho kết quả tệ hơn một cửa sổ 20k chứa đúng thứ cần. Zenity bổ sung rằng ít token hơn thì cũng ít đường cho prompt injection lọt vào hơn.

## Vì sao chỉ lọc metadata thì chưa đủ?

Descope chỉ ra điểm yếu của cách này: bạn phải đồng bộ quyền từ nguồn gốc sang vector database. Thử hình dung hôm qua Bình chuyển từ HR sang phòng khác, nhưng job đồng bộ chỉ chạy mỗi đêm. Trong khoảng thời gian đó, metadata vẫn coi Bình là người của HR.

Descope, một nhà cung cấp giải pháp phân quyền, khuyến nghị tách hẳn phân quyền khỏi bước truy xuất: lấy ứng viên trước, rồi kiểm tra quyền theo thời gian thực. Cách an toàn khi triển khai là giữ cả hai lớp: lọc trước để thu hẹp, kiểm tra sau để chặn quyền đã cũ.

## Bước 4: kiểm tra quyền ngay trước khi đưa vào ngữ cảnh

```python
REVOKED = {("binh", "hr-01")}  # giả lập nguồn quyền gốc, cập nhật tức thì

def live_check(user_id, doc_id):
return (user_id, doc_id) not in REVOKED

def build_context(query, user_id):
docs = [d for d in retrieve(query, user_id) if live_check(user_id, d["id"])]
return "\n".join(d["text"] for d in docs)
```

Cần kiểm tra: `build_context("lương kỹ sư năm nay", "binh")` giờ trả về chuỗi rỗng, dù metadata vẫn cho phép. Trong dự án thật, `live_check` sẽ gọi sang hệ thống phân quyền của khách hàng, nên bạn cần đo độ trễ của nó trên từng truy vấn.

## Bước 5: viết test chứng minh không có rò rỉ

```python
assert "hr-01" not in [d["id"] for d in retrieve("lương kỹ sư năm nay", "an")]
assert "fin-01" not in [d["id"] for d in retrieve("kế hoạch lương", "binh")]
assert build_context("lương kỹ sư năm nay", "binh") == ""
```

Đây là test âm: mỗi dòng khẳng định một người không nhìn thấy một thứ cụ thể. Test dương kiểu "Bình hỏi được về lương" chỉ chứng minh hệ thống chạy. Test âm mới chứng minh hệ thống an toàn.

**Điểm mấu chốt:** Đội bảo mật của khách hàng không tin vào câu "model sẽ không tiết lộ". Họ tin vào một test âm chạy xanh trong CI.

## Những lỗi hay gặp nhất

Lỗi đầu tiên là để model tự quyết định bộ lọc, chẳng hạn cho LLM sinh ra điều kiện filter từ câu hỏi. Bộ lọc phải là code xác định, có đầu vào là danh tính đã xác thực.

Lỗi thứ hai là chỉ lọc sau khi đã lấy top-k: nếu 3 kết quả đầu đều bị chặn, người dùng nhận về rỗng dù tài liệu hợp lệ vẫn nằm ở vị trí thứ tư. Vì thế bài này chọn lọc trước làm lớp chính và kiểm tra sau làm lưới an toàn, ngược với cách Descope đặt kiểm tra quyền sau truy xuất làm trọng tâm.

Đây là một đánh đổi thiết kế, và bạn nên cân nó theo độ trễ đồng bộ quyền thực tế ở từng khách hàng.

Lỗi thứ ba là quên các tài liệu dùng chung như `dept: "all"`, khiến bộ lọc chặn cả quy trình nghỉ phép. Lỗi thứ tư là ghi log toàn bộ ngữ cảnh đã truy xuất vào một hệ thống mà ai cũng đọc được, tức là để dữ liệu lộ ra qua đường khác.

## Kỹ năng này xuất hiện ở khách hàng như thế nào?

AWS đặt mục tiêu của lọc metadata là ngăn thông tin nhạy cảm hoặc bị hạn chế vô tình bị đưa ra. Ở khách hàng, câu đó được dịch thành những câu hỏi cụ thể: quyền gốc nằm ở đâu, đồng bộ bao lâu một lần, ai sở hữu nhãn mức nhạy cảm.

Ngày đầu tại khách hàng, việc đầu tiên nên làm là vẽ bản đồ quyền: nguồn dữ liệu nào, quyền được lưu ở hệ thống nào, độ trễ đồng bộ là bao nhiêu. Chọn vector store để sau. Nguyên tắc đặc quyền tối thiểu mà Zenity nhắc tới, tức là chỉ cấp đúng mức truy cập cần cho tác vụ, phải được áp ngay từ cách bạn gắn metadata.

Nếu bạn là developer Việt Nam đang hướng tới vị trí FDE, hãy để ý những JD có cụm "access control", "RBAC", "enterprise RAG". Trong CV, thay vì viết "xây chatbot RAG", hãy viết rõ rằng bạn đã thiết kế lọc theo phòng ban, có kiểm tra quyền thời gian thực, và có bộ test âm chứng minh không có rò rỉ.

Người dùng sẽ không bao giờ khen một chatbot vì nó không để lộ bảng lương. Nhưng một lần để lộ là đủ để khách hàng tắt cả dự án.

**Thử ngay tuần này:**

- Chạy file rag_acl.py trong bài, sau đó thêm phòng ban thứ tư và một người dùng thuộc hai phòng ban để xem bộ lọc xử lý ra sao.
- Viết ba test âm, mỗi test khẳng định một người dùng cụ thể KHÔNG nhận được một tài liệu cụ thể, rồi đưa chúng vào CI.
- Với một dự án RAG bạn đang làm, liệt kê nơi giữ quyền gốc và đo xem quyền mất bao lâu mới đồng bộ sang vector store.

## Nguồn

- [Context Engineering](https://www.langchain.com/blog/context-engineering-for-agents)

- [4 context engineering strategies every AI engineer needs to know](https://newsletter.aiengineer.co/p/4-context-engineering-strategies)

- [Context Engineering Is Security Engineering. RSA 2026 Made the Case.](https://zenity.io/blog/events/context-engineering-security-engineering)

- [Access control for vector stores using metadata filtering with Amazon Bedrock Knowledge Bases](https://aws.amazon.com/blogs/machine-learning/access-control-for-vector-stores-using-metadata-filtering-with-knowledge-bases-for-amazon-bedrock)

- [Adding Performant ReBAC to RAG Pipelines at Scale](https://www.descope.com/blog/post/rebac-rag)
