Chạy model embedding với sentence-transformers

1. Tổng quan

Embedding là cách biến một câu văn thành một vector số. Hai câu có nghĩa gần nhau sẽ cho ra hai vector gần nhau. Đây là nền tảng của semantic search, RAG, phân cụm văn bản và gợi ý nội dung.

Trong bài này, bạn sẽ dùng thư viện sentence-transformers để chạy một model embedding từ Hugging Face ngay trên máy. Đọc xong, bạn sẽ biết cách tạo embedding, tính độ tương đồng, tìm kiếm theo ngữ nghĩa, viết test, và đưa model ra thành một HTTP service để ứng dụng Spring Boot gọi tới.

2. Cài đặt

sentence-transformers là thư viện Python, chạy trên PyTorch. Bạn nên tạo một virtual environment riêng:

python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers

Lệnh này kéo theo torch, transformers và huggingface_hub. Nếu máy có GPU NVIDIA, hãy cài bản torch có CUDA trước, rồi mới cài sentence-transformers.

Lần đầu bạn nạp một model, thư viện sẽ tải nó từ Hugging Face Hub về thư mục cache (mặc định là ~/.cache/huggingface). Những lần sau, model được đọc thẳng từ cache.

3. Tạo embedding đầu tiên

Ta bắt đầu với all-MiniLM-L6-v2. Đây là một model nhỏ (khoảng 90 MB), nhanh, phù hợp để thử nghiệm với văn bản tiếng Anh:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

sentences = [
    "The order was shipped yesterday.",
    "Your package left our warehouse last night.",
    "I would like to change my password.",
]

embeddings = model.encode(sentences)

print(embeddings.shape)
print(model.get_sentence_embedding_dimension())

Giải thích:

  • SentenceTransformer(...) nạp model và tokenizer từ Hub, hoặc từ cache nếu đã có.
  • model.encode(sentences) nhận một list chuỗi và trả về một numpy.ndarray. Mỗi câu ứng với một hàng.
  • get_sentence_embedding_dimension() cho biết số chiều của vector.

Kết quả:

(3, 384)
384

Bạn có ba câu, mỗi câu thành một vector 384 chiều. Model khác sẽ có số chiều khác, ví dụ 768 hoặc 1024.

3.1. Văn bản tiếng Việt

all-MiniLM-L6-v2 được huấn luyện chủ yếu trên tiếng Anh. Với tiếng Việt, bạn nên dùng model đa ngôn ngữ:

model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

vectors = model.encode([
    "Đơn hàng đã được giao cho đơn vị vận chuyển.",
    "Gói hàng của bạn đã rời kho tối qua.",
])
print(vectors.shape)
(2, 384)

Model này cũng cho vector 384 chiều. Nó hiểu khoảng 50 ngôn ngữ, gồm cả tiếng Việt.

4. Tính độ tương đồng

Vector chỉ có ích khi bạn so sánh chúng với nhau. Thước đo phổ biến nhất là cosine similarity. Từ phiên bản 3, thư viện có sẵn model.similarity:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

sentences = [
    "The order was shipped yesterday.",
    "Your package left our warehouse last night.",
    "I would like to change my password.",
]
embeddings = model.encode(sentences)

scores = model.similarity(embeddings, embeddings)
print(scores)

model.similarity(a, b) trả về một ma trận torch.Tensor, trong đó ô [i][j] là độ tương đồng giữa câu i và câu j. Đầu ra trông tương tự như sau (các số có thể lệch nhẹ tùy phiên bản):

tensor([[1.0000, 0.6123, 0.0412],
        [0.6123, 1.0000, 0.0587],
        [0.0412, 0.0587, 1.0000]])

Đường chéo luôn bằng 1 vì mỗi câu giống hệt chính nó. Hai câu về giao hàng có điểm cao, dù gần như không trùng từ nào. Câu về đổi mật khẩu thì gần như không liên quan.

4.1. Chuẩn hóa vector

Nếu bạn định lưu vector vào một vector database, hãy chuẩn hóa chúng về độ dài 1:

embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123

Khi vector đã được chuẩn hóa, tích vô hướng (@) cho đúng giá trị cosine similarity. Phép này rẻ hơn, và nhiều database như pgvector, Qdrant hay Elasticsearch tối ưu riêng cho nó.

Một bài toán thực tế là tìm câu trả lời phù hợp nhất trong kho FAQ cho câu hỏi của khách hàng. Hàm util.semantic_search làm việc này:

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

faq = [
    "Làm sao để theo dõi đơn hàng?",
    "Chính sách hoàn tiền như thế nào?",
    "Tôi quên mật khẩu đăng nhập.",
    "Phí vận chuyển ra nước ngoài là bao nhiêu?",
]
faq_embeddings = model.encode(faq, convert_to_tensor=True, normalize_embeddings=True)

query = "Hàng của tôi đang ở đâu rồi?"
query_embedding = model.encode(query, convert_to_tensor=True, normalize_embeddings=True)

hits = util.semantic_search(query_embedding, faq_embeddings, top_k=2)[0]
for hit in hits:
    print(f"{hit['score']:.3f}  {faq[hit['corpus_id']]}")

Giải thích:

  • Bạn encode toàn bộ FAQ một lần. Trong ứng dụng thật, bước này chạy lúc khởi động hoặc khi dữ liệu thay đổi.
  • convert_to_tensor=True giữ kết quả dưới dạng tensor, nên không phải chuyển qua lại với numpy.
  • semantic_search trả về một list cho mỗi query. Mỗi phần tử là một dict gồm corpus_id và score, đã sắp xếp giảm dần.

Đầu ra mẫu:

0.721  Làm sao để theo dõi đơn hàng?
0.388  Phí vận chuyển ra nước ngoài là bao nhiêu?

Cách này đủ dùng cho vài chục nghìn tài liệu nằm trong bộ nhớ. Với hàng triệu tài liệu, bạn nên chuyển sang vector database có index ANN.

Lưu ý: Một số model như họ E5 hay BGE yêu cầu thêm tiền tố, ví dụ "query: " cho câu hỏi và "passage: " cho tài liệu. Bỏ tiền tố, chất lượng tìm kiếm có thể giảm rõ rệt. Hãy đọc model card trước khi dùng. Bạn có thể truyền prompt="query: " vào encode.

6. Tối ưu hiệu năng

encode có vài tham số ảnh hưởng trực tiếp tới tốc độ:

import torch
from sentence_transformers import SentenceTransformer

device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=device)

documents = [f"Customer review number {i}" for i in range(10_000)]

embeddings = model.encode(
    documents,
    batch_size=64,
    show_progress_bar=True,
    normalize_embeddings=True,
)
print(embeddings.shape, device)
Batches: 100%|██████████| 157/157 [00:09<00:00, 16.8it/s]
(10000, 384) cpu
  • device chọn CPU hay GPU. Trên GPU, tốc độ có thể nhanh hơn hàng chục lần.
  • batch_size mặc định là 32. Trên GPU, tăng lên 64–256 thường giúp chạy nhanh hơn, miễn là không hết VRAM.
  • Luôn gửi cả list vào encode. Gọi encode từng câu trong vòng lặp for chậm hơn nhiều lần.

Mỗi model còn có giới hạn độ dài đầu vào, xem qua model.max_seq_length. Với all-MiniLM-L6-v2, giá trị này là 256 token. Phần văn bản vượt quá bị cắt bỏ âm thầm, không có cảnh báo. Vì vậy, tài liệu dài cần được chia nhỏ (chunking) trước khi encode.

7. Viết test cho phần embedding

Embedding là số thực, nên bạn không thể so sánh từng giá trị cố định. Hãy test tính chất thay vì giá trị cụ thể. Cài pytest:

pip install pytest
# test_embeddings.py
import numpy as np
import pytest
from sentence_transformers import SentenceTransformer


@pytest.fixture(scope="module")
def model():
    return SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


def test_embedding_has_expected_dimension(model):
    vectors = model.encode(["Order #42 is delayed"])
    assert vectors.shape == (1, 384)


def test_normalized_embedding_has_unit_length(model):
    vector = model.encode("Refund request", normalize_embeddings=True)
    assert np.linalg.norm(vector) == pytest.approx(1.0, abs=1e-5)


def test_similar_sentences_score_higher(model):
    shipping = model.encode("The order was shipped yesterday.")
    package = model.encode("Your package left our warehouse last night.")
    password = model.encode("I would like to change my password.")

    related = model.similarity(shipping, package).item()
    unrelated = model.similarity(shipping, password).item()
    assert related > unrelated

Fixture có scope="module" nên model chỉ được nạp một lần cho cả file. Test thứ ba bắt được lỗi khi ai đó lỡ đổi sang một model không phù hợp.

8. Gọi model từ ứng dụng Spring Boot

Phần lớn backend của bạn có thể viết bằng Java. Cách đơn giản là bọc model thành một HTTP service nhỏ bằng FastAPI, rồi gọi nó từ Spring Boot.

8.1. Embedding service bằng FastAPI

pip install fastapi uvicorn
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer

app = FastAPI()
model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)


class EmbedRequest(BaseModel):
    texts: list[str]


@app.post("/embed")
def embed(request: EmbedRequest):
    vectors = model.encode(request.texts, normalize_embeddings=True)
    return {
        "embeddings": vectors.tolist(),
        "dimension": model.get_sentence_embedding_dimension(),
    }
uvicorn app:app --port 8000

Model được nạp một lần khi module khởi động, không nạp lại cho mỗi request. Endpoint dùng def chứ không dùng async def. Lý do là encode là tác vụ blocking, và FastAPI sẽ chạy hàm def trong một thread pool riêng thay vì chặn event loop.

8.2. Client trong Spring Boot

Dependency Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
embedding.base-url=http://localhost:8000
public record EmbedRequest(List<String> texts) {}

public record EmbedResponse(List<List<Float>> embeddings, int dimension) {}

@Service
public class EmbeddingClient {

    private final RestClient restClient;

    public EmbeddingClient(RestClient.Builder builder,
                           @Value("${embedding.base-url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    public List<List<Float>> embed(List<String> texts) {
        EmbedResponse response = restClient.post()
                .uri("/embed")
                .body(new EmbedRequest(texts))
                .retrieve()
                .body(EmbedResponse.class);
        return response.embeddings();
    }
}

RestClient (có từ Spring Framework 6.1) gửi EmbedRequest dưới dạng JSON, rồi map phản hồi vào record EmbedResponse bằng Jackson. Bạn có thể dùng kết quả để lưu vào pgvector hoặc đem so sánh với vector khác.

8.3. Test client với MockRestServiceServer

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

class EmbeddingClientTest {

    @Test
    void shouldReturnEmbeddingsFromService() {
        RestClient.Builder builder = RestClient.builder();
        MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();

        server.expect(requestTo("http://localhost:8000/embed"))
              .andExpect(method(HttpMethod.POST))
              .andExpect(jsonPath("$.texts[0]").value("Đơn hàng bị trễ"))
              .andRespond(withSuccess("""
                      {"embeddings": [[0.1, 0.2, 0.3]], "dimension": 3}
                      """, MediaType.APPLICATION_JSON));

        EmbeddingClient client = new EmbeddingClient(builder, "http://localhost:8000");

        List<List<Float>> result = client.embed(List.of("Đơn hàng bị trễ"));

        assertThat(result).hasSize(1);
        assertThat(result.getFirst()).containsExactly(0.1f, 0.2f, 0.3f);
        server.verify();
    }
}

MockRestServiceServer.bindTo(builder) phải được gọi trước khi builder tạo ra RestClient. Trong test này, điều đó luôn đúng vì client chỉ gọi build() bên trong constructor. Nhờ vậy, test chạy mà không cần Python service thật.

9. Lỗi hay gặp

  • Trộn vector từ nhiều model. Vector của hai model khác nhau nằm trong hai không gian khác nhau, kể cả khi cùng số chiều. Đổi model đồng nghĩa với encode lại toàn bộ dữ liệu.
  • Quên chuẩn hóa ở một phía. Nếu tài liệu được chuẩn hóa còn query thì không, điểm tích vô hướng sẽ sai lệch. Hãy dùng cùng một cấu hình encode cho cả hai phía.
  • Văn bản quá dài. Phần vượt max_seq_length bị cắt mà không có cảnh báo. Hãy chunk tài liệu trước khi encode.
  • Nạp model trong mỗi request. Thao tác này tốn vài giây và nhiều RAM. Chỉ nạp một lần khi khởi động.
  • Container không có mạng. Môi trường production thường chặn truy cập Hub. Hãy tải model sẵn vào image, rồi đặt HF_HUB_OFFLINE=1.

Lưu ý: Embedding không thay thế được tìm kiếm chính xác. Với mã đơn hàng, số điện thoại hay SKU, full-text search hoặc truy vấn SQL thông thường cho kết quả tốt hơn. Trong thực tế, kết hợp cả hai (hybrid search) thường hiệu quả nhất.

10. Kết luận

Bạn đã dùng sentence-transformers để tạo embedding chỉ với vài dòng code. Bạn cũng đã tính cosine similarity, xây semantic search cho kho FAQ và tinh chỉnh batch_size, device để chạy nhanh hơn. Ngoài ra, bạn đã biết cách test embedding qua tính chất, cũng như bọc model thành HTTP service để Spring Boot gọi qua RestClient.

Bước tiếp theo hợp lý là lưu vector vào một vector database như pgvector, rồi dùng chúng làm phần retrieval trong một pipeline RAG.

huggingfacesentence-transformersembeddingpythonspring-boot