Từ đoạn tìm được đến câu trả lời có bằng chứng trong RAG

Bài này có bản video ngắn:YouTubeTikTok

1. Tổng quan

Retrieval-Augmented Generation (RAG) thường dừng lại ở bước “tìm top-k đoạn gần nhất” rồi ném thẳng vào prompt. Cách này dễ sinh ảo giác vì vector search luôn trả về thứ gì đó gần nhất, kể cả khi chẳng đoạn nào thực sự trả lời được câu hỏi. Bài này mổ xẻ bước tiếp theo: biến các ứng viên (candidates) thành bằng chứng (evidence) đáng tin, qua hybrid search, reranking và cơ chế từ chối trả lời khi dữ liệu không đủ. Đọc xong, bạn dựng được một pipeline nhỏ bằng Spring Boot mô phỏng đầy đủ các bước này, kèm test kiểm chứng hành vi.

2. Vấn đề: gần nhất không có nghĩa là đúng

Giả sử bạn có một chatbot hỗ trợ nhân viên tra cứu chính sách hoàn tiền khi chuyến bay bị hủy. Vector search sẽ luôn trả về 30 passage “gần” nhất với câu hỏi, dù trong collection không có đoạn nào nói về hủy chuyến. Nếu hệ thống đưa thẳng top-3 đoạn đó cho LLM, model sẽ cố “bịa” ra câu trả lời nghe có vẻ hợp lý dựa trên các đoạn nói về đặt phòng khách sạn thông thường.

Hai vấn đề cần giải quyết:

  1. Chọn lọc: trong hàng chục ứng viên, đoạn nào thực sự hữu ích cho câu hỏi.
  2. Từ chối có kiểm soát: khi không đoạn nào đủ tốt, hệ thống phải nói “không tìm thấy” thay vì đoán mò.

3. Pipeline tổng thể

Câu hỏi
  → Vector search (semantic)         ─┐
  → Keyword search (BM25/exact match) ├─→ Hợp nhất (hybrid) → Rerank → Ngưỡng tin cậy → Evidence → LLM

Mỗi bước thu hẹp dần số lượng và tăng dần chất lượng: từ hàng chục ứng viên thô xuống còn vài đoạn có bằng chứng rõ ràng, kèm nguồn trích dẫn.

4. Mô hình dữ liệu cơ bản

public record Passage(
        String id,
        String text,
        String source,
        double score
) {
    public Passage withScore(double newScore) {
        return new Passage(id, text, source, newScore);
    }
}

Passage đại diện một đoạn văn bản đã được index, kèm source để trích dẫn và score để so sánh/sắp xếp. withScore giúp tạo bản sao với điểm số mới sau mỗi bước rerank mà không mutate object gốc.

5. Hybrid search: kết hợp semantic và keyword

Embedding tốt ở việc nối nghĩa (ví dụ “chuyến bay bị hủy” ↔ “gián đoạn di chuyển”), nhưng dễ bỏ sót các token chính xác như mã chính sách POL-2024-07 hay tên riêng. Keyword search (BM25) bù lại điểm yếu này.

public interface VectorSearchClient {
    List<Passage> search(String query, int topK);
}

public interface KeywordSearchClient {
    List<Passage> search(String query, int topK);
}

@Service
public class HybridSearchService {

    private final VectorSearchClient vectorClient;
    private final KeywordSearchClient keywordClient;

    public HybridSearchService(VectorSearchClient vectorClient,
                                KeywordSearchClient keywordClient) {
        this.vectorClient = vectorClient;
        this.keywordClient = keywordClient;
    }

    public List<Passage> search(String query) {
        List<Passage> semantic = vectorClient.search(query, 20);
        List<Passage> keyword = keywordClient.search(query, 20);

        Map<String, Passage> merged = new LinkedHashMap<>();
        for (Passage p : semantic) {
            merged.put(p.id(), p);
        }
        for (Passage p : keyword) {
            merged.merge(p.id(), p, (existing, incoming) ->
                    existing.withScore(existing.score() + incoming.score()));
        }
        return new ArrayList<>(merged.values());
    }
}

merge cộng điểm khi một passage xuất hiện ở cả hai nguồn — đó là tín hiệu mạnh rằng đoạn này vừa liên quan về nghĩa, vừa khớp từ khóa chính xác. Kết quả là danh sách ứng viên đã hợp nhất, chưa sắp xếp theo độ hữu ích cuối cùng.

6. Reranking: lọc lại bằng mô hình so sánh cặp

Vector search so sánh câu hỏi với từng đoạn một cách độc lập (bi-encoder), nên không “đọc” được mối liên hệ sâu giữa chúng. Reranker (thường là cross-encoder) nhận cả câu hỏi và đoạn cùng lúc, chấm điểm chính xác hơn nhưng chậm hơn — vì vậy chỉ áp dụng cho một lượng nhỏ ứng viên đã qua vòng lọc thô.

public interface Reranker {
    List<Passage> rerank(String query, List<Passage> candidates, int topN);
}

@Component
public class CrossEncoderReranker implements Reranker {

    private final CrossEncoderClient client; // gọi model rerank bên ngoài

    public CrossEncoderReranker(CrossEncoderClient client) {
        this.client = client;
    }

    @Override
    public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
        return candidates.stream()
                .map(p -> p.withScore(client.relevanceScore(query, p.text())))
                .sorted(Comparator.comparingDouble(Passage::score).reversed())
                .limit(topN)
                .toList();
    }
}

client.relevanceScore trả về điểm liên quan thực sự (ví dụ 0–1) giữa câu hỏi và từng đoạn. Sau khi sắp xếp giảm dần, ta chỉ giữ topN đoạn tốt nhất — thường là 3 đến 5, đủ ngắn để đưa vào prompt mà không làm loãng ngữ cảnh.

Lưu ý: Đừng rerank toàn bộ hàng nghìn passage — cross-encoder chậm hơn vector search hàng chục đến hàng trăm lần. Luôn giới hạn candidate đầu vào (20–50) trước khi rerank.

7. Dựng evidence kèm trích dẫn cho LLM

Sau rerank, bạn có vài đoạn chất lượng cao. Bước tiếp theo là đóng gói chúng thành context rõ ràng, có nguồn, để LLM trích dẫn lại khi trả lời thay vì “nói suông”.

@Component
public class EvidenceBuilder {

    public String build(List<Passage> topPassages) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < topPassages.size(); i++) {
            Passage p = topPassages.get(i);
            sb.append("[Nguồn %d - %s]%n%s%n%n"
                    .formatted(i + 1, p.source(), p.text()));
        }
        return sb.toString();
    }
}

Mỗi đoạn được đánh số và gắn tên nguồn (source). Khi ghép vào system prompt, bạn yêu cầu LLM chỉ trả lời dựa trên các đoạn này và trích dẫn số nguồn tương ứng — điều này giúp người dùng kiểm tra lại bằng chứng thay vì tin mù quáng vào câu trả lời.

[Nguồn 1 - policy-2024-07.pdf]
Nhân viên được hoàn 100% chi phí khách sạn phát sinh nếu chuyến bay bị hủy do thời tiết...

[Nguồn 2 - policy-2024-07.pdf]
Điều kiện áp dụng: chuyến bay phải bị hủy, không áp dụng cho trường hợp delay dưới 6 giờ...

8. Xử lý trường hợp không có câu trả lời

Đây là phần dễ bị bỏ qua nhất. Mọi collection đều có “lân cận gần nhất” — kể cả khi không đoạn nào thật sự trả lời được câu hỏi. Nếu không kiểm tra, LLM sẽ tận dụng context không liên quan để suy diễn ra một câu trả lời nghe hợp lý nhưng sai.

Giải pháp đơn giản: đặt ngưỡng tin cậy (confidence threshold) dựa trên điểm rerank cao nhất.

@Service
public class AnswerService {

    private static final double CONFIDENCE_THRESHOLD = 0.55;

    private final HybridSearchService searchService;
    private final Reranker reranker;
    private final EvidenceBuilder evidenceBuilder;
    private final LlmClient llmClient;

    public AnswerService(HybridSearchService searchService,
                          Reranker reranker,
                          EvidenceBuilder evidenceBuilder,
                          LlmClient llmClient) {
        this.searchService = searchService;
        this.reranker = reranker;
        this.evidenceBuilder = evidenceBuilder;
        this.llmClient = llmClient;
    }

    public String answer(String question) {
        List<Passage> candidates = searchService.search(question);
        List<Passage> topPassages = reranker.rerank(question, candidates, 4);

        boolean hasEvidence = !topPassages.isEmpty()
                && topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;

        if (!hasEvidence) {
            return "Mình không tìm thấy chính sách nào trả lời rõ câu hỏi này. "
                    + "Bạn nên liên hệ bộ phận nhân sự để xác nhận.";
        }

        String context = evidenceBuilder.build(topPassages);
        return llmClient.generate(question, context);
    }
}

Nếu đoạn tốt nhất sau rerank có điểm thấp hơn CONFIDENCE_THRESHOLD, hệ thống trả lời từ chối thay vì gọi LLM sinh câu trả lời không có cơ sở. Ngưỡng cụ thể nên được tinh chỉnh bằng dữ liệu thật — quá thấp sẽ gây ảo giác, quá cao sẽ từ chối cả câu hỏi đáng lẽ trả lời được.

9. Kiểm thử hành vi

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class AnswerServiceTest {

    @Test
    void shouldAbstainWhenBestScoreBelowThreshold() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage weakMatch = new Passage("p1", "Quy định đặt phòng khách sạn thường",
                "hotel-faq.pdf", 0.3);

        when(search.search(anyString())).thenReturn(List.of(weakMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(weakMatch));

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("Hoàn tiền khi chuyến bay bị hủy thế nào?");

        assertThat(result).contains("không tìm thấy chính sách");
        verifyNoInteractions(llm);
    }

    @Test
    void shouldAnswerWhenConfidenceIsHigh() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage strongMatch = new Passage("p2", "Hoàn 100% chi phí nếu chuyến bay bị hủy",
                "policy-2024-07.pdf", 0.82);

        when(search.search(anyString())).thenReturn(List.of(strongMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(strongMatch));
        when(llm.generate(anyString(), anyString())).thenReturn("Bạn được hoàn 100% [Nguồn 1].");

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("Hoàn tiền khi chuyến bay bị hủy thế nào?");

        assertThat(result).contains("Nguồn 1");
        verify(llm).generate(anyString(), anyString());
    }
}

Test đầu kiểm tra hành vi từ chối khi điểm thấp — llm không hề được gọi, tiết kiệm chi phí và tránh ảo giác. Test sau xác nhận khi có bằng chứng mạnh, hệ thống gọi LLM và câu trả lời có trích dẫn nguồn.

10. Lỗi hay gặp

  • Bỏ qua hybrid search, chỉ dùng vector thuần: dễ mất các match chính xác về mã số, tên riêng — những thứ embedding không phân biệt tốt.
  • Rerank toàn bộ tập dữ liệu thay vì tập con đã lọc: gây chậm và tốn kém không cần thiết.
  • Không đặt ngưỡng tin cậy: luôn trả lời dù context không liên quan, dẫn đến ảo giác mà người dùng khó phát hiện vì câu trả lời “nghe” rất tự tin.
  • Context quá dài: nhồi cả 30 đoạn gốc vào prompt thay vì 3–5 đoạn đã rerank, làm loãng tín hiệu và tăng chi phí token.

Lưu ý: Nếu domain của bạn có vocabulary ổn định, ít từ khóa đặc thù (ví dụ hội thoại chat thông thường), hybrid search có thể dư thừa — vector search đơn thuần vẫn đủ dùng và đơn giản hơn để vận hành.

11. Kết luận

RAG chỉ đáng tin khi bước retrieval dừng đúng lúc: hợp nhất kết quả semantic và keyword, rerank để chọn đoạn thực sự hữu ích, và biết từ chối khi không đủ bằng chứng. Pipeline nhỏ trong bài — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — là khung sườn bạn có thể cắm model thật vào (embedding, BM25 index, cross-encoder) để có một trợ lý trả lời có trích dẫn, trung thực hơn là một cỗ máy đoán chữ.

ragllmspring-bootsearchjava