본문 바로가기
Python

[Python] 문서 임베딩으로 간단한 의미 검색기 만들기 (sentence-transformers)

by teamnova 2025. 9. 10.
728x90

 

 

요즘 검색 기술은 단순히 단어를 일치시키는 수준을 넘어서,

사용자가 무엇을 의미하는지까지 이해하려는 방향으로 발전하고 있습니다.

 

예를 들어, "환불"이라는 단어를 직접 검색하는 키워드 검색과, "결제 취소"처럼 표현은 다르지만 뜻이 비슷한 문장까지 찾아주는 의미 검색은 완전히 다른 접근 방식입니다. 이번 글에서는 키워드 검색과 의미 검색의 차이를 간단히 살펴보고, 파이썬으로 직접 의미 검색을 구현하는 예제도 함께 소개하겠습니다. 

 

키워드 검색 (Keyword Search)

  • 정의: 사용자가 입력한 단어(키워드)와 동일한 텍스트를 문서 안에서 그대로 찾아내는 방식
  • 원리: 문자열 매칭 (예: LIKE, 정규식, 검색엔진의 인덱스 기반 매칭)
  • 장점: 빠르고 직관적임
  • 단점: 표현이 조금만 달라져도 못 찾음
    • 예: "환불"을 검색하면 "환불"이라는 단어가 있는 결과만 나오고, "결제 취소"는 못 잡아냄

 

의미 검색 (Semantic Search)

  • 정의: 사용자의 질의를 벡터(embedding)로 변환하고, 문서들도 같은 방식으로 변환하여 의미적으로 가까운 것을 찾아내는 검색 방식
  • 원리: 자연어 처리(NLP) 모델이 문장을 벡터로 변환 → 코사인 유사도(cosine similarity) 등으로 비교
  • 장점: 표현이 달라도 의미가 비슷하면 검색 가능
    • 예: "환불 받고 싶어요" → "결제를 취소하고 싶어요. 환불 규정이 어떻게 되나요?"
  • 단점: 모델 성능에 따라 결과가 달라지고, 벡터 연산이라 속도/리소스가 더 듦

 

 

1. 환경 설정 (파이썬 환경 준비 필수)  

pip install sentence-transformers scikit-learn numpy

 

2, 코드 설명 

 

2-1) 라이브러리 & 데이터

from sentence_transformers import SentenceTransformer
from sklearn.neighbors import NearestNeighbors
import numpy as np, json
from pathlib import Path

 

  • SentenceTransformer: 문장을 임베딩(벡터)로 바꿔주는 모델 로더.
  • NearestNeighbors: 임베딩 공간에서 가까운 문장을 찾기 위한 최근접 탐색기(KNN).
  • DOCS: 검색 대상. 실제 서비스에서는 DB/CSV/노션 등에서 읽어오도록 변경하면 됩니다 

 

 

2-2) 임베딩 모델 로드

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

 

  • 다국어 문장 임베딩에 널리 쓰이는 경량 모델.
  • CPU에서도 빠른 편이라 실습/프로토타입에 적합.

 

 

2-3) 캐시 파일 경로

EMB_PATH = Path("embeddings.npy")
META_PATH = Path("docs.json")
  • 처음 한 번 계산한 임베딩을 디스크에 저장해서 다음 실행부터는 빠르게 로드.

 

2-4) 인덱스 빌드 또는 로드

def build_or_load_index(docs):
    texts = [d["text"] for d in docs]

    if EMB_PATH.exists() and META_PATH.exists():
        embeddings = np.load(EMB_PATH)
        with META_PATH.open("r", encoding="utf-8") as f:
            meta = json.load(f)
        assert len(meta) == len(docs), "docs가 변경되었으면 임베딩을 다시 빌드하세요."
    else:
        embeddings = model.encode(
            texts, convert_to_numpy=True, show_progress_bar=True,
            normalize_embeddings=True
        )
        np.save(EMB_PATH, embeddings)
        with META_PATH.open("w", encoding="utf-8") as f:
            json.dump(docs, f, ensure_ascii=False, indent=2)

    nn = NearestNeighbors(n_neighbors=3, metric="cosine")
    nn.fit(embeddings)
    return nn, embeddings

 

  • 캐시 존재: embeddings.npy와 docs.json이 있으면 그대로 로드.
  • 첫 실행: model.encode()로 모든 문장을 벡터화 후 파일로 저장.
  • normalize_embeddings=True:
    • 각 벡터를 L2 정규화 → 코사인 유사도 = 내적이 되어 비교가 안정적.
  • NearestNeighbors(metric="cosine"):
    • KNN으로 코사인 거리(0이 가까움) 기반 최근접 이웃 탐색기를 만듦.

 

 

2-5) 검색 함수

def search(query, nn, embeddings, docs, top_k=3):
    q_emb = model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
    distances, indices = nn.kneighbors(q_emb, n_neighbors=top_k)

    results = []
    for dist, idx in zip(distances[0], indices[0]):
        sim = 1.0 - float(dist)  # 코사인 유사도
        results.append({"doc": docs[idx], "similarity": round(sim, 4)})
    return results

 

  • 질의를 임베딩으로 변환 → KNN으로 가장 가까운 문장 top_k개 조회.
  • similarity = 1 - distance: 코사인 유사도(1에 가까울수록 유사)로 변환해 출력.

 

 

 

2-6) 코드 실행 

if __name__ == "__main__":
    nn, emb = build_or_load_index(DOCS)
    print("\n[의미 검색] 질문을 입력하세요. (종료: 빈 입력 후 Enter)\n")
    while True:
        q = input("Q> ").strip()
        if not q:
            break
        hits = search(q, nn, emb, DOCS, top_k=3)
        print("\n--- 결과 ---")
        for h in hits:
            print(f"[sim {h['similarity']}] #{h['doc']['id']}: {h['doc']['text']}")
        print()

 

 

 

 

3. 전체 코드 

# semantic_search.py
from sentence_transformers import SentenceTransformer
from sklearn.neighbors import NearestNeighbors
import numpy as np
import json
from pathlib import Path

# 1) 데이터: 여기에 노트/FAQ/문서 문장들을 넣으세요.
DOCS = [
    {"id": 1, "text": "결제를 취소하고 싶어요. 환불 규정이 어떻게 되나요?"},
    {"id": 2, "text": "배송은 보통 영업일 기준 2~3일 소요됩니다."},
    {"id": 3, "text": "비밀번호를 잊어버렸습니다. 재설정 방법을 알려주세요."},
    {"id": 4, "text": "프리미엄 요금제는 추가 저장공간과 통계 기능을 제공합니다."},
    {"id": 5, "text": "해외 배송이 가능한가요? 관부가세는 어떻게 처리되나요?"},
    {"id": 6, "text": "구독을 해지하면 다음 결제일부터 요금이 청구되지 않습니다."},
]

# 2) 임베딩 모델 로드 (다국어 지원)
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
model = SentenceTransformer(MODEL_NAME)

# 3) 인덱스 파일 경로
EMB_PATH = Path("embeddings.npy")
META_PATH = Path("docs.json")

def build_or_load_index(docs):
    """
    문서 임베딩을 계산(처음 한 번)하거나, 저장된 벡터를 불러옵니다.
    그리고 최근접 탐색기(NearestNeighbors)를 준비합니다.
    """
    texts = [d["text"] for d in docs]

    if EMB_PATH.exists() and META_PATH.exists():
        embeddings = np.load(EMB_PATH)
        with META_PATH.open("r", encoding="utf-8") as f:
            meta = json.load(f)
        assert len(meta) == len(docs), "docs가 변경되었으면 임베딩을 다시 빌드하세요."
    else:
        embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True, normalize_embeddings=True)
        np.save(EMB_PATH, embeddings)
        with META_PATH.open("w", encoding="utf-8") as f:
            json.dump(docs, f, ensure_ascii=False, indent=2)

    # 4) 최근접 탐색기 (코사인 유사도 ≈ 내적, normalize_embeddings=True 이면 코사인=내적)
    nn = NearestNeighbors(n_neighbors=3, metric="cosine")
    nn.fit(embeddings)
    return nn, embeddings

def search(query, nn, embeddings, docs, top_k=3):
    q_emb = model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
    distances, indices = nn.kneighbors(q_emb, n_neighbors=top_k)
    # cosine distance → 0이 가까움, 유사도 = 1 - distance
    results = []
    for dist, idx in zip(distances[0], indices[0]):
        sim = 1.0 - float(dist)
        results.append({"doc": docs[idx], "similarity": round(sim, 4)})
    return results

if __name__ == "__main__":
    nn, emb = build_or_load_index(DOCS)
    print("\n[의미 검색] 질문을 입력하세요. (종료: 빈 입력 후 Enter)\n")
    while True:
        q = input("Q> ").strip()
        if not q:
            break
        hits = search(q, nn, emb, DOCS, top_k=3)
        print("\n--- 결과 ---")
        for h in hits:
            print(f"[sim {h['similarity']}] #{h['doc']['id']}: {h['doc']['text']}")
        print()

 

 

 

 

결과 화면 

 

1. "계산한거 되돌리기" 검색한 경우 

- 결제 취소 게시글 포함 

 

2. "국제" 검색 

- 해외 배송 게시글 상위 포함 

 

3. 매달 내는 돈 

- 프리미엄 요금제 

 

4. 시간 

- 배송기간 게시글 포함 

 

이렇게 각 키워드에 관련된 게시글 제목이 포함되는 것을 확인할 수 있습니다.