sentence-transformers를 사용한 임베딩 모델 실행
1. 개요
임베딩은 문장을 숫자 벡터로 변환하는 방법입니다. 의미가 비슷한 두 문장은 비슷한 벡터를 생성합니다. 이것은 의미 검색, RAG, 텍스트 클러스터링 및 콘텐츠 추천의 기초입니다.
이 글에서는 sentence-transformers 라이브러리를 사용하여 Hugging Face의 임베딩 모델을 로컬 머신에서 실행할 것입니다. 이 글을 마치면, 임베딩 생성, 유사도 계산, 의미 검색, 테스트 작성, 그리고 Spring Boot 애플리케이션에서 호출할 수 있는 HTTP 서비스로 모델을 제공하는 방법을 알게 될 것입니다.
2. 설치
sentence-transformers는 PyTorch 기반의 Python 라이브러리입니다. 가상 환경을 별도로 만드는 것이 좋습니다:
python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers
이 명령은 torch, transformers 및 huggingface_hub를 함께 설치합니다. NVIDIA GPU가 있다면 먼저 CUDA가 포함된 torch를 설치한 후 sentence-transformers를 설치하세요.
처음으로 모델을 로드할 때 라이브러리는 Hugging Face Hub에서 캐시 디렉토리(기본값: ~/.cache/huggingface)로 다운로드합니다. 이후에는 캐시에서 직접 모델을 읽습니다.
3. 첫 번째 임베딩 생성
all-MiniLM-L6-v2로 시작하겠습니다. 이것은 작은 모델(약 90MB)로 빠르며 영문 텍스트 실험에 적합합니다:
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())
설명:
SentenceTransformer(...)는 Hub에서 모델과 토크나이저를 로드하거나 캐시에 있으면 캐시에서 로드합니다.model.encode(sentences)는 문자열 리스트를 받아numpy.ndarray를 반환합니다. 각 문장은 한 행에 해당합니다.get_sentence_embedding_dimension()은 벡터의 차원 수를 반환합니다.
결과:
(3, 384)
384
3개의 문장이 있고, 각 문장은 384차원의 벡터가 됩니다. 다른 모델은 다른 차원을 가질 수 있습니다(예: 768 또는 1024).
3.1. 베트남어 텍스트
all-MiniLM-L6-v2는 주로 영어로 학습되었습니다. 베트남어의 경우 다국어 모델을 사용해야 합니다:
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)
이 모델도 384차원의 벡터를 생성합니다. 베트남어를 포함하여 약 50개 언어를 지원합니다.
4. 유사도 계산
벡터는 서로 비교할 때만 유용합니다. 가장 일반적인 측도는 코사인 유사도입니다. 버전 3부터 라이브러리에는 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)는 torch.Tensor 행렬을 반환하며, 여기서 [i][j] 요소는 문장 i와 문장 j 사이의 유사도입니다. 출력은 대략 다음과 같습니다(버전에 따라 숫자가 약간 다를 수 있음):
tensor([[1.0000, 0.6123, 0.0412],
[0.6123, 1.0000, 0.0587],
[0.0412, 0.0587, 1.0000]])
대각선은 항상 1입니다. 각 문장은 자기 자신과 완전히 같기 때문입니다. 배송에 관한 두 문장은 높은 점수를 얻습니다. 거의 같은 단어가 없음에도 불구하고. 비밀번호 변경 문장은 거의 관련이 없습니다.
4.1. 벡터 정규화
벡터를 벡터 데이터베이스에 저장할 계획이면 길이 1로 정규화하세요:
embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123
벡터가 정규화되면 내적(@)은 정확한 코사인 유사도 값을 제공합니다. 이 연산은 더 저렴하며, pgvector, Qdrant, Elasticsearch 같은 많은 데이터베이스가 이를 위해 최적화되어 있습니다.
5. 의미 검색
실제 문제는 고객의 질문에 맞는 가장 적절한 답변을 FAQ 저장소에서 찾는 것입니다. util.semantic_search 함수가 이를 수행합니다:
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']]}")
설명:
- 전체 FAQ를 한 번 인코딩합니다. 실제 애플리케이션에서는 이 단계가 시작 시 또는 데이터가 변경될 때 실행됩니다.
convert_to_tensor=True는 결과를 tensor로 유지하므로 numpy로 변환할 필요가 없습니다.semantic_search는 각 쿼리에 대해 리스트를 반환합니다. 각 요소는corpus_id와score를 포함하는 딕셔너리이며 내림차순으로 정렬됩니다.
샘플 출력:
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?
이 방법은 메모리에 있는 수십만 개 문서에 충분합니다. 수백만 개의 문서가 있으면 ANN 인덱스가 있는 벡터 데이터베이스로 전환해야 합니다.
참고: E5 또는 BGE 같은 일부 모델은 쿼리에
"query: ", 문서에"passage: "같은 추가 접두사가 필요합니다. 접두사를 빠뜨리면 검색 품질이 크게 저하될 수 있습니다. 사용하기 전에 모델 카드를 읽으세요.encode에prompt="query: "를 전달할 수 있습니다.
6. 성능 최적화
encode에는 속도에 직접 영향을 미치는 여러 매개변수가 있습니다:
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는 CPU 또는 GPU를 선택합니다. GPU에서는 속도가 수십 배 더 빠를 수 있습니다.batch_size의 기본값은 32입니다. GPU에서 64–256으로 증가하면 VRAM이 부족하지 않은 한 일반적으로 더 빠르게 실행됩니다.- 항상 전체 리스트를
encode에 전달하세요.for루프에서 각 문장에 대해encode를 호출하면 훨씬 느립니다.
각 모델에는 입력 길이 제한이 있으며 model.max_seq_length를 통해 확인할 수 있습니다. all-MiniLM-L6-v2의 경우 이 값은 256 토큰입니다. 초과된 텍스트는 무음으로 잘려나가며 경고가 없습니다. 따라서 긴 문서는 인코딩하기 전에 청킹(chunking)되어야 합니다.
7. 임베딩을 위한 테스트 작성
임베딩은 실수이므로 고정 값과 각각을 비교할 수 없습니다. 구체적인 값 대신 특성을 테스트하세요. 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은 scope="module"을 가지므로 모델은 전체 파일에 대해 한 번만 로드됩니다. 세 번째 테스트는 누군가 부적절한 모델로 실수로 변경한 경우를 감지합니다.
8. Spring Boot 애플리케이션에서 모델 호출
대부분의 백엔드는 Java로 작성할 수 있습니다. 간단한 방법은 FastAPI를 사용하여 모델을 작은 HTTP 서비스로 래핑한 후 Spring Boot에서 호출하는 것입니다.
8.1. 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
모델은 모듈 시작 시 한 번 로드되며 각 요청에 대해 다시 로드되지 않습니다. Endpoint은 async def가 아닌 def를 사용합니다. 그 이유는 encode가 블로킹 작업이고, FastAPI가 이벤트 루프를 차단하지 않고 별도의 스레드 풀에서 def 함수를 실행하기 때문입니다.
8.2. Spring Boot의 클라이언트
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(Spring Framework 6.1부터 사용 가능)는 EmbedRequest를 JSON으로 전송하고 Jackson을 사용하여 응답을 EmbedResponse 레코드로 매핑합니다. 결과를 pgvector에 저장하거나 다른 벡터와 비교하는 데 사용할 수 있습니다.
8.3. 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)은 빌더가 RestClient를 생성하기 전에 호출되어야 합니다. 이 테스트에서는 클라이언트가 생성자 내에서만 build()를 호출하므로 이것이 항상 참입니다. 따라서 테스트는 실제 Python 서비스 없이 실행됩니다.
9. 일반적인 오류
- 여러 모델의 벡터 혼합. 두 개의 다른 모델의 벡터는 차원이 같더라도 다른 공간에 있습니다. 모델을 변경하면 모든 데이터를 다시 인코딩해야 합니다.
- 한쪽에서 정규화 잊음. 문서는 정규화되고 쿼리는 정규화되지 않으면 내적 점수가 정확하지 않습니다. 양쪽에서 동일한
encode구성을 사용하세요. - 텍스트가 너무 김.
max_seq_length를 초과하는 부분은 경고 없이 잘립니다. 인코딩하기 전에 문서를 청킹하세요. - 각 요청에서 모델 로드. 이 작업은 몇 초와 많은 RAM을 소모합니다. 시작 시에만 한 번 로드하세요.
- 컨테이너에 네트워크가 없음. 프로덕션 환경은 일반적으로 Hub에 대한 액세스를 차단합니다. 모델을 이미지에 미리 로드하고
HF_HUB_OFFLINE=1을 설정하세요.
참고: 임베딩은 정확한 검색을 대체할 수 없습니다. 주문 코드, 전화 번호 또는 SKU의 경우 전체 텍스트 검색 또는 일반 SQL 쿼리가 더 나은 결과를 제공합니다. 실제로는 둘 다 결합하는 것(하이브리드 검색)이 가장 효과적입니다.
10. 결론
몇 줄의 코드만으로 sentence-transformers를 사용하여 임베딩을 만들었습니다. 코사인 유사도를 계산하고, FAQ 저장소에 대한 의미 검색을 구축하고, 더 빠르게 실행하기 위해 batch_size와 device를 조정했습니다. 또한 특성을 통해 임베딩을 테스트하는 방법과 모델을 HTTP 서비스로 래핑하여 Spring Boot에서 RestClient를 통해 호출하는 방법을 배웠습니다.
다음 단계는 벡터를 pgvector와 같은 벡터 데이터베이스에 저장한 후 RAG 파이프라인에서 검색 부분으로 사용하는 것입니다.