- Published on
Day 35: Metadata, Citation, Permission-aware RAG
- Authors

- Name
- Trần Mạnh Thắng
- @TranManhThang96
Mục Tiêu
Sau bài này, bạn cần làm được những việc sau:
- Hiểu metadata là data contract của RAG, không phải field phụ.
- Thiết kế chunk schema có source metadata, page number, section heading, document version, tenant ID, ACL và user permission.
- Biết enforce permission trước khi đưa chunk vào prompt.
- Render citation có thể trace ngược về document, page, section và version thật.
- Validate citation để giảm hallucination và citation ảo.
- Thiết kế audit log phục vụ debug, compliance và incident investigation.
- Xử lý stale ACL, stale index, tombstone, delete và cache invalidation.
- Trả lời rõ: hệ thống này dùng được trong production không, và cần điều kiện gì.
TL;DR
Production RAG không được chỉ làm embed query -> vector search -> nhét top_k vào prompt. Mỗi chunk phải có metadata đủ để filter, cite, audit, version và delete. Permission-aware RAG phải enforce ACL ở backend/retriever trước khi build context, không giao cho LLM tự quyết. Citation phải do backend cấp source_id, LLM chỉ được cite source đã cấp, sau đó backend validate citation trước khi trả về người dùng.
1. Vì Sao Metadata Là Contract
Trong app CRUD, database schema quyết định record nào thuộc tenant nào, ai được đọc, trạng thái đã xóa hay chưa. Trong RAG cũng vậy, nhưng dữ liệu thường đi qua nhiều bước hơn:
source document
-> parser
-> chunker
-> metadata enricher
-> embedding
-> vector/BM25 index
-> retriever
-> context builder
-> LLM
-> answer + citation
Nếu metadata sai hoặc thiếu, lỗi không chỉ là "tìm kiếm kém". Lỗi có thể thành data leak:
- User tenant A thấy tài liệu tenant B.
- Nhân viên thường thấy tài liệu chỉ dành cho HR.
- Câu trả lời cite một page không tồn tại.
- Document đã xóa vẫn được retrieve từ vector index.
- Audit log không biết answer dựa trên chunk nào.
Rule production: field nào dùng cho security, citation, deletion hoặc audit thì phải do backend/parser sinh ra và validate, không lấy từ text do LLM đoán.
2. Metadata Tối Thiểu Cho Enterprise RAG
Một chunk production nên có bốn nhóm metadata.
| Nhóm | Field ví dụ | Dùng để làm gì |
|---|---|---|
| Identity | chunk_id, document_id, tenant_id | Định danh, multi-tenancy, delete |
| Source/citation | document_title, source_uri, source_type, page_start, page_end, section_path | Render citation và trace source |
| Permission | visibility, acl_roles, acl_groups, acl_users, acl_version, acl_updated_at | Permission-aware retrieval |
| Lifecycle/ops | document_version, index_version, chunking_version, embedding_model, text_hash, deleted_at | Reindex, rollback, tombstone, audit |
Schema gợi ý:
{
"chunk_id": "company_a:policy_001:v2026-01:00012",
"document_id": "policy_001",
"document_title": "Employee Leave Policy",
"text": "Nhân viên full-time có 12 ngày nghỉ phép năm...",
"metadata": {
"tenant_id": "company_a",
"source_type": "pdf",
"source_uri": "s3://private-kb/company_a/hr/policy.pdf",
"page_start": 12,
"page_end": 13,
"section_path": ["HR", "Leave Policy", "Annual Leave"],
"document_version": "2026-01",
"chunk_index": 12,
"chunking_version": "markdown-pdf-v3",
"embedding_model": "BAAI/bge-m3",
"index_version": "rag-index-2026-05-09",
"visibility": "restricted",
"acl_roles": ["hr", "manager"],
"acl_groups": [],
"acl_users": [],
"acl_version": "acl-2026-05-09T10:00:00Z",
"acl_updated_at": "2026-05-09T10:00:00Z",
"text_hash": "sha256:...",
"deleted_at": null,
"created_at": "2026-05-09T09:00:00Z",
"updated_at": "2026-05-09T10:00:00Z"
}
}
Không nên expose source_uri thẳng ra client nếu đó là S3 path, internal wiki URL hoặc file path nội bộ. Client nên nhận một citation object đã được backend render qua proxy hoặc signed URL ngắn hạn.
3. Permission-aware Retrieval
Permission-aware RAG có nguyên tắc đơn giản: chunk không được đọc thì không được vào prompt.
Flow chuẩn:
user auth context
-> normalize tenant, roles, groups, attributes
-> pre-filter tenant + ACL trong search query
-> retrieve candidate chunks
-> post-filter lại ở backend
-> build context + source_map
-> call LLM
-> validate citation + permission lần cuối
Các mode permission thường gặp:
| Mode | Ví dụ | Ghi chú |
|---|---|---|
| Tenant-level | User chỉ thấy data của company_a | Bắt buộc trong B2B SaaS |
| Role-level | hr, manager, finance | Dễ vận hành nhưng hơi coarse |
| Group-level | engineering-backend, sales-vn | Phù hợp enterprise directory |
| User-level | Document share riêng cho u123 | Chính xác nhưng filter lớn |
| Attribute-based | region=VN, employment_type=full_time | Mạnh nhưng cần policy engine |
Default production: deny by default. Nếu chunk thiếu tenant, thiếu ACL hoặc metadata không parse được, không retrieve chunk đó.
4. Pre-filter Và Post-filter
Pre-filter là đưa tenant/ACL vào query trước hoặc trong search:
tenant_id = current_user.tenant_id
AND deleted_at IS NULL
AND (
acl_roles intersects current_user.roles
OR acl_groups intersects current_user.groups
OR acl_users contains current_user.user_id
)
Post-filter là lấy candidate rồi lọc lại bằng code backend. Production nên dùng cả hai:
| Cách | Điểm mạnh | Điểm yếu | Khi dùng |
|---|---|---|---|
| Pre-filter | Giảm leak vì chunk cấm không vào candidate | Có thể giảm recall nếu filter/index yếu | Default cho tenant/ACL |
| Post-filter | Defense-in-depth, độc lập với Vector DB | Không đủ làm security boundary duy nhất | Luôn bật thêm |
| Pre + post | An toàn hơn và dễ test | Phức tạp hơn, phải benchmark latency | Enterprise RAG |
Nếu Vector DB không hỗ trợ filter đủ tốt, không nên dùng nó làm security boundary. Khi đó cần partition theo tenant, search service riêng hoặc authz layer trước retriever.
5. Citation Không Phải Trang Trí
Citation là audit trail của answer. Citation tốt phải trả lời được:
- Answer dựa trên chunk nào?
- Chunk thuộc document nào, version nào?
- Page/section nào trong source?
- User hiện tại có quyền mở source không?
- Link source có an toàn không?
Flow tốt:
retrieved visible chunks
-> backend gán source_id S1/S2/S3
-> build context có [S1], [S2]
-> LLM chỉ được cite source_id đã cấp
-> validator kiểm tra citation
-> renderer tạo citation link qua proxy/signed URL
Citation object nên là structured data:
{
"source_id": "S1",
"chunk_id": "company_a:policy_001:v2026-01:00012",
"document_id": "policy_001",
"title": "Employee Leave Policy",
"page_start": 12,
"page_end": 13,
"section": "HR > Leave Policy > Annual Leave",
"document_version": "2026-01",
"access_url": "/api/rag/sources/S1?trace_id=trace_123",
"expires_at": "2026-05-09T10:15:00Z"
}
Không để LLM tự tạo URL, document ID hoặc page number. LLM chỉ nên nhìn thấy nội dung context và source_id được backend cấp.
6. Citation Validation
Validator tối thiểu cần check:
- Citation id trong answer có tồn tại trong
source_map. source_idmap về chunk thật đã nằm trong context.- Chunk vẫn visible với user tại thời điểm response.
- Chunk không bị tombstone hoặc document không bị delete.
- Citation renderer không expose raw private URI.
- Answer có claim quan trọng nhưng không citation thì flag để review hoặc yêu cầu model trả lời lại.
Mức validate có thể tăng dần:
| Mức | Cách làm | Trade-off |
|---|---|---|
| Regex source id | Check [S1], [S2] có trong source_map | Rẻ, bắt được citation ảo cơ bản |
| Sentence-level citation | Mỗi câu factual phải có source | Tốt hơn nhưng prompt/parse phức tạp |
| Claim grounding | Trích claim rồi verify bằng retrieved chunks | Chính xác hơn, tốn thêm LLM/reranker |
| Human review | Sampling answer rủi ro cao | Chậm nhưng cần cho compliance |
Với capstone hoặc v1 production, regex + structured answer + regression tests là baseline hợp lý.
7. Signed URL Và Source Proxy
Citation link không nên là s3://..., path nội bộ hoặc URL wiki private. Có hai cách phổ biến:
| Cách | Khi dùng | Ghi chú |
|---|---|---|
| Signed URL | File object storage, PDF/image | TTL ngắn, bind theo user/tenant nếu có thể |
| Source proxy | Wiki, SharePoint, internal docs, redaction | Backend check permission mỗi lần mở |
Recommended flow:
client click citation
-> GET /api/rag/traces/{trace_id}/sources/{source_id}
-> backend load source_map
-> re-check user permission
-> generate signed URL or proxy page snippet
-> audit source_opened event
Điểm quan trọng: permission phải được check lại khi user mở citation, vì quyền có thể đã thay đổi sau lúc answer được tạo.
8. Versioning, Tombstone Và Delete
RAG index dễ stale vì source document, ACL, chunking hoặc embedding có thể thay đổi độc lập.
Các version cần track:
document_version: version của source document.acl_version: version permission tại thời điểm index.chunking_version: version parser/chunker.embedding_model: model tạo vector.index_version: batch/index release.text_hash: phát hiện duplicate hoặc content drift.
Delete path production:
source delete event
-> mark document/chunks as tombstone: deleted_at != null
-> invalidate query/result cache
-> remove chunks from vector index and BM25 index
-> remove or revoke generated signed URLs
-> write deletion audit event
-> run verification job: deleted chunks not retrievable
Tombstone quan trọng vì physical delete thường async. Trong khoảng thời gian job xóa chưa xong, retriever vẫn phải bỏ qua chunk có deleted_at.
9. Audit Log
Audit log không chỉ để debug. Với enterprise RAG, audit log là cách chứng minh hệ thống đã enforce permission và citation đúng.
Một query trace nên log:
{
"trace_id": "trace_123",
"timestamp": "2026-05-09T10:02:03Z",
"user_id": "u123",
"tenant_id": "company_a",
"query_hash": "sha256:...",
"query_redacted": "tôi còn bao nhiêu ngày nghỉ phép",
"permission_snapshot": {
"roles": ["employee"],
"groups": ["engineering"],
"acl_version": "acl-2026-05-09T10:00:00Z"
},
"retrieval": {
"index_version": "rag-index-2026-05-09",
"filters": ["tenant", "acl", "deleted_at"],
"retrieved_chunk_ids": ["c1", "c2"],
"post_filtered_chunk_ids": []
},
"context_source_ids": ["S1", "S2"],
"citation_ids": ["S1"],
"latency_ms": {
"embedding": 45,
"retrieval": 38,
"rerank": 120,
"llm": 1400
}
}
Không log raw prompt chứa PII nếu không có redaction, retention policy và access control. Audit log cũng là sensitive data.
10. Regression Tests Bắt Buộc
Production RAG cần test như một data system:
- Cross-tenant: user tenant A không bao giờ retrieve chunk tenant B.
- Role/group ACL: user mất role thì chunk biến mất khỏi result.
- Missing ACL: chunk thiếu permission bị deny.
- Tombstone: chunk có
deleted_atkhông vào candidate/context/citation. - Stale ACL: cache permission hết hạn hoặc bị invalidate khi ACL đổi.
- Citation hallucination: answer cite
[S99]bị reject. - Source link: citation renderer không trả raw private URI.
- Reindex: không mix
document_versioncũ với mới trong cùng answer nếu policy không cho phép. - Audit: trace có đủ
trace_id, user, filters, chunk IDs, source IDs, index version.
Các test này nên nằm trong CI cho retriever/context builder, không chỉ test prompt.
11. Trade-off Quan Trọng
| Lựa chọn | Nên dùng khi | Không nên dùng khi | Production note |
|---|---|---|---|
| Document-level ACL | Permission đồng nhất trên cả document | Một document có section restricted | Đơn giản, dễ vận hành |
| Chunk-level ACL | Document có nhiều vùng visibility | Metadata pipeline chưa đáng tin | Chính xác hơn, tốn ops hơn |
| Pre-filter ACL | Security quan trọng | Vector DB filter quá yếu | Default cho tenant/ACL |
| Post-filter ACL | Defense-in-depth | Dùng một mình làm security | Bắt lỗi index/filter sai |
| Stable source ID | Cần deep link/bookmark lâu dài | Source đổi version liên tục | Cần version rõ |
| Per-response source ID | Dễ prompt model cite [S1] | Cần citation permanent | Tốt cho answer rendering |
| Signed URL | File source trong object storage | Cần redact dynamic theo user | TTL ngắn, audit click |
| Source proxy | Cần re-check permission/redaction | Latency cực chặt | An toàn hơn raw link |
| Async deletion | Corpus lớn, update nhiều | Legal delete cần immediate hard delete | Cần tombstone trước |
| Permission cache | Authz service chậm | ACL đổi rất thường xuyên | TTL ngắn + invalidation |
12. Performance Considerations
- Metadata filter có thể làm vector search chậm nếu field không được index.
- ACL list quá lớn làm query filter phình to; group-based ACL thường tốt hơn user list dài.
- Post-filter sau
top_k=5có thể lọc hết kết quả; nên retrieve candidate lớn hơn, ví dụ 50-100, rồi rerank/lọc. - Tenant partition giảm search space nhưng tăng số collection/index cần vận hành.
- Citation rendering có thể cần fetch metadata/document page; nên batch fetch và cache có TTL.
- Audit log nên ghi async qua queue, nhưng failure phải được monitor.
- Permission cache giảm latency nhưng tăng stale ACL risk.
- Delete/reindex jobs phải idempotent để retry an toàn.
13. Dùng Được Trong Production Không?
Có, nhưng chỉ khi xem RAG như một backend data system có security boundary rõ ràng, không phải chỉ là prompt engineering.
Điều kiện tối thiểu:
- Metadata schema có tenant, ACL, source, page/section, version, tombstone và index version.
- Permission được enforce ở backend/retriever trước khi context builder.
- Default deny khi metadata thiếu hoặc permission không xác định.
- Citation do backend cấp source ID, validate trước khi trả response.
- Source link đi qua signed URL hoặc proxy có re-check permission.
- Có tombstone/delete path, cache invalidation và verification job.
- Có audit log redacted và retention policy.
- Có regression tests cho cross-tenant leak, stale ACL, deleted document và citation ảo.
- Có monitoring latency, retrieval quality, citation invalid rate và permission denied rate.
Nếu thiếu các điều kiện trên, chỉ nên gọi là prototype/internal demo. Đặc biệt, một hệ thống đưa chunk không được phép vào prompt rồi yêu cầu LLM "đừng tiết lộ" là không đạt chuẩn production.
Checklist Học Xong
- Giải thích được metadata contract trong RAG.
- Thiết kế được chunk schema có tenant, ACL, source, page, section, version.
- Phân biệt pre-filter và post-filter.
- Biết vì sao ACL phải enforce trước prompt.
- Thiết kế được
source_mapvà citation object. - Validate được citation ảo như
[S99]. - Biết dùng signed URL/proxy cho citation link.
- Thiết kế được tombstone/delete flow.
- Biết audit log cần field nào.
- Viết được regression tests cho permission-aware retrieval.
Tài liệu
Tài liệu này gom các artifact có thể dùng lại khi thiết kế enterprise RAG: schema, query flow, code mẫu gần production và test case cốt lõi.
1. Kiến Trúc Tham Khảo
Client
-> API Gateway/AuthN
-> RAG API
-> Authz/Directory service
-> Query normalizer
-> Retriever
-> Vector DB pre-filter tenant/ACL/deleted_at
-> BM25/hybrid search optional
-> Backend post-filter
-> Reranker optional
-> Context builder + source_map
-> LLM
-> Citation validator
-> Citation renderer via proxy/signed URL
-> Audit logger
Security boundary nằm ở RAG API/Retriever, không nằm trong prompt.
2. Canonical Data Model
DocumentRecord
{
"document_id": "policy_001",
"tenant_id": "company_a",
"title": "Employee Leave Policy",
"source_type": "pdf",
"source_uri": "s3://private-kb/company_a/hr/policy.pdf",
"document_version": "2026-01",
"status": "active",
"acl_policy_id": "acl_policy_789",
"acl_version": "acl-2026-05-09T10:00:00Z",
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-09T10:00:00Z",
"deleted_at": null
}
ChunkRecord
{
"chunk_id": "company_a:policy_001:v2026-01:00012",
"document_id": "policy_001",
"tenant_id": "company_a",
"text": "Nhân viên full-time có 12 ngày nghỉ phép năm...",
"metadata": {
"document_title": "Employee Leave Policy",
"source_type": "pdf",
"source_uri": "s3://private-kb/company_a/hr/policy.pdf",
"page_start": 12,
"page_end": 13,
"section_path": ["HR", "Leave Policy", "Annual Leave"],
"document_version": "2026-01",
"chunk_index": 12,
"chunking_version": "pdf-layout-v3",
"embedding_model": "BAAI/bge-m3",
"embedding_dimension": 1024,
"index_version": "rag-index-2026-05-09",
"visibility": "restricted",
"acl_roles": ["hr", "manager"],
"acl_groups": [],
"acl_users": [],
"acl_version": "acl-2026-05-09T10:00:00Z",
"text_hash": "sha256:...",
"deleted_at": null
}
}
SourceMap Entry
{
"source_id": "S1",
"chunk_id": "company_a:policy_001:v2026-01:00012",
"document_id": "policy_001",
"tenant_id": "company_a",
"title": "Employee Leave Policy",
"page_start": 12,
"page_end": 13,
"section_path": ["HR", "Leave Policy", "Annual Leave"],
"document_version": "2026-01",
"raw_source_uri": "s3://private-kb/company_a/hr/policy.pdf"
}
raw_source_uri chỉ dùng server-side. Client nhận access_url được tạo bởi renderer.
3. Code Mẫu Python
Code dưới đây không phụ thuộc framework để dễ đọc, nhưng tổ chức theo hướng có thể đưa vào service thật: auth context rõ ràng, deny-by-default, context builder tạo source map, citation validator và audit event.
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import hashlib
import re
from typing import Any
PRIVATE_URI_PREFIXES = ("s3://", "gs://", "file://", "/mnt/", "/var/")
@dataclass(frozen=True)
class UserContext:
user_id: str
tenant_id: str
roles: frozenset[str]
groups: frozenset[str]
attributes: dict[str, str]
@dataclass(frozen=True)
class Chunk:
chunk_id: str
document_id: str
text: str
metadata: dict[str, Any]
score: float = 0.0
@dataclass(frozen=True)
class SourceRef:
source_id: str
chunk_id: str
document_id: str
title: str
page_start: int | None
page_end: int | None
section_path: list[str]
document_version: str
raw_source_uri: str
def hash_for_log(value: str) -> str:
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
def _has_any(left: frozenset[str], right: list[str] | tuple[str, ...]) -> bool:
return bool(left.intersection(right))
def can_read(user: UserContext, chunk: Chunk) -> bool:
meta = chunk.metadata
if meta.get("tenant_id") != user.tenant_id:
return False
if meta.get("deleted_at"):
return False
visibility = meta.get("visibility", "restricted")
if visibility == "public_to_tenant":
return True
if visibility != "restricted":
return False
roles = meta.get("acl_roles") or []
groups = meta.get("acl_groups") or []
users = meta.get("acl_users") or []
if not roles and not groups and not users:
return False
return (
_has_any(user.roles, roles)
or _has_any(user.groups, groups)
or user.user_id in users
)
def build_vector_filter(user: UserContext) -> dict[str, Any]:
return {
"must": [
{"key": "tenant_id", "match": user.tenant_id},
{"key": "deleted_at", "is_null": True},
],
"should": [
{"key": "visibility", "match": "public_to_tenant"},
{"key": "acl_roles", "intersects": sorted(user.roles)},
{"key": "acl_groups", "intersects": sorted(user.groups)},
{"key": "acl_users", "contains": user.user_id},
],
"minimum_should_match": 1,
}
def post_filter_chunks(user: UserContext, candidates: list[Chunk]) -> list[Chunk]:
return [chunk for chunk in candidates if can_read(user, chunk)]
def build_context(
user: UserContext,
candidates: list[Chunk],
*,
max_chars: int = 6000,
) -> tuple[str, dict[str, SourceRef]]:
visible_chunks = post_filter_chunks(user, candidates)
source_map: dict[str, SourceRef] = {}
context_blocks: list[str] = []
used_chars = 0
for idx, chunk in enumerate(visible_chunks, start=1):
meta = chunk.metadata
source_id = f"S{idx}"
section_path = list(meta.get("section_path") or [])
section_text = " > ".join(section_path) if section_path else "Unknown section"
title = meta.get("document_title") or chunk.document_id
block = (
f"[{source_id}]\n"
f"Title: {title}\n"
f"Version: {meta.get('document_version', 'unknown')}\n"
f"Page: {meta.get('page_start')}-{meta.get('page_end')}\n"
f"Section: {section_text}\n"
f"Text: {chunk.text}\n"
)
if used_chars + len(block) > max_chars:
break
source_map[source_id] = SourceRef(
source_id=source_id,
chunk_id=chunk.chunk_id,
document_id=chunk.document_id,
title=title,
page_start=meta.get("page_start"),
page_end=meta.get("page_end"),
section_path=section_path,
document_version=meta.get("document_version", "unknown"),
raw_source_uri=meta.get("source_uri", ""),
)
context_blocks.append(block)
used_chars += len(block)
return "\n---\n".join(context_blocks), source_map
def validate_citations(
answer: str,
source_map: dict[str, SourceRef],
user: UserContext,
chunk_lookup: dict[str, Chunk],
) -> list[str]:
errors: list[str] = []
cited_ids = set(re.findall(r"\[(S\d+)\]", answer))
for source_id in sorted(cited_ids):
source = source_map.get(source_id)
if source is None:
errors.append(f"invalid_citation:{source_id}")
continue
chunk = chunk_lookup.get(source.chunk_id)
if chunk is None:
errors.append(f"missing_chunk:{source_id}")
continue
if not can_read(user, chunk):
errors.append(f"not_visible:{source_id}")
if answer.strip() and not cited_ids:
errors.append("missing_citation")
return errors
def render_access_url(source: SourceRef, *, trace_id: str) -> dict[str, str]:
if source.raw_source_uri.startswith(PRIVATE_URI_PREFIXES):
access_url = f"/api/rag/traces/{trace_id}/sources/{source.source_id}"
else:
access_url = f"/api/rag/traces/{trace_id}/sources/{source.source_id}"
expires_at = datetime.now(UTC) + timedelta(minutes=10)
return {
"source_id": source.source_id,
"title": source.title,
"page": str(source.page_start or ""),
"section": " > ".join(source.section_path),
"document_version": source.document_version,
"access_url": access_url,
"expires_at": expires_at.isoformat(),
}
def build_audit_event(
*,
trace_id: str,
user: UserContext,
query: str,
vector_filter: dict[str, Any],
retrieved: list[Chunk],
visible: list[Chunk],
source_map: dict[str, SourceRef],
citation_errors: list[str],
) -> dict[str, Any]:
return {
"trace_id": trace_id,
"timestamp": datetime.now(UTC).isoformat(),
"user_id": user.user_id,
"tenant_id": user.tenant_id,
"query_hash": hash_for_log(query),
"query_redacted": query[:300],
"permission_snapshot": {
"roles": sorted(user.roles),
"groups": sorted(user.groups),
},
"vector_filter": vector_filter,
"retrieved_chunk_ids": [chunk.chunk_id for chunk in retrieved],
"visible_chunk_ids": [chunk.chunk_id for chunk in visible],
"context_source_ids": list(source_map.keys()),
"citation_errors": citation_errors,
}
4. Test Cases Tối Thiểu
Các test sau có thể chuyển thẳng thành pytest.
def sample_chunks() -> list[Chunk]:
return [
Chunk(
chunk_id="company_a:policy:v1:001",
document_id="policy",
text="Nhân viên full-time có 12 ngày nghỉ phép năm.",
metadata={
"tenant_id": "company_a",
"document_title": "Leave Policy",
"source_uri": "s3://private/company_a/policy.pdf",
"page_start": 1,
"page_end": 1,
"section_path": ["HR", "Leave"],
"document_version": "v1",
"visibility": "public_to_tenant",
"deleted_at": None,
},
),
Chunk(
chunk_id="company_a:salary:v1:001",
document_id="salary",
text="Bảng lương chỉ dành cho HR.",
metadata={
"tenant_id": "company_a",
"document_title": "Salary Policy",
"source_uri": "s3://private/company_a/salary.pdf",
"page_start": 2,
"page_end": 2,
"section_path": ["HR", "Compensation"],
"document_version": "v1",
"visibility": "restricted",
"acl_roles": ["hr"],
"acl_groups": [],
"acl_users": [],
"deleted_at": None,
},
),
Chunk(
chunk_id="company_b:policy:v1:001",
document_id="policy_b",
text="Policy của tenant B.",
metadata={
"tenant_id": "company_b",
"document_title": "Tenant B Policy",
"visibility": "public_to_tenant",
"deleted_at": None,
},
),
Chunk(
chunk_id="company_a:old:v1:001",
document_id="old",
text="Document đã xóa.",
metadata={
"tenant_id": "company_a",
"document_title": "Old Policy",
"visibility": "public_to_tenant",
"deleted_at": "2026-05-09T10:00:00Z",
},
),
]
def test_employee_cannot_read_hr_or_other_tenant_or_deleted():
user = UserContext(
user_id="u1",
tenant_id="company_a",
roles=frozenset({"employee"}),
groups=frozenset({"engineering"}),
attributes={},
)
visible = post_filter_chunks(user, sample_chunks())
assert [chunk.chunk_id for chunk in visible] == ["company_a:policy:v1:001"]
def test_hr_can_read_restricted_salary_policy():
user = UserContext(
user_id="u2",
tenant_id="company_a",
roles=frozenset({"hr"}),
groups=frozenset(),
attributes={},
)
visible = post_filter_chunks(user, sample_chunks())
assert "company_a:salary:v1:001" in {chunk.chunk_id for chunk in visible}
def test_citation_validator_rejects_unknown_source():
user = UserContext("u1", "company_a", frozenset({"employee"}), frozenset(), {})
chunks = sample_chunks()
context, source_map = build_context(user, chunks)
chunk_lookup = {chunk.chunk_id: chunk for chunk in chunks}
errors = validate_citations(
"Bạn có 12 ngày nghỉ phép năm [S1]. Thông tin khác [S99].",
source_map,
user,
chunk_lookup,
)
assert "invalid_citation:S99" in errors
5. Query Flow Pseudocode
def answer_question(user: UserContext, query: str) -> dict:
trace_id = create_trace_id()
vector_filter = build_vector_filter(user)
candidates = vector_db.search(
query_embedding=embed(query),
top_k=80,
filter=vector_filter,
)
visible = post_filter_chunks(user, candidates)
reranked = rerank(query, visible)[:8]
context, source_map = build_context(user, reranked, max_chars=6000)
llm_answer = llm.generate(
system=(
"Trả lời chỉ dựa trên context. "
"Mỗi claim factual phải cite bằng [S1], [S2]. "
"Không tự tạo citation ngoài source đã có."
),
user=f"Question: {query}\n\nContext:\n{context}",
)
chunk_lookup = {chunk.chunk_id: chunk for chunk in reranked}
errors = validate_citations(llm_answer, source_map, user, chunk_lookup)
if errors:
raise CitationValidationError(errors)
citations = [
render_access_url(source, trace_id=trace_id)
for source in source_map.values()
if f"[{source.source_id}]" in llm_answer
]
audit_logger.write_async(
build_audit_event(
trace_id=trace_id,
user=user,
query=query,
vector_filter=vector_filter,
retrieved=candidates,
visible=visible,
source_map=source_map,
citation_errors=[],
)
)
return {
"trace_id": trace_id,
"answer": llm_answer,
"citations": citations,
}
6. Production Readiness Checklist
- Metadata có
tenant_id, ACL, source, page/section, version và tombstone. - Retriever dùng pre-filter cho tenant/ACL/deleted_at.
- Backend vẫn post-filter trước context builder.
- Context builder tạo
source_map, không để LLM tự tạo source. - Citation validator reject source không tồn tại hoặc không visible.
- Citation link đi qua signed URL/proxy, không expose raw private URI.
- Delete path có tombstone trước physical delete.
- Cache key có tenant/user/permission version.
- Audit log redacted, có retention và access control.
- Regression tests chạy trong CI.
Bài tập
Bối Cảnh
Bạn đang xây một RAG assistant cho công ty SaaS B2B. Mỗi customer là một tenant. Corpus gồm:
- HR policy PDF.
- Engineering runbook Markdown.
- Pricing spreadsheet export.
- Legal contract PDF.
- Customer support FAQ.
Yêu cầu bảo mật:
- User chỉ được xem document cùng tenant.
- HR document chỉ dành cho role
hrhoặcmanager. - Pricing chỉ dành cho group
salesvàfinance. - Legal contract có một số section chỉ dành cho user cụ thể.
- Document đã xóa phải biến mất khỏi retrieval gần như ngay lập tức.
- Citation phải mở được đúng document, page và section nếu user còn quyền.
Mục Tiêu
Hoàn thành một thiết kế production-oriented cho Day 35:
- Metadata schema cho document và chunk.
- Pre-filter và post-filter logic.
- Citation source map và validation rule.
- Signed URL/proxy flow khi user mở citation.
- Tombstone/delete flow.
- Audit log schema.
- Regression tests cho permission và citation.
- Kết luận production readiness.
Phần 1: Thiết Kế Schema
Tạo hai schema JSON: DocumentRecord và ChunkRecord.
DocumentRecord bắt buộc có:
document_idtenant_idtitlesource_typesource_uridocument_versionacl_policy_idacl_versionstatusdeleted_at
ChunkRecord bắt buộc có:
chunk_iddocument_idtenant_idtextdocument_titlepage_startpage_endsection_pathdocument_versionchunk_indexchunking_versionembedding_modelindex_versionvisibilityacl_rolesacl_groupsacl_usersacl_versiontext_hashdeleted_at
Câu hỏi cần trả lời:
- Field nào phải được index trong Vector DB?
- Field nào chỉ dùng để render citation?
- Field nào là security-critical?
- Nếu một chunk thiếu
acl_roles,acl_groups,acl_usersthì xử lý thế nào?
Phần 2: Sample Data
Tạo ít nhất 6 chunks:
| Chunk | Tenant | Visibility | Permission | Ghi chú | |---|---|---|---| | HR leave policy | company_a | public_to_tenant | Không cần role đặc biệt | User thường đọc được | | HR salary policy | company_a | restricted | role hr | User thường không đọc được | | Pricing playbook | company_a | restricted | group sales hoặc finance | Sales đọc được | | Legal contract section | company_a | restricted | user u_legal_1 | User cụ thể đọc được | | Tenant B policy | company_b | public_to_tenant | Tenant B | Tenant A không đọc được | | Deleted policy | company_a | public_to_tenant | Đã tombstone | Không ai retrieve được |
Tạo ít nhất 4 user contexts:
- Employee thường của
company_a. - HR của
company_a. - Sales của
company_a. - Employee của
company_b.
Phần 3: Implement Permission Logic
Viết function:
def can_read(user: UserContext, chunk: Chunk) -> bool:
...
Acceptance criteria:
- Sai tenant trả về
False. deleted_at != Nonetrả vềFalse.public_to_tenantcùng tenant trả vềTrue.restrictedchỉ trả vềTruenếu match role, group hoặc user.- Metadata thiếu ACL với
restrictedphải deny.
Sau đó viết:
def build_vector_filter(user: UserContext) -> dict:
...
def post_filter_chunks(user: UserContext, chunks: list[Chunk]) -> list[Chunk]:
...
Giải thích vì sao production nên dùng cả pre-filter và post-filter.
Phần 4: Context Builder Và Citation
Viết function:
def build_context(user: UserContext, chunks: list[Chunk], max_chars: int) -> tuple[str, dict]:
...
Yêu cầu:
- Chỉ chunk visible mới được vào context.
- Mỗi chunk trong context có source ID dạng
[S1],[S2]. source_mapphải chứachunk_id,document_id,title,page_start,page_end,section_path,document_version.- Không đưa raw private URI vào prompt nếu không cần.
Ví dụ context block:
[S1]
Title: Employee Leave Policy
Version: 2026-01
Page: 12-13
Section: HR > Leave Policy > Annual Leave
Text: Nhân viên full-time có 12 ngày nghỉ phép năm.
Phần 5: Citation Validator
Viết function:
def validate_citations(answer: str, source_map: dict, user: UserContext) -> list[str]:
...
Validator cần bắt được:
- Answer cite
[S99]nhưngsource_mapkhông có. - Answer không có citation nào dù có factual claim.
- Citation trỏ tới chunk không còn visible với user.
- Citation trỏ tới chunk đã tombstone.
Test case bắt buộc:
"Bạn có 12 ngày nghỉ phép năm [S1]. Mức lương theo bảng nội bộ [S99]."
Kết quả kỳ vọng: validator trả lỗi invalid_citation:S99.
Phần 6: Signed URL Hoặc Source Proxy
Thiết kế endpoint:
GET /api/rag/traces/{trace_id}/sources/{source_id}
Endpoint cần làm:
- Load
source_maptheotrace_id. - Check
source_idcó tồn tại. - Re-check user permission với chunk/document hiện tại.
- Nếu source là file object storage, tạo signed URL TTL ngắn.
- Nếu source là wiki/private system, proxy nội dung hoặc redirect qua backend.
- Ghi audit event
source_opened.
Trả lời:
- Vì sao không trả
s3://bucket/path.pdftrực tiếp cho client? - TTL signed URL nên dài bao lâu trong context enterprise?
- Khi user mất quyền sau khi answer được tạo, click citation phải xử lý thế nào?
Phần 7: Tombstone Và Delete Flow
Thiết kế flow khi document bị xóa:
delete event
-> mark tombstone
-> invalidate cache
-> remove vector chunks
-> remove BM25 entries
-> revoke signed URL if possible
-> write audit
-> verify not retrievable
Trả lời:
- Vì sao tombstone phải xảy ra trước physical delete?
- Cache nào cần invalidate?
- Nếu vector delete job fail giữa chừng thì retriever vẫn an toàn bằng cách nào?
Phần 8: Audit Log
Thiết kế audit event cho query:
{
"trace_id": "trace_123",
"timestamp": "2026-05-09T10:02:03Z",
"user_id": "u123",
"tenant_id": "company_a",
"query_hash": "sha256:...",
"permission_snapshot": {},
"filters": {},
"retrieved_chunk_ids": [],
"visible_chunk_ids": [],
"context_source_ids": [],
"citation_ids": [],
"citation_errors": [],
"index_version": "rag-index-2026-05-09"
}
Trả lời:
- Field nào cần redact?
- Audit log nên sync hay async?
- Ai được quyền đọc audit log?
- Retention policy nên nghĩ đến những yếu tố nào?
Phần 9: Regression Tests
Viết test checklist hoặc pytest cho các case:
- Employee tenant A không thấy document tenant B.
- Employee thường không thấy HR salary policy.
- HR thấy HR salary policy.
- Sales thấy pricing playbook.
- User không phải
u_legal_1không thấy legal restricted section. - Deleted policy không xuất hiện trong context.
- Citation
[S99]bị reject. - Citation access endpoint không trả raw private URI.
- Query cache key có tenant ID và permission version.
- Khi ACL đổi, permission cache bị invalidate hoặc hết hạn trong TTL ngắn.
Phần 10: Production Readiness Answer
Viết đoạn kết luận 8-12 dòng trả lời:
Dùng được trong production không?
Nếu có thì cần điều kiện gì?
Nếu chưa đủ thì đang thiếu gì?
Kết luận tốt cần nhắc đến:
- Metadata contract.
- Backend permission enforcement.
- Deny-by-default.
- Citation validation.
- Source proxy/signed URL.
- Tombstone/delete.
- Audit log.
- Regression tests.
- Monitoring latency và security metrics.
Gợi Ý Rubric Tự Chấm
| Tiêu chí | Đạt |
|---|---|
| Schema có đủ tenant, ACL, source, page/section, version, tombstone | |
| Permission logic deny-by-default | |
| Có cả pre-filter và post-filter | |
| Citation do backend cấp source ID | |
| Validator bắt citation ảo | |
| Source link không expose raw private URI | |
| Delete flow có tombstone và verification | |
| Audit log có trace retrieval/citation | |
| Regression tests bao phủ cross-tenant, ACL, delete, citation | |
| Kết luận production readiness rõ ràng |