Chuyển đến nội dung chính

Bài 6: Metadata, Filtering & Hybrid Search

Gắn metadata cho chunks, filtering theo field, kết hợp vector search + keyword search (BM25) cho retrieval chính xác hơn. Self-query retriever.

🧠 AI & ML — Bài 5 Bài 6: Metadata, Filtering & Hybrid Search

RAG Thực Chiến: Từ Basic đến Advanced

Phần 2: Document Processing Pipeline

xdev.asia

Giới thiệu

Bài trước bạn đã biết cách chunk tài liệu. Nhưng vector search thuần túy có 1 nhược điểm lớn: nó chỉ tìm theo ý nghĩa (semantic), không lọc được theo thuộc tính (ngày tạo, tác giả, loại tài liệu...).

Ví dụ: User hỏi "Chính sách nghỉ phép năm 2025". Vector search có thể trả về chính sách năm 2023 vì nội dung tương tự — nhưng sai năm! Metadata filtering giải quyết: year == 2025 AND category == "HR".

Bài này cover 3 kỹ thuật nâng cấp retrieval:

  1. Metadata — gắn thông tin bổ sung cho mỗi chunk
  2. Filtering — lọc chunks trước/sau khi search
  3. Hybrid Search — kết hợp vector + keyword (BM25)

1. Metadata — Gắn thông tin cho Chunks

1.1 Metadata là gì?

Mỗi chunk trong vector store gồm 3 phần:

┌─────────────────────────────────────────┐
│  Chunk                                   │
│  ├── content: "Nghỉ phép 15 ngày..."    │
│  ├── embedding: [0.12, -0.34, ...]      │  ← vector search dùng
│  └── metadata: {                         │  ← filtering dùng
│        source: "hr-policy.pdf",          │
│        page: 5,                          │
│        year: 2025,                       │
│        department: "HR",                 │
│        author: "Nguyen Van A"            │
│      }                                   │
└─────────────────────────────────────────┘

1.2 Tự động extract metadata

"""Gắn metadata khi chunk tài liệu"""
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
from datetime import datetime

# Load PDF — tự động có metadata page number
loader = PyPDFLoader("hr-policy-2025.pdf")
pages = loader.load()

# Thêm metadata custom
for page in pages:
    page.metadata.update({
        "source_type": "pdf",
        "department": "HR",
        "year": 2025,
        "language": "vi",
        "last_updated": "2025-01-15",
    })

# Chunk — metadata được kế thừa cho mỗi chunk
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(pages)

print(chunks[0].metadata)
# {'source': 'hr-policy-2025.pdf', 'page': 0,
#  'source_type': 'pdf', 'department': 'HR', 'year': 2025, ...}

1.3 Metadata nên gắn gì?

Metadata fieldVí dụUse case
source"hr-policy.pdf"Truy xuất nguồn
page5Người dùng verify
year / date2025Lọc theo thời gian
category"HR", "Finance"Lọc theo phòng ban
language"vi", "en"Multi-language RAG
author"Nguyen Van A"Lọc theo tác giả
chunk_index3Sắp xếp thứ tự
doc_type"policy", "faq"Phân loại tài liệu

💡 Bài tập 1: Load 1 thư mục chứa 5 file khác nhau (PDF, TXT, DOCX). Tự động gắn metadata gồm: source, file_type, file_size, created_date.


2. Metadata Filtering

2.1 Filter khi truy vấn

"""Filter metadata trong Chroma"""
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# Index chunks (đã có metadata)
vectorstore = Chroma.from_documents(
    chunks,
    OpenAIEmbeddings(model="text-embedding-3-small"),
    collection_name="company_docs"
)

# Search KHÔNG filter — trả về kết quả từ mọi phòng ban
results = vectorstore.similarity_search("nghỉ phép bao nhiêu ngày?", k=5)

# Search CÓ filter — chỉ tìm trong tài liệu HR năm 2025
results = vectorstore.similarity_search(
    "nghỉ phép bao nhiêu ngày?",
    k=5,
    filter={"year": 2025, "department": "HR"}  # Exact match
)

# Filter phức tạp: $and, $or, $gt, $lt, $in
results = vectorstore.similarity_search(
    "chính sách lương",
    k=5,
    filter={
        "$and": [
            {"year": {"$gte": 2024}},           # Năm >= 2024
            {"department": {"$in": ["HR", "Finance"]}},  # HR hoặc Finance
        ]
    }
)

2.2 Self-Query Retriever — Tự parse filter từ câu hỏi

"""Self-Query: AI tự tách query thành search + filter"""
from langchain.retrievers import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo
from langchain_openai import ChatOpenAI

# Mô tả metadata fields cho LLM hiểu
metadata_field_info = [
    AttributeInfo(name="year", description="Năm ban hành", type="integer"),
    AttributeInfo(name="department", description="Phòng ban: HR, Finance, IT", type="string"),
    AttributeInfo(name="doc_type", description="Loại: policy, faq, guide", type="string"),
]

retriever = SelfQueryRetriever.from_llm(
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0),
    vectorstore=vectorstore,
    document_contents="Tài liệu nội bộ công ty về chính sách và quy trình",
    metadata_field_info=metadata_field_info,
)

# User hỏi: "Chính sách HR năm 2025 về nghỉ phép"
# → LLM tự parse:
#   search_query = "chính sách nghỉ phép"
#   filter = {"year": 2025, "department": "HR"}
results = retriever.invoke("Chính sách HR năm 2025 về nghỉ phép")
Flow:
User query: "Chính sách HR năm 2025 về nghỉ phép"
                    │
          ┌─────────┴─────────┐
          │   Self-Query LLM  │
          │   (parse intent)  │
          └─────────┬─────────┘
                    │
    ┌───────────────┼───────────────┐
    │               │               │
search_query    filter_year    filter_dept
"nghỉ phép"      2025           "HR"
    │               │               │
    └───────────────┼───────────────┘
                    │
          ┌─────────┴─────────┐
          │   Vector Store    │
          │  (search+filter)  │
          └─────────┬─────────┘
                    │
              Filtered results

💡 Bài tập 2: Tạo Self-Query Retriever cho bộ tài liệu có ít nhất 3 metadata fields. Test với 5 câu hỏi tự nhiên. Kiểm tra LLM parse filter đúng không.


3. Hybrid Search — Vector + Keyword

3.1 Vấn đề của Vector Search thuần

Query: "Nghị định 168/2024/NĐ-CP"

Vector search: tìm theo ý nghĩa → có thể trả về
               Nghị định 150/2023 (nội dung tương tự nhưng SAI số!)

Keyword search (BM25): tìm đúng "168/2024/NĐ-CP" → ĐÚNG

→ Kết hợp cả 2 = Hybrid Search
Search typeMạnhYếu
VectorHiểu ý nghĩa, synonym, ngữ cảnhSai khi cần exact match (mã, số, tên)
Keyword (BM25)Exact match, mã số, proper nounsKhông hiểu synonym, ngữ cảnh
HybridKết hợp cả 2 ưu điểmCần tune weight

3.2 Implement Hybrid Search

"""Hybrid search với BM25 + Vector"""
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# Chuẩn bị documents (đã chunk)
# chunks = [Document(...), Document(...), ...]

# 1. Vector retriever
vectorstore = Chroma.from_documents(chunks, OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 2. BM25 retriever (keyword-based)
bm25_retriever = BM25Retriever.from_documents(chunks, k=5)

# 3. Ensemble (hybrid) — weight 50/50
hybrid_retriever = EnsembleRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    weights=[0.5, 0.5],  # Tùy chỉnh: 0.7/0.3 nếu ưu tiên vector
)

# Query
results = hybrid_retriever.invoke("Nghị định 168/2024/NĐ-CP")

3.3 Reciprocal Rank Fusion (RRF)

Khi kết hợp kết quả từ 2 retriever, cần 1 cách merge + rank:

Vector results:        BM25 results:
1. Doc A (score 0.95)  1. Doc C (score 8.2)
2. Doc B (score 0.88)  2. Doc A (score 7.1)
3. Doc C (score 0.82)  3. Doc D (score 6.5)

RRF formula: score(d) = Σ 1/(k + rank(d))  (k=60 default)

Doc A: 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325  ← Top 1!
Doc C: 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323  ← Top 2
Doc B: 1/(60+2) + 0       = 0.0161                      ← Top 3
Doc D: 0       + 1/(60+3) = 0.0159                      ← Top 4

Doc A xuất hiện ở cả 2 retriever → rank cao nhất!

3.4 Pinecone Hybrid Search (Production-ready)

"""Pinecone native hybrid search — sparse + dense vectors"""
from pinecone import Pinecone
from pinecone_text.sparse import BM25Encoder

# Sparse encoder (BM25)
bm25 = BM25Encoder()
bm25.fit([chunk.page_content for chunk in chunks])

# Dense encoder (embedding)
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# Index với cả 2 loại vector
pc = Pinecone(api_key="your-key")
index = pc.Index("hybrid-rag")

for chunk in chunks:
    dense = embeddings.embed_query(chunk.page_content)
    sparse = bm25.encode_queries(chunk.page_content)
    
    index.upsert(vectors=[{
        "id": chunk.metadata.get("id", str(hash(chunk.page_content))),
        "values": dense,          # Dense vector
        "sparse_values": sparse,  # Sparse vector (BM25)
        "metadata": chunk.metadata
    }])

# Query hybrid
query = "Nghị định 168/2024"
results = index.query(
    vector=embeddings.embed_query(query),
    sparse_vector=bm25.encode_queries(query),
    top_k=5,
    alpha=0.5,  # 0=pure sparse, 1=pure dense, 0.5=hybrid
)

💡 Bài tập 3: Implement hybrid search trên 1 bộ tài liệu. So sánh kết quả: (a) chỉ vector, (b) chỉ BM25, (c) hybrid. Dùng 10 câu test, ghi nhận accuracy mỗi loại.


4. Tuning Hybrid Weights

4.1 Khi nào ưu tiên Vector vs Keyword?

Use caseVector weightBM25 weightLý do
FAQ / Q&A tổng quát0.70.3User hỏi bằng nhiều cách khác nhau
Luật / Mã số0.30.7Cần exact match mã điều luật
Tài liệu kỹ thuật0.50.5Cần cả keyword lẫn semantic
Multi-language0.80.2Vector tốt hơn cho cross-language
Code documentation0.40.6Function names = keyword

4.2 Auto-tune weights

"""Benchmark hybrid weights trên golden test set"""
test_queries = [
    {"q": "nghỉ phép bao nhiêu ngày", "expected_doc": "hr-policy.pdf"},
    {"q": "Nghị định 168/2024", "expected_doc": "legal/nd168.pdf"},
    # ... 10+ câu test
]

weight_configs = [
    (0.3, 0.7), (0.4, 0.6), (0.5, 0.5),
    (0.6, 0.4), (0.7, 0.3), (0.8, 0.2),
]

best_config = None
best_accuracy = 0

for vec_w, bm25_w in weight_configs:
    hybrid = EnsembleRetriever(
        retrievers=[vector_retriever, bm25_retriever],
        weights=[vec_w, bm25_w],
    )
    
    correct = 0
    for test in test_queries:
        results = hybrid.invoke(test["q"])
        sources = [r.metadata["source"] for r in results[:3]]
        if test["expected_doc"] in sources:
            correct += 1
    
    accuracy = correct / len(test_queries)
    print(f"Vector={vec_w}, BM25={bm25_w}: {accuracy:.0%}")
    
    if accuracy > best_accuracy:
        best_accuracy = accuracy
        best_config = (vec_w, bm25_w)

print(f"\nBest: Vector={best_config[0]}, BM25={best_config[1]} ({best_accuracy:.0%})")

Tóm tắt

ConceptGhi nhớ
MetadataThông tin bổ sung gắn vào chunk (source, year, category...)
FilteringLọc chunks theo metadata trước/sau search
Self-QueryLLM tự parse câu hỏi thành search + filter
BM25Keyword search, mạnh với exact match
Hybrid SearchVector + BM25, kết hợp ưu điểm cả 2
RRFReciprocal Rank Fusion — merge kết quả 2 retriever
Weight tuningBenchmark trên golden test set để chọn tỷ lệ

Bài tập tổng hợp

  1. ✅ Hoàn thành 3 bài tập nhỏ (1, 2, 3)
  2. Full Pipeline: Load 10+ tài liệu → gắn metadata đầy đủ → index vào Chroma → implement hybrid search → so sánh accuracy vector vs hybrid trên 20 câu test.
  3. Self-Query + Hybrid: Kết hợp Self-Query Retriever với Hybrid Search. User hỏi "Chính sách HR năm 2025 về lương" → tự filter department + year + hybrid search nội dung.
  4. Dashboard: Tạo Streamlit app: upload tài liệu → tự gắn metadata → search với filter UI (dropdown chọn year, department...).

Bài tiếp theo: Query Transformation — HyDE, Multi-Query, Step-Back — biến 1 câu hỏi thành nhiều biến thể để tìm kiếm chính xác hơn.