Vector database có còn cần thiết khi context window ngày càng dài?

1. Tổng quan

Mỗi thế hệ mô hình ngôn ngữ lớn (LLM) lại có context window dài hơn. Vì vậy nhiều nhóm bắt đầu hỏi: nếu đưa được cả bộ tài liệu vào prompt thì có cần dựng RAG với vector database nữa không?

Bài này không trả lời “có” hay “không” một cách tuyệt đối. Bạn sẽ thấy hai cách tiếp cận khác nhau ở đâu. Bài có code Java minh hoạ cách ước lượng ngân sách token, một vector store tối thiểu để hiểu cơ chế retrieval, và một bộ chọn chiến lược đơn giản. Đọc xong, bạn có thể tự quyết định cho hệ thống của mình.

2. Hai cách đưa tri thức vào LLM

LLM không biết dữ liệu nội bộ của bạn, ví dụ chính sách hoàn tiền, tài liệu API hay lịch sử Order của Customer. Có hai cách chính để đưa dữ liệu đó vào câu trả lời.

2.1. Retrieval với vector database

Quy trình RAG (Retrieval-Augmented Generation) điển hình gồm bốn bước:

  1. Cắt tài liệu thành các đoạn nhỏ (chunk).
  2. Dùng một embedding model biến mỗi chunk thành một vector số.
  3. Lưu các vector vào vector database, có index để tìm láng giềng gần nhất.
  4. Khi người dùng hỏi, embed câu hỏi, lấy top-K chunk gần nhất rồi đưa vào prompt.

Prompt cuối cùng chỉ chứa vài đoạn liên quan, không chứa toàn bộ kho tài liệu.

2.2. Long context: “nhồi hết vào prompt”

Cách này đơn giản hơn nhiều: ghép toàn bộ tài liệu vào prompt và để mô hình tự tìm thông tin. Bạn không cần chunking, không cần embedding, không cần thêm hạ tầng.

Với kho tài liệu nhỏ, cách này thường cho kết quả tốt. Mô hình nhìn thấy toàn bộ ngữ cảnh, nên trả lời được cả những câu hỏi cần tổng hợp nhiều phần, việc mà retrieval theo top-K hay bỏ sót.

3. Vì sao câu hỏi này đang được quan tâm

Có vài lý do khiến cuộc tranh luận nóng lên:

  • Context window tăng nhanh. Những kho tài liệu từng phải chia nhỏ giờ có thể nằm gọn trong một prompt.
  • Prompt caching. Nhiều nhà cung cấp cho phép cache phần đầu prompt cố định. Nhờ vậy chi phí và độ trễ của việc gửi lại cùng một khối tài liệu giảm đáng kể.
  • RAG khó làm đúng. Chunking sai, embedding không hợp ngôn ngữ, top-K bỏ sót đoạn quan trọng là những lỗi rất phổ biến. Đội nào từng gỡ lỗi pipeline RAG đều muốn bỏ bớt một tầng phức tạp.

Long context cũng có giới hạn riêng. Chất lượng truy xuất thông tin trong prompt rất dài có thể không đồng đều: thông tin nằm giữa một khối văn bản lớn đôi khi bị bỏ qua. Chi phí và độ trễ vẫn tăng theo số token đầu vào. Ngoài ra còn những vấn đề mà context window không giải quyết được, như phân quyền và dữ liệu thay đổi liên tục. Các mục sau đi vào từng điểm.

4. Bắt đầu từ con số: ước lượng ngân sách token

Trước khi chọn kiến trúc, bạn cần biết kho tài liệu của mình “nặng” bao nhiêu token. Ví dụ dưới đây chỉ dùng Java chuẩn.

import java.util.List;

public class TokenBudget {

    // Ước lượng thô: tokenizer thật cho kết quả khác, nhất là với tiếng Việt
    private static final double CHARS_PER_TOKEN = 4.0;

    public static long estimateTokens(String text) {
        return Math.round(text.length() / CHARS_PER_TOKEN);
    }

    public static long estimateCorpus(List<String> documents) {
        return documents.stream().mapToLong(TokenBudget::estimateTokens).sum();
    }

    public static void main(String[] args) {
        List<String> policies = List.of(
            "Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi.",
            "Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng.",
            "Tài khoản Customer bị khoá sau nhiều lần đăng nhập sai."
        );
        long total = estimateCorpus(policies);
        long budget = 8_000; // phần context bạn dành cho tài liệu
        System.out.printf("Corpus ~%d tokens, budget %d, fits=%b%n",
            total, budget, total <= budget);
    }
}

Giải thích từng phần:

  • CHARS_PER_TOKEN là một heuristic rất thô. Tiếng Việt có dấu thường tốn nhiều token hơn tiếng Anh. Khi cần số chính xác, bạn nên dùng tokenizer của đúng mô hình đang dùng.
  • budget không phải toàn bộ context window. Bạn phải chừa chỗ cho system prompt, lịch sử hội thoại và câu trả lời.
  • Kết quả cho ba đoạn ngắn:
Corpus ~48 tokens, budget 8000, fits=true

Nếu fits=true và kho tài liệu ít thay đổi, long context là ứng viên nghiêm túc. Nếu kho lớn hơn ngân sách nhiều lần, bạn vẫn cần retrieval, dù context window có dài đến đâu.

5. Một vector store tối thiểu để hiểu cơ chế

Để thấy retrieval thực sự làm gì, ta viết một vector store trong bộ nhớ. Embedding ở đây là kiểu “hashing bag-of-words”, chỉ dùng để minh hoạ. Hệ thống thật sẽ gọi embedding model.

import java.util.*;

public record Document(String id, String text) {}

public record ScoredDocument(Document document, double score) {}

public class HashingEmbedder {
    private final int dimensions;

    public HashingEmbedder(int dimensions) { this.dimensions = dimensions; }

    public double[] embed(String text) {
        double[] vector = new double[dimensions];
        for (String token : text.toLowerCase().split("\\P{L}+")) {
            if (token.isBlank()) continue;
            vector[Math.floorMod(token.hashCode(), dimensions)] += 1.0;
        }
        double norm = Math.sqrt(Arrays.stream(vector).map(v -> v * v).sum());
        if (norm > 0) for (int i = 0; i < dimensions; i++) vector[i] /= norm;
        return vector;
    }
}

public class InMemoryVectorStore {
    private record Entry(Document document, double[] vector) {}

    private final HashingEmbedder embedder;
    private final List<Entry> entries = new ArrayList<>();

    public InMemoryVectorStore(HashingEmbedder embedder) { this.embedder = embedder; }

    public void add(Document document) {
        entries.add(new Entry(document, embedder.embed(document.text())));
    }

    public List<ScoredDocument> search(String query, int topK) {
        double[] q = embedder.embed(query);
        return entries.stream()
            .map(e -> new ScoredDocument(e.document(), dot(q, e.vector())))
            .sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
            .limit(topK)
            .toList();
    }

    private static double dot(double[] a, double[] b) {
        double sum = 0;
        for (int i = 0; i < a.length; i++) sum += a[i] * b[i];
        return sum;
    }
}

Các điểm chính:

  • embed tách từ theo ký tự không phải chữ cái (\\P{L}+), nên giữ nguyên chữ có dấu tiếng Việt. Mỗi từ được băm vào một chiều của vector.
  • Vector được chuẩn hoá về độ dài 1. Khi đó tích vô hướng (dot) chính là cosine similarity.
  • search duyệt toàn bộ entry. Đây là tìm kiếm vét cạn, độ phức tạp O(n). Một vector database thật dùng index xấp xỉ như HNSW để giữ tốc độ khi số vector rất lớn.

Chạy thử:

var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi."));
store.add(new Document("shipping", "Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng."));
store.add(new Document("account", "Tài khoản Customer bị khoá sau nhiều lần đăng nhập sai."));

store.search("khách hàng muốn hoàn tiền", 2)
     .forEach(r -> System.out.printf("%s -> %.3f%n", r.document().id(), r.score()));
refund -> 0.6xx
shipping -> 0.1xx

Số cụ thể phụ thuộc vào hàm băm. Thứ tự thì ổn định: refund đứng đầu vì có chung nhiều từ với câu hỏi. Chỉ đoạn này được đưa vào prompt, không phải cả ba.

Lưu ý: Embedding thật nắm được ngữ nghĩa (“trả lại tiền” gần với “hoàn tiền”), còn hashing bag-of-words thì không. Đừng dùng ví dụ trên cho production.

Trong production, nếu bạn đã dùng PostgreSQL, extension pgvector thường là bước đầu hợp lý trước khi cân nhắc một vector database chuyên dụng:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE policy_chunk (
    id        BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    content   TEXT NOT NULL,
    embedding vector(768)
);

CREATE INDEX ON policy_chunk USING hnsw (embedding vector_cosine_ops);

-- :query_embedding do ứng dụng truyền vào
SELECT id, content
FROM policy_chunk
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_embedding
LIMIT 5;

Số chiều 768 phải khớp với embedding model bạn dùng. Toán tử <=> tính khoảng cách cosine. Mệnh đề WHERE tenant_id = ... đưa ta tới một lý do quan trọng khiến retrieval vẫn cần thiết, được bàn ở mục 7.

6. Kết hợp hai cách: chọn chiến lược theo dữ liệu

Trong thực tế, câu trả lời thường là “tuỳ kho tài liệu”. Ta có thể biểu diễn quyết định này thành code để cả nhóm thống nhất tiêu chí.

public enum ContextStrategy { FULL_CONTEXT, RETRIEVAL }

public record CorpusProfile(long totalTokens,
                            boolean changesFrequently,
                            boolean needsPerUserAccess) {}

public class ContextStrategySelector {
    private final long documentBudgetTokens;

    public ContextStrategySelector(long documentBudgetTokens) {
        this.documentBudgetTokens = documentBudgetTokens;
    }

    public ContextStrategy choose(CorpusProfile profile) {
        if (profile.needsPerUserAccess()) return ContextStrategy.RETRIEVAL;
        if (profile.totalTokens() > documentBudgetTokens) return ContextStrategy.RETRIEVAL;
        if (profile.changesFrequently() && profile.totalTokens() > documentBudgetTokens / 2) {
            return ContextStrategy.RETRIEVAL; // cache prompt khó tận dụng khi dữ liệu đổi liên tục
        }
        return ContextStrategy.FULL_CONTEXT;
    }
}

Logic theo thứ tự ưu tiên:

  • Nếu mỗi người dùng chỉ được thấy một phần dữ liệu, bạn phải lọc trước khi đưa vào prompt. Retrieval có filter làm việc này tự nhiên.
  • Nếu kho lớn hơn ngân sách token, không có lựa chọn nào khác ngoài retrieval.
  • Nếu dữ liệu đổi liên tục và đã chiếm đáng kể ngân sách, prompt caching kém hiệu quả, nên retrieval thường rẻ hơn.
  • Các trường hợp còn lại: dùng long context cho đơn giản.

Ngưỡng / 2 chỉ là ví dụ. Bạn nên tự đo chi phí và chất lượng trên dữ liệu của mình rồi điều chỉnh.

7. Kiểm thử hành vi chính

Dependency test, nếu dự án dùng Spring Boot parent thì version đã được quản lý sẵn:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class RetrievalAndStrategyTest {

    @Test
    void givenRefundQuestion_whenSearch_thenRefundPolicyRanksFirst() {
        var store = new InMemoryVectorStore(new HashingEmbedder(1024));
        store.add(new Document("refund", "Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi."));
        store.add(new Document("shipping", "Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng."));

        var results = store.search("khách hàng muốn hoàn tiền", 1);

        assertThat(results).hasSize(1);
        assertThat(results.getFirst().document().id()).isEqualTo("refund");
    }

    @Test
    void givenSmallStaticCorpus_whenChoose_thenFullContext() {
        var selector = new ContextStrategySelector(50_000);
        var profile = new CorpusProfile(10_000, false, false);

        assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.FULL_CONTEXT);
    }

    @Test
    void givenPerUserAccess_whenChoose_thenRetrievalEvenIfSmall() {
        var selector = new ContextStrategySelector(50_000);
        var profile = new CorpusProfile(1_000, false, true);

        assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.RETRIEVAL);
    }
}

Test thứ ba quan trọng nhất về mặt nghiệp vụ. Dù kho tài liệu nhỏ, yêu cầu phân quyền vẫn buộc bạn dùng retrieval. Đó là một ràng buộc mà context window dài không gỡ được.

8. Khi nào vẫn cần vector database, khi nào không

Vẫn cần retrieval (vector database, hoặc vector index trong DB sẵn có) khi:

  • Kho tài liệu lớn hơn context window nhiều lần, ví dụ toàn bộ ticket hỗ trợ hay toàn bộ wiki nội bộ.
  • Có phân quyền theo người dùng hoặc tenant. Bạn không được để mô hình “nhìn thấy” dữ liệu mà người dùng không có quyền xem, dù chỉ trong prompt.
  • Dữ liệu cập nhật liên tục và cần phản ánh gần như ngay lập tức.
  • Lưu lượng truy vấn cao. Gửi một prompt khổng lồ cho mỗi request làm chi phí và độ trễ tăng theo số token.
  • Bạn cần trích dẫn nguồn rõ ràng, tức là biết câu trả lời dựa trên chunk nào.

Có thể bỏ vector database khi:

  • Kho tài liệu nhỏ, ổn định và nằm gọn trong ngân sách token, ví dụ bộ chính sách của một sản phẩm hay tài liệu một module.
  • Câu hỏi thường cần tổng hợp toàn bộ tài liệu, như “tóm tắt mọi điểm khác nhau giữa hai hợp đồng”. Top-K retrieval làm việc này kém.
  • Bạn đang làm prototype và muốn kiểm chứng giá trị trước khi đầu tư hạ tầng.

Cũng không nhất thiết phải dùng vector search khi truy vấn mang tính chính xác như mã Order, SKU hay tên hàm. Full-text search hoặc truy vấn SQL thường tốt hơn. Nhiều hệ thống dùng hybrid search, kết hợp từ khoá và vector.

9. Những lỗi hay gặp

  • Coi context window là ngân sách tài liệu. Bạn quên chừa chỗ cho system prompt, lịch sử chat và output, nên request bị cắt hoặc lỗi ở môi trường thật.
  • Nhồi hết vào prompt mà không đánh giá chất lượng. Prompt càng dài, mô hình càng dễ bỏ sót chi tiết nằm giữa. Hãy dựng một bộ câu hỏi kiểm thử có đáp án chuẩn và đo trên dữ liệu của bạn.
  • Dựng vector database chuyên dụng quá sớm. Với vài chục nghìn chunk, một vector index trong PostgreSQL thường đủ dùng, và bạn bớt được một hệ thống phải vận hành.
  • Lọc quyền sau khi retrieval. Lấy top-K rồi mới lọc theo quyền có thể làm rơi hết kết quả hợp lệ. Hãy đưa điều kiện quyền vào chính câu truy vấn, như mệnh đề WHERE tenant_id ở mục 5.
  • Đổi embedding model mà không re-index. Vector sinh từ hai model khác nhau không so sánh được với nhau.

Lưu ý: Long context và retrieval không loại trừ nhau. Một mẫu phổ biến là retrieval “rộng tay”, lấy nhiều chunk hơn trước đây, rồi tận dụng context dài để mô hình tự chọn lọc. Cách này giảm rủi ro bỏ sót mà vẫn giữ chi phí trong tầm kiểm soát.

10. Kết luận

Context window dài hơn làm thay đổi điểm cân bằng, nhưng không xoá bỏ nhu cầu retrieval. Với kho tài liệu nhỏ và ổn định, “nhồi hết vào prompt” thường đơn giản và đủ tốt. Với dữ liệu lớn, thay đổi liên tục, cần phân quyền hoặc có lưu lượng cao, retrieval vẫn cần thiết. Tuy vậy, nó không nhất thiết phải là một vector database riêng: một vector index trong database sẵn có thường là đủ.

Cách làm thực tế là đo trước rồi quyết định sau. Hãy ước lượng số token của kho tài liệu, xác định yêu cầu phân quyền và tần suất cập nhật, rồi đánh giá chất lượng trên bộ câu hỏi thật. Khi các tiêu chí đó được viết thành code như ContextStrategySelector, cả nhóm sẽ có một quyết định rõ ràng và kiểm thử được.

vector databaseRAGLLMcontext windowJava