All posts
HyDE Vượt Qua Giới Hạn Của Vector Search Thông Thường
ragai

HyDE Vượt Qua Giới Hạn Của Vector Search Thông Thường

Tien Le H.'s avatarTien Le H.
Table of Contents23 sections

Đây là bài viết tìm hiểu về kỹ thuật Hypothetical Document Embeddings (HyDE), chúng ta sẽ đi qua về nguyên lý, cách thức triển khai với Python và so sánh với dense retrieval + re-ranking. Kính mời quý anh em.

Vấn Đề Của Vector Search Thông Thường

Dense Retrieval hoạt động như thế nào

Dense retrieval là phương pháp tìm kiếm dựa trên độ tương đồng ngữ nghĩa (semantic similarity). Thay vì so khớp từ khóa như BM25, cả query và document đều được encode thành vector trong không gian chiều cao, rồi tính cosine similarity.

Document Store (đã index):
  Doc A: "Arya Stark là con gái thba ca Eddard và Catelyn Stark..."
  Doc B: "Jon Snow là con trai ngoài giá thú ca Lyanna Stark..."
  Doc C: "Daenerys Targaryen là người đứng cui cùng ca vương tc..."

          ↓ encode bng sentence-transformer

  Doc A[0.12, -0.34, 0.87, ...]
  Doc B[-0.05, 0.67, 0.23, ...]
  Doc C[0.91, 0.11, -0.45, ...]

Query: "Arya Stark là ai?"
          ↓ encode
  Query[0.08, -0.29, 0.71, ...]cosine similarity
  Score(Doc A) = 0.94cao nht
  Score(Doc B) = 0.31
  Score(Doc C) = 0.12

Semantic Gap là vấn đề cốt lõi?

Query và document có hình thức rất khác nhau:

  • Query: ngắn (3-10 từ), dạng câu hỏi, thường mơ hồ

  • Document: dài (nhiều câu), dạng mô tả/trình bày, chi tiết

Mặc dù cùng nói về cùng một thực thể, sentence-transformer encode chúng thành các vector nằm ở vùng khác nhau trong không gian embedding. Đây gọi là semantic gap khoảng cách ngữ nghĩa.

Ví dụ cụ thể — Query mơ hồ:

Query: "Cách chữa sốt"

Document thực tế: "Paracetamol (acetaminophen) là thuốc giảm đau hạ sốt
được sử dụng phổ biến nhất. Liều dùng cho người lớn là 500-1000mg
mỗi 4-6 giờ, không vượt quá 4g/ngày."

→ Query nói "chữa sốt" nhưng doc dùng thuật ngữ y khoa "giảm đau hạ sốt",
  "acetaminophen", "liều dùng" → vector query và vector doc không gần nhau.

HyDE — Hypothetical Document Embeddings

Ý tưởng cốt lõi

Thay vì encode query (ngắn, thưa) → tìm document tương tự, HyDE:

  1. Dùng LLM sinh tài liệu giả định (hypothetical document) từ query

  2. Embed tài liệu giả định đó

  3. Dùng embedding này để tìm document thật

Kết quả: Chuyển từ query → document matching (bất đối xứng) sang document → document matching (đối xứng hơn).

Pipeline chi tiết

Tại sao averaging N=5 hypothetical documents?

Sinh nhiều docs rồi trung bình hóa có 3 tác dụng:

  1. Làm mượt noise (smoothing): Mỗi hypothetical doc có thể chứa thông tin sai (hallucination), nhưng trung bình hóa sẽ loại bỏ chi tiết sai, giữ lại xu hướng ngữ nghĩa chung

  2. Ổn định hóa representation: Giảm variance từ tính ngẫu nhiên của LLM (temperature > 0)

  3. Bắt được nhiều góc nhìn: 5 docs có thể diễn đạt cùng concept theo nhiều cách khác nhau, averaging bao phủ tốt hơn

Doc giả định 1: "Paracetamol là thuc hst phbiến..."     → [v₁]
Doc giả định 2: "Khi bst, nên ung ibuprofen hoc..."      → [v₂]
Doc giả định 3: "St cao cn được điu trbng thuc..."     → [v₃]
Doc giả định 4: "Thuc gim đau acetaminophen giúp hạ..."     → [v₄]
Doc giả định 5: "Để cha st at home, bn có thdùng..."     → [v₅]

                     Average = (v₁ + v₂ + v₃ + v₄ + v₅) / 5Vector kết qunm được "domain y tế, thuc hst"
  dù tng doc riêng lcó thsai vchi tiết

Hands-on: Triển khai HyDE bằng Python

Setup

pip install haystack-ai sentence-transformers datasets numpy
import os
os.environ["OPENAI_API_KEY"] = "sk-your-key-here"

Standard Vector Search

from haystack import Pipeline, Document
from haystack.components.embedders import (
    SentenceTransformersDocumentEmbedder,
    SentenceTransformersTextEmbedder,
)
from haystack.components.preprocessors import DocumentCleaner, DocumentSplitter
from haystack.components.writers import DocumentWriter
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
from datasets import load_dataset

# --- Indexing Pipeline ---
EMBEDDER_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
document_store = InMemoryDocumentStore()

indexing = Pipeline()
indexing.add_component("cleaner", DocumentCleaner())
indexing.add_component("splitter", DocumentSplitter(split_by="sentence", split_length=10))
indexing.add_component("embedder", SentenceTransformersDocumentEmbedder(model=EMBEDDER_MODEL))
indexing.add_component("writer", DocumentWriter(document_store=document_store))

indexing.connect("cleaner", "splitter")
indexing.connect("splitter", "embedder")
indexing.connect("embedder", "writer")

# Index Game of Thrones dataset
data = load_dataset("Tuana/game-of-thrones")
indexing.run({
    "cleaner": {
        "documents": [Document.from_dict(doc) for doc in data["train"]]
    }
})

# --- Retrieval Pipeline (Standard) ---
standard_retrieval = Pipeline()
standard_retrieval.add_component(
    "query_embedder",
    SentenceTransformersTextEmbedder(model=EMBEDDER_MODEL),
)
standard_retrieval.add_component(
    "retriever",
    InMemoryEmbeddingRetriever(document_store=document_store),
)
standard_retrieval.connect("query_embedder.embedding", "retriever.query_embedding")

# Run
query = "Who is Arya Stark?"
results = standard_retrieval.run({
    "query_embedder": {"text": query},
    "retriever": {"top_k": 5},
})

for doc in results["retriever"]["documents"]:
    print(f"[Score: {doc.score:.4f}] {doc.content[:100]}...")

HyDE Retrieval

from haystack import Pipeline, component, Document
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.converters import OutputAdapter
from haystack.components.embedders import SentenceTransformersDocumentEmbedder
from haystack.dataclasses import ChatMessage
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
from typing import List
from numpy import array, mean


# --- Custom HyDE Component ---
@component
class HypotheticalDocumentEmbedder:

    def __init__(
        self,
        instruct_llm: str = "gpt-4o-mini",
        nr_completions: int = 5,
        embedder_model: str = "sentence-transformers/all-MiniLM-L6-v2",
    ):
        self.nr_completions = nr_completions
        self.embedder_model = embedder_model

        self.generator = OpenAIChatGenerator(
            model=instruct_llm,
            generation_kwargs={
                "n": self.nr_completions,
                "temperature": 0.75,
                "max_tokens": 400,
            },
        )

        template = [
            ChatMessage.from_user(
                """Given a question, generate a paragraph of text \
that answers the question.
Question: {{question}}
Paragraph:"""
            )
        ]
        self.prompt_builder = ChatPromptBuilder(template=template)

        self.adapter = OutputAdapter(
            template="{{answers | build_doc}}",
            output_type=List[Document],
            custom_filters={
                "build_doc": lambda data: [Document(content=d) for d in data]
            },
        )

        self.embedder = SentenceTransformersDocumentEmbedder(
            model=embedder_model, progress_bar=False
        )

        # Internal pipeline: prompt → LLM → adapter → embedder
        self.pipeline = Pipeline()
        self.pipeline.add_component("prompt_builder", self.prompt_builder)
        self.pipeline.add_component("generator", self.generator)
        self.pipeline.add_component("adapter", self.adapter)
        self.pipeline.add_component("embedder", self.embedder)
        self.pipeline.connect("prompt_builder", "generator")
        self.pipeline.connect("generator.replies", "adapter.answers")
        self.pipeline.connect("adapter.output", "embedder.documents")

    @component.output_types(hypothetical_embedding=List[float])
    def run(self, query: str):
        result = self.pipeline.run(
            data={"prompt_builder": {"question": query}}
        )

        # Average N hypothetical document embeddings
        docs = result["embedder"]["documents"]
        stacked = array([doc.embedding for doc in docs])
        avg_embedding = mean(stacked, axis=0)

        return {"hypothetical_embedding": avg_embedding.tolist()}


# --- HyDE Retrieval Pipeline ---
hyde_retrieval = Pipeline()
hyde_retrieval.add_component("hyde", HypotheticalDocumentEmbedder(nr_completions=5))
hyde_retrieval.add_component(
    "retriever",
    InMemoryEmbeddingRetriever(document_store=document_store),
)
hyde_retrieval.connect("hyde.hypothetical_embedding", "retriever.query_embedding")

# Run
query = "Who is Arya Stark?"
results_hyde = hyde_retrieval.run({
    "hyde": {"query": query},
    "retriever": {"top_k": 5},
})

for doc in results_hyde["retriever"]["documents"]:
    print(f"[Score: {doc.score:.4f}] {doc.content[:100]}...")

So sánh kết quả

def compare_results(query, standard_results, hyde_results):
    """So sánh scores giữa standard và HyDE retrieval."""
    print(f"Query: {query}\n")
    print(f"{'Standard Search':<30} {'HyDE Search':<30}")
    print(f"{'-'*30} {'-'*30}")

    std_docs = standard_results["retriever"]["documents"]
    hyde_docs = hyde_results["retriever"]["documents"]

    for i in range(5):
        std_score = std_docs[i].score if i < len(std_docs) else 0
        hyde_score = hyde_docs[i].score if i < len(hyde_docs) else 0

        std_preview = std_docs[i].content[:25] if i < len(std_docs) else ""
        hyde_preview = hyde_docs[i].content[:25] if i < len(hyde_docs) else ""

        print(f"#{i+1} [{std_score:.4f}] {std_preview:<18} "
              f"[{hyde_score:.4f}] {hyde_preview}")

    # Check overlap
    std_ids = {d.id for d in std_docs}
    hyde_ids = {d.id for d in hyde_docs}
    overlap = std_ids & hyde_ids
    print(f"\nOverlap: {len(overlap)}/{len(std_ids)} docs appear in both")
    if hyde_ids - std_ids:
        print(f"HyDE found {len(hyde_ids - std_ids)} NEW docs not in standard results")


compare_results(query, results, results_hyde)

Phân Tích Kỹ Thuật: Tại Sao HyDE Tăng Độ Tương Quan

Từ góc nhìn không gian vector

Ngay cả khi LLM "sai" thì... HyDE vẫn hiệu quả

Đây là điểm counter-intuitive quan trọng nhất. Hypothetical document có thể sai về facts, nhưng HyDE vẫn hoạt động vì sentence-transformer encode hướng ngữ nghĩa (topic, domain, entities), không phải facts chính xác.

Ví dụ: LLM nói "Arya Stark là con gái của Robert Baratheon" (sai) nhưng vector vẫn nằm ở khu vực "Game of Thrones, nhân vật, gia đình Stark" (đúng domain). Averaging 5 docs làm mượt chi tiết sai, giữ lại xu hướng chung

Đối tượng

Factually Correct?

Vector Direction?

Cosine Sim

Doc giả 1

✓ đúng

đúng hướng

cao

Doc giả 2

✗ sai facts

đúng hướng

khá cao

Doc giả 3

~ đúng một phần

đúng hướng

khá cao

Doc giả 4

✓ đúng

đúng hướng

cao

Doc giả 5

✗ sai facts

đúng hướng

khá cao

Average

không quan trọng

ĐÚNG HƯỚNG

CAO ✓

Sentence-transformer "không quan tâm" facts, nó quan tâm semantic direction

Giới hạn của HyDE

  • Nếu LLM hoàn toàn hiểu sai query và sinh doc ở domain khác, HyDE sẽ retrieve sai

  • Với queries đã rõ ràng và specific, standard retrieval thường đủ tốt, HyDE thêm cost không cần thiết

  • Mỗi query cần gọi LLM (thêm ~0.5-2s, tốn API cost)

So Sánh Với Re-ranking (Cross-Encoder)

Re-ranking là gì

Re-ranking là chiến lược post-retrieval, bi-encoder lấy Top-K nhanh, rồi dùng cross-encoder chấm lại chính xác từng cặp (query, doc).

Hands-on: Thêm Re-ranking

# Cài đặt thêm
# pip install sentence-transformers

from sentence_transformers import CrossEncoder

# Load cross-encoder model
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank_results(query: str, documents: list, top_n: int = 5):
    """Re-rank retrieved documents using cross-encoder."""
    # Tạo cặp (query, doc) cho cross-encoder
    pairs = [(query, doc.content) for doc in documents]

    # Chấm điểm từng cặp
    scores = cross_encoder.predict(pairs)

    # Sắp xếp lại theo score
    scored_docs = list(zip(documents, scores))
    scored_docs.sort(key=lambda x: x[1], reverse=True)

    return scored_docs[:top_n]


# Re-rank kết quả từ standard search
query = "Who is Arya Stark?"
standard_results = standard_retrieval.run({
    "query_embedder": {"text": query},
    "retriever": {"top_k": 20},  # Lấy rộng hơn, để re-rank lọc lại
})

reranked = rerank_results(
    query,
    standard_results["retriever"]["documents"],
    top_n=5,
)

print("After Re-ranking:")
for doc, score in reranked:
    print(f"  [Cross-Encoder: {score:.4f}] {doc.content[:100]}...")

So sánh ba phương pháp

Khía cạnh

Standard

HyDE

Re-ranking

HyDE + Re-ranking

Recall

Baseline

Tăng

Giữ nguyên

Tăng

Precision

Baseline

Tăng nhẹ

Tăng mạnh

Tăng mạnh

Latency thêm

~0.5-2s (LLM)

~50-200ms

Cộng dồn

Cost

$$$ (LLM API)

$ (local model)

Chạy local?

✗ (cần LLM)

Cần LLM

Lấy lại doc đã miss?

5.4 Khi nào dùng gì

Bối cảnh / Mục tiêu

Query rõ ràng, corpus chất lượng

Query mơ hồ, corpus phức tạp

Chỉ cần OK

Standard ✓

Cần precision tốt

+ Re-ranking ✓

Cần recall tốt

HyDE ✓

Cần cả hai

HyDE + Re-ranking ✓

Budget hạn chế

+ Re-ranking ✓

Thử HyDE với cheap LLM (gpt-4o-mini)

6. Tổng Kết

HyDE giải quyết semantic gap bằng cách chuyển query thành document-format representation trước khi retrieval. Điểm mạnh nhất là tăng recall — lấy được docs mà standard search bỏ sót. Trade-off chính là chi phí LLMlatency thêm.

Re-ranking giải quyết precision bằng cách chấm lại từng cặp (query, doc) với cross-attention. Điểm mạnh là tăng precision ở top-K với chi phí thấp, nhưng không thể lấy lại docs đã miss ở bước retrieval ban đầu.

Kết hợp cả hai cho kết quả tốt nhất: HyDE cải thiện recall ở bước retrieval, Re-ranking cải thiện precision ở bước post-processing.

Tham Khảo