Embedding tìm ý nghĩa: giải thích chi tiết kèm ví dụ
1. Tổng quan
Khi bạn hỏi một chatbot “Tôi bị hủy chuyến bay, tiền khách sạn có được hoàn không?”, hệ thống RAG cần tìm đúng đoạn chính sách nói về “gián đoạn hành trình” — dù câu hỏi không chứa từ nào trùng khớp 100% với văn bản gốc. Bài này giải thích cơ chế embedding giúp làm được điều đó: biến văn bản thành vector, so khớp theo khoảng cách ngữ nghĩa, và lưu trữ sao cho LLM vẫn truy ngược được văn bản gốc. Đọc xong, bạn sẽ tự viết được một pipeline tìm kiếm ngữ nghĩa đơn giản bằng Java, kèm test minh chứng nó hoạt động đúng.
2. Embedding là gì
Embedding model nhận một đoạn text và trả về một vector — một dãy số thực, thường vài trăm đến vài nghìn chiều. Vector này không phải là “từ khóa nén lại” mà là một điểm trong không gian nhiều chiều, nơi các đoạn văn có ý nghĩa gần nhau sẽ có vector nằm gần nhau.
public record EmbeddingVector(float[] values) {
public int dimension() {
return values.length;
}
}
EmbeddingVector chỉ là wrapper cho float[], nhưng có nó giúp bạn tránh truyền tay float[] lẫn lộn giữa các model khác nhau — một lỗi rất dễ gặp khi hệ thống lớn dần.
2.1. Vì sao không nên nghĩ embedding như một cái “bản đồ” có nhãn rõ ràng
Trên bản đồ thật, bạn có trục kinh độ, vĩ độ, có nhãn “Hà Nội”, “Đà Nẵng”. Với embedding, từng chiều riêng lẻ không mang nhãn như “khách sạn”, “chuyến bay”, “hoàn tiền”. Ý nghĩa chỉ xuất hiện qua pattern tổng hợp của toàn bộ vector. Vì vậy bạn không thể “đọc” một chiều để biết nó đại diện cho cái gì — bạn chỉ có thể so sánh toàn vector với vector khác.
Điều này quan trọng vì nó giải thích tại sao bạn không nên tự ý cắt bớt vector (ví dụ chỉ giữ 100/1536 chiều để tiết kiệm bộ nhớ) — làm vậy phá vỡ pattern và độ chính xác tìm kiếm giảm mạnh.
3. So khớp theo khoảng cách ngữ nghĩa
Hai vector “gần nhau” thường được đo bằng cosine similarity — càng gần 1 thì càng giống về ý nghĩa.
public final class VectorMath {
public static double cosineSimilarity(float[] a, float[] b) {
if (a.length != b.length) {
throw new IllegalArgumentException(
"Vector dimension mismatch: %d vs %d".formatted(a.length, b.length));
}
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
- Dòng
if (a.length != b.length): chặn ngay lỗi phổ biến — so sánh vector từ hai model khác nhau (ví dụ một bên 768 chiều, một bên 1536 chiều) sẽ sinh lỗi runtime khó debug nếu không check sớm. dot,normA,normB: tính tích vô hướng và độ dài từng vector theo công thức cosine chuẩn.
Lưu ý: hai model có cùng số chiều không có nghĩa là tương thích. Model A và model B đều xuất ra vector 1536 chiều, nhưng không gian ngữ nghĩa của chúng khác nhau — so sánh chéo sẽ cho kết quả vô nghĩa dù code chạy không lỗi.
4. Mô phỏng embedding model để thực hành không cần gọi API thật
Để ví dụ chạy được ngay, mình dùng một EmbeddingClient giả lập dựa trên tần suất từ khóa thuộc “chủ đề” (travel, password, billing). Trong thực tế bạn sẽ thay bằng OpenAI, Cohere hay model mã nguồn mở, nhưng interface giữ nguyên.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {
private static final List<String> TRAVEL_WORDS =
List.of("chuyến bay", "hủy", "khách sạn", "hành trình", "sân bay");
private static final List<String> PASSWORD_WORDS =
List.of("mật khẩu", "đăng nhập", "tài khoản", "reset", "quên");
private static final List<String> BILLING_WORDS =
List.of("hoàn tiền", "thanh toán", "hóa đơn", "chi phí", "phí");
@Override
public EmbeddingVector embed(String text) {
String normalized = text.toLowerCase();
float travel = score(normalized, TRAVEL_WORDS);
float password = score(normalized, PASSWORD_WORDS);
float billing = score(normalized, BILLING_WORDS);
return new EmbeddingVector(new float[] { travel, password, billing });
}
private float score(String text, List<String> keywords) {
long matches = keywords.stream().filter(text::contains).count();
return matches / (float) keywords.size();
}
}
KeywordEmbeddingClient chỉ dùng 3 chiều (travel/password/billing) để dễ quan sát — embedding thật sự dùng hàng trăm chiều học từ dữ liệu, nhưng ý tưởng “văn bản có ý nghĩa gần nhau → vector gần nhau” là giống nhau.
5. Lưu trữ embedding kèm metadata để LLM truy ngược văn bản gốc
Vector chỉ giúp định vị đoạn text, còn LLM vẫn cần nội dung chữ để trả lời. Vì vậy mỗi record trong vector store phải nối embedding với chunk gốc và metadata.
public record DocumentChunk(
String chunkId,
String documentId,
String text,
String section,
LocalDate effectiveDate,
int version,
EmbeddingVector embedding
) {}
chunkIdvàdocumentId: cho phép truy lại toàn bộ tài liệu khi cần hiển thị nguồn hoặc cập nhật version mới.section,effectiveDate,version: giúp lọc trước khi so khớp ngữ nghĩa — ví dụ chỉ tìm trong chính sách còn hiệu lực, bỏ qua bản cũ đã hết hạn.text: bắt buộc phải lưu cùng, vì LLM không “đọc” được vector — nó cần câu chữ thật để sinh câu trả lời.
public class InMemoryVectorStore {
private final List<DocumentChunk> chunks = new ArrayList<>();
public void add(DocumentChunk chunk) {
chunks.add(chunk);
}
public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
return chunks.stream()
.sorted(Comparator.comparingDouble(
(DocumentChunk c) -> -VectorMath.cosineSimilarity(
query.values(), c.embedding().values())))
.limit(topK)
.toList();
}
}
findNearest sắp xếp các chunk theo cosine similarity giảm dần (dấu - để đảo chiều Comparator) và lấy topK kết quả gần nhất — đây chính là bước “retrieval” trong RAG.
6. Ví dụ đầy đủ: câu hỏi khác chữ nhưng đúng ý
public class SemanticSearchDemo {
public static void main(String[] args) {
EmbeddingClient embedder = new KeywordEmbeddingClient();
InMemoryVectorStore store = new InMemoryVectorStore();
store.add(new DocumentChunk(
"chunk-1", "policy-travel", "Chính sách hoàn tiền khi chuyến bay bị hủy hoặc hành trình gián đoạn.",
"Travel Disruption", LocalDate.of(2026, 1, 1), 2,
embedder.embed("Chính sách hoàn tiền khi chuyến bay bị hủy hoặc hành trình gián đoạn.")
));
store.add(new DocumentChunk(
"chunk-2", "policy-account", "Hướng dẫn reset mật khẩu khi quên tài khoản đăng nhập.",
"Account Recovery", LocalDate.of(2025, 6, 1), 1,
embedder.embed("Hướng dẫn reset mật khẩu khi quên tài khoản đăng nhập.")
));
String question = "Tôi bị hủy chuyến bay, tiền khách sạn đã đặt có được hoàn không?";
EmbeddingVector queryVector = embedder.embed(question);
List<DocumentChunk> results = store.findNearest(queryVector, 1);
System.out.println(results.get(0).text());
}
}
Chạy chương trình này in ra:
Chính sách hoàn tiền khi chuyến bay bị hủy hoặc hành trình gián đoạn.
Câu hỏi không chứa từ “chính sách” hay “hành trình”, nhưng vì câu hỏi và văn bản cùng nằm gần nhau trong không gian ngữ nghĩa (chiều “travel” cao ở cả hai), hệ thống chọn đúng chunk — bỏ qua hoàn toàn chunk nói về reset mật khẩu.
7. Kiểm chứng bằng test
class SemanticSearchTest {
private final EmbeddingClient embedder = new KeywordEmbeddingClient();
private final InMemoryVectorStore store = new InMemoryVectorStore();
@BeforeEach
void setUp() {
store.add(new DocumentChunk(
"chunk-1", "policy-travel",
"Chính sách hoàn tiền khi chuyến bay bị hủy hoặc hành trình gián đoạn.",
"Travel Disruption", LocalDate.of(2026, 1, 1), 2,
embedder.embed("Chính sách hoàn tiền khi chuyến bay bị hủy hoặc hành trình gián đoạn.")
));
store.add(new DocumentChunk(
"chunk-2", "policy-account",
"Hướng dẫn reset mật khẩu khi quên tài khoản đăng nhập.",
"Account Recovery", LocalDate.of(2025, 6, 1), 1,
embedder.embed("Hướng dẫn reset mật khẩu khi quên tài khoản đăng nhập.")
));
}
@Test
void shouldMatchTravelPolicyEvenWithoutSharedWords() {
String question = "Tôi bị hủy chuyến bay, tiền khách sạn đã đặt có được hoàn không?";
EmbeddingVector queryVector = embedder.embed(question);
List<DocumentChunk> results = store.findNearest(queryVector, 1);
assertThat(results)
.hasSize(1)
.first()
.extracting(DocumentChunk::documentId)
.isEqualTo("policy-travel");
}
@Test
void shouldRejectComparingDifferentDimensions() {
float[] a = { 1f, 0f, 0f };
float[] b = { 1f, 0f };
assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("dimension mismatch");
}
}
Test đầu khẳng định hành vi cốt lõi: câu hỏi diễn đạt khác hẳn văn bản gốc vẫn tìm đúng chunk. Test thứ hai đảm bảo bạn không vô tình so sánh hai vector không tương thích — lỗi này rất dễ xảy ra khi đổi embedding model mà quên re-embed toàn bộ dữ liệu cũ.
8. Lỗi thường gặp và khi nào không nên dùng
- Đổi embedding model nhưng không re-embed dữ liệu cũ: vector cũ và vector mới không nằm trong cùng không gian, so khớp sẽ cho kết quả sai một cách âm thầm — không có exception nào báo cho bạn biết.
- Chunk quá dài hoặc quá ngắn: chunk dài làm vector “pha trộn” nhiều ý, giảm độ chính xác; chunk quá ngắn mất ngữ cảnh. Thường 200–500 token là điểm khởi đầu hợp lý, cần tinh chỉnh theo domain.
- Quên lưu metadata: nếu chỉ lưu vector mà không lưu
documentId/version, bạn không thể xóa hoặc cập nhật đúng tài liệu khi chính sách thay đổi. - Dùng embedding cho dữ liệu cần khớp chính xác: nếu bạn cần tìm đúng mã đơn hàng
ORD-2026-00123, full-text search hoặc index theo khóa chính xác vẫn nhanh và đúng hơn embedding — embedding mạnh ở tìm theo ý nghĩa, không mạnh ở tìm theo chuỗi ký tự chính xác.
Lưu ý: với tập dữ liệu nhỏ (vài trăm chunk),
InMemoryVectorStoreở trên đủ dùng. Khi scale lên hàng triệu chunk, bạn cần vector database chuyên dụng (pgvector, Pinecone, Milvus…) hỗ trợ index xấp xỉ (ANN) để tránh so sánh tuần tự từng vector.
9. Kết luận
Embedding biến văn bản thành vector để máy so sánh được “ý nghĩa” thay vì chỉ so khớp từ khóa — đó là nền tảng của mọi hệ thống RAG. Điểm mấu chốt bạn cần nhớ: query và document phải dùng cùng một encoder tương thích, mỗi vector phải đi kèm metadata để truy ngược văn bản gốc, và số chiều giống nhau không đảm bảo hai model tương thích. Từ ví dụ Java ở trên, bạn có thể thay KeywordEmbeddingClient bằng một embedding model thật (qua Spring AI hoặc API trực tiếp) mà toàn bộ phần còn lại — cosine similarity, vector store, metadata — vẫn giữ nguyên.