Published on

Day 35: Metadata, Citation, Permission-aware RAG

Authors

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ómField ví dụDùng để làm gì
Identitychunk_id, document_id, tenant_idĐịnh danh, multi-tenancy, delete
Source/citationdocument_title, source_uri, source_type, page_start, page_end, section_pathRender citation và trace source
Permissionvisibility, acl_roles, acl_groups, acl_users, acl_version, acl_updated_atPermission-aware retrieval
Lifecycle/opsdocument_version, index_version, chunking_version, embedding_model, text_hash, deleted_atReindex, 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:

ModeVí dụGhi chú
Tenant-levelUser chỉ thấy data của company_aBắt buộc trong B2B SaaS
Role-levelhr, manager, financeDễ vận hành nhưng hơi coarse
Group-levelengineering-backend, sales-vnPhù hợp enterprise directory
User-levelDocument share riêng cho u123Chính xác nhưng filter lớn
Attribute-basedregion=VN, employment_type=full_timeMạ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ếuKhi dùng
Pre-filterGiảm leak vì chunk cấm không vào candidateCó thể giảm recall nếu filter/index yếuDefault cho tenant/ACL
Post-filterDefense-in-depth, độc lập với Vector DBKhông đủ làm security boundary duy nhấtLuôn bật thêm
Pre + postAn toàn hơn và dễ testPhức tạp hơn, phải benchmark latencyEnterprise 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_id map 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ứcCách làmTrade-off
Regex source idCheck [S1], [S2] có trong source_mapRẻ, bắt được citation ảo cơ bản
Sentence-level citationMỗi câu factual phải có sourceTốt hơn nhưng prompt/parse phức tạp
Claim groundingTrích claim rồi verify bằng retrieved chunksChính xác hơn, tốn thêm LLM/reranker
Human reviewSampling answer rủi ro caoChậ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áchKhi dùngGhi chú
Signed URLFile object storage, PDF/imageTTL ngắn, bind theo user/tenant nếu có thể
Source proxyWiki, SharePoint, internal docs, redactionBackend 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_at khô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_version cũ 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ọnNên dùng khiKhông nên dùng khiProduction note
Document-level ACLPermission đồng nhất trên cả documentMột document có section restrictedĐơn giản, dễ vận hành
Chunk-level ACLDocument có nhiều vùng visibilityMetadata pipeline chưa đáng tinChính xác hơn, tốn ops hơn
Pre-filter ACLSecurity quan trọngVector DB filter quá yếuDefault cho tenant/ACL
Post-filter ACLDefense-in-depthDùng một mình làm securityBắt lỗi index/filter sai
Stable source IDCần deep link/bookmark lâu dàiSource đổi version liên tụcCần version rõ
Per-response source IDDễ prompt model cite [S1]Cần citation permanentTốt cho answer rendering
Signed URLFile source trong object storageCần redact dynamic theo userTTL ngắn, audit click
Source proxyCần re-check permission/redactionLatency cực chặtAn toàn hơn raw link
Async deletionCorpus lớn, update nhiềuLegal delete cần immediate hard deleteCần tombstone trước
Permission cacheAuthz service chậmACL đổi rất thường xuyênTTL 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=5 có 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_map và 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 hr hoặc manager.
  • Pricing chỉ dành cho group salesfinance.
  • 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:

  1. Metadata schema cho document và chunk.
  2. Pre-filter và post-filter logic.
  3. Citation source map và validation rule.
  4. Signed URL/proxy flow khi user mở citation.
  5. Tombstone/delete flow.
  6. Audit log schema.
  7. Regression tests cho permission và citation.
  8. Kết luận production readiness.

Phần 1: Thiết Kế Schema

Tạo hai schema JSON: DocumentRecordChunkRecord.

DocumentRecord bắt buộc có:

  • document_id
  • tenant_id
  • title
  • source_type
  • source_uri
  • document_version
  • acl_policy_id
  • acl_version
  • status
  • deleted_at

ChunkRecord bắt buộc có:

  • chunk_id
  • document_id
  • tenant_id
  • text
  • document_title
  • page_start
  • page_end
  • section_path
  • document_version
  • chunk_index
  • chunking_version
  • embedding_model
  • index_version
  • visibility
  • acl_roles
  • acl_groups
  • acl_users
  • acl_version
  • text_hash
  • deleted_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_users thì 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 != None trả về False.
  • public_to_tenant cùng tenant trả về True.
  • restricted chỉ trả về True nếu match role, group hoặc user.
  • Metadata thiếu ACL với restricted phả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_map phải chứa chunk_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ưng source_map khô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_map theo trace_id.
  • Check source_id có 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.pdf trự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_1 khô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