임베딩의 의미 찾기: 상세 설명과 예제

이 글에는 짧은 영상이 있습니다:YouTubeTikTok

1. 개요

채팅봇에게 “비행기가 취소되었는데, 예약한 호텔 비용을 환급받을 수 있을까요?“라고 물어본다고 생각해보세요. RAG 시스템이 “여행 중단”에 대한 정책을 정확히 찾아야 하는데 — 질문에는 원본 텍스트와 100% 일치하는 단어가 없습니다. 이 글에서는 임베딩 메커니즘을 설명합니다. 텍스트를 벡터로 변환하고, 의미적 거리로 매칭하며, LLM이 원본 텍스트를 참조할 수 있도록 저장하는 방법 말이죠. 이 글을 다 읽으면 Java로 간단한 의미 검색 파이프라인을 직접 작성할 수 있고, 실제로 잘 작동하는지 테스트하는 방법도 알게 됩니다.

2. 임베딩이란 무엇인가

Embedding 모델은 텍스트 한 조각을 받아서 벡터 하나를 반환합니다 — 보통 수백에서 수천 개 차원을 가진 실수 배열이죠. 이 벡터는 “압축된 키워드”가 아니라 고차원 공간의 한 점입니다. 의미상 비슷한 텍스트들의 벡터는 서로 가깝게 위치합니다.

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVector는 단순히 float[]를 감싼 래퍼일 뿐이지만, 이것이 있으면 여러 모델 간에 float[]를 섞어 전달하는 실수를 방지할 수 있습니다 — 시스템이 커질수록 발생하기 쉬운 버그이거든요.

2.1. 임베딩을 명확한 레이블이 있는 “지도”처럼 생각하면 안 되는 이유

실제 지도에는 경도, 위도 축이 있고 “하노이”, “다낭” 같은 레이블이 있습니다. 그런데 임베딩의 경우, 개별 차원은 “호텔”, “비행기”, “환급” 같은 레이블을 갖지 않습니다. 의미는 전체 벡터의 패턴으로만 나타납니다. 따라서 한 차원을 “읽어서” 그것이 무엇을 나타내는지 알 수 없습니다 — 오직 다른 벡터와 전체 벡터를 비교할 수만 있습니다.

이게 중요한 이유는 임베딩을 마음대로 자르면 안 된다는 걸 설명해주거든요 (예를 들어 메모리 절약한다고 1536개 중 100개만 유지). 그렇게 하면 패턴이 깨져서 검색 정확도가 크게 떨어집니다.

3. 의미적 거리로 매칭하기

두 벡터가 “가깝다”는 것은 보통 코사인 유사도로 측정합니다 — 1에 가까울수록 의미상 유사합니다.

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));
    }
}
  • if (a.length != b.length) 라인: 흔한 실수를 조기에 막습니다 — 두 개의 다른 모델의 벡터를 비교하면 (예: 한쪽 768차원, 다른 쪽 1536차원) 디버깅하기 어려운 런타임 에러가 발생하는데, 미리 체크하면 예방할 수 있습니다.
  • dot, normA, normB: 코사인 공식에 따라 내적과 각 벡터의 길이를 계산합니다.

주의: 두 모델이 같은 차원을 가진다고 해서 호환 가능한 것은 아닙니다. 모델 A와 B가 모두 1536차원 벡터를 출력해도, 의미적 공간은 다릅니다 — 교차 비교 결과는 의미 없을 것입니다. 코드는 에러 없이 돌겠지만요.

4. 실제 API 호출 없이 실습하기 위한 임베딩 모델 모의

예제를 바로 실행하려고, “주제”(travel, password, billing)에 속하는 키워드 빈도를 바탕으로 한 EmbeddingClient 시뮬레이션을 사용합니다. 실제로는 OpenAI, Cohere, 또는 오픈소스 모델로 바꾸면 되지만, interface는 동일합니다.

<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("비행기", "취소", "호텔", "여행", "공항");
    private static final List<String> PASSWORD_WORDS =
        List.of("비밀번호", "로그인", "계정", "재설정", "잊음");
    private static final List<String> BILLING_WORDS =
        List.of("환급", "결제", "청구서", "비용", "수수료");

    @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는 관찰을 쉽게 하려고 3차원만 사용합니다 (travel/password/billing) — 실제 임베딩은 데이터로부터 학습한 수백 개 차원을 쓰지만, “의미상 비슷한 텍스트 → 가까운 벡터” 원리는 동일합니다.

5. 임베딩과 메타데이터를 함께 저장해 LLM이 원본 텍스트를 참조하게 하기

벡터는 텍스트를 찾기만 도와주고, LLM은 여전히 글귀가 필요합니다. 따라서 벡터 저장소의 각 레코드는 임베딩을 청크 원문과 메타데이터와 묶어야 합니다.

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkId와 documentId: 필요할 때 전체 문서를 다시 가져오게 합니다 (출처 표시나 새 버전 업데이트할 때).
  • section, effectiveDate, version: 의미 매칭 전에 미리 필터링하는 데 도움이 됩니다 — 예: 유효한 정책만 검색하고, 만료된 이전 버전은 스킵.
  • text: 반드시 함께 저장해야 합니다. LLM은 벡터를 “읽을 수” 없으니까요 — 답변을 만들려면 실제 문장이 필요합니다.
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는 코사인 유사도 내림차순으로 청크를 정렬하고 (- 부호로 역순) 상위 topK 결과를 반환합니다 — 이것이 RAG의 “retrieval” 단계입니다.

6. 완전한 예제: 다른 표현이지만 같은 의미

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", "비행기가 취소되거나 여행이 중단될 때의 환급 정책.",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("비행기가 취소되거나 여행이 중단될 때의 환급 정책.")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account", "계정 로그인을 잊었을 때 비밀번호 재설정 가이드.",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("계정 로그인을 잊었을 때 비밀번호 재설정 가이드.")
        ));

        String question = "비행기가 취소되었는데, 예약한 호텔 비용을 환급받을 수 있을까요?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

이 프로그램을 실행하면 다음을 출력합니다:

비행기가 취소되거나 여행이 중단될 때의 환급 정책.

질문에는 “정책” 또는 “여행”이라는 단어가 없지만, 질문과 텍스트가 모두 의미 공간에서 비슷한 위치에 있으니까요 (둘 다 “travel” 차원이 높음), 시스템이 올바른 청크를 선택합니다 — 비밀번호 재설정에 대한 청크는 완전히 무시합니다.

7. 테스트로 검증하기

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",
            "비행기가 취소되거나 여행이 중단될 때의 환급 정책.",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("비행기가 취소되거나 여행이 중단될 때의 환급 정책.")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "계정 로그인을 잊었을 때 비밀번호 재설정 가이드.",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("계정 로그인을 잊었을 때 비밀번호 재설정 가이드.")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "비행기가 취소되었는데, 예약한 호텔 비용을 환급받을 수 있을까요?";
        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");
    }
}

첫 번째 테스트는 핵심 동작을 확인합니다: 원본과 다르게 표현한 질문도 올바른 청크를 찾습니다. 두 번째 테스트는 호환되지 않는 두 벡터를 비교하는 실수를 방지합니다 — 이런 실수는 임베딩 모델을 바꿨는데 기존 데이터를 다시 임베딩하지 않았을 때 아주 쉽게 일어납니다.

8. 흔한 실수와 사용하면 안 되는 경우

  • 임베딩 모델은 바꿨는데 기존 데이터는 다시 임베딩하지 않음: 오래된 벡터와 새 벡터는 다른 공간에 있어서, 비교 결과가 몰래 틀립니다 — 예외는 던져지지 않으니까요.
  • 청크가 너무 길거나 너무 짧음: 긴 청크는 벡터에 너무 많은 의미가 섞여서 정확도가 낮고; 짧은 청크는 문맥을 잃습니다. 보통 200–500 토큰이 좋은 출발점이고, 도메인에 따라 조정하면 됩니다.
  • 메타데이터를 저장하지 않음: 벡터만 저장하고 documentId/version을 빠뜨리면, 정책이 바뀔 때 올바른 문서를 지우거나 업데이트할 수 없습니다.
  • 정확 매칭이 필요한 데이터에 임베딩 사용: 주문번호 ORD-2026-00123처럼 정확하게 찾아야 한다면, full-text search나 정확 키 인덱스가 임베딩보다 빠르고 정확합니다 — 임베딩은 의미적으로 찾을 때 강하지, 문자열 정확도에서는 약합니다.

주의: 청크가 수백 개 정도라면 위의 InMemoryVectorStore로도 충분합니다. 수백만 개까지 늘어나면, 전용 벡터 데이터베이스 (pgvector, Pinecone, Milvus…) 를 써서 근사 최근접 탐색 (ANN) 인덱스를 활용해야 합니다. 그래야 매번 모든 벡터와 비교하는 일이 없습니다.

9. 결론

임베딩은 텍스트를 벡터로 변환해서 기계가 “키워드 일치” 대신 “의미”를 비교하게 합니다 — 이것이 모든 RAG 시스템의 기초입니다. 꼭 기억해야 할 핵심: 질의와 문서는 호환하는 같은 인코더를 써야 하고, 각 벡터는 원본 텍스트를 참조할 메타데이터를 포함해야 하며, 차원이 같다고 해서 두 모델이 호환하는 건 아닙니다. 위의 Java 예제에서 KeywordEmbeddingClient를 실제 임베딩 모델 (Spring AI나 직접 API)로 바꾸면, 코사인 유사도, 벡터 저장소, 메타데이터 나머지 부분은 그대로 유지됩니다.

ragembeddingvector-searchspring-aillm