마루옵스서버 · 네트워크 · AI 기술 노트

소개문의

[Local LLM 구축하기 16] 임베딩과 코사인 유사도로 문서 검색하기

책과 노트북이 놓인 책상

참고 사진 · 책과 노트북이 놓인 책상 · 사진: freddie marriage fredmarriage / 원본 / CC0 · 목록용 크기 조정

Local LLM 구축하기 · 16/30 · API와 문서 검색

전체 연재와 학습 순서 · 실습 코드 ZIP

“백업을 며칠 남기지?”라고 물었는데 문서에는 “보관 기간”이라고 적혀 있을 수 있어요. 이런 표현 차이를 다루는 방법 중 하나가 임베딩 검색입니다. 문장을 벡터로 바꿔 가까운 문서 조각을 찾고, 답변 생성 전에 검색 결과부터 확인해 보겠습니다.

13편의 공용 함수와 가상의 텍스트 문서를 준비합니다. 검색할 자료에는 개인정보나 업무 비밀을 넣지 않습니다.

임베딩 모델 준비

여기서는 EmbeddingGemma로 텍스트를 벡터로 바꿉니다. 대화 모델의 답변 문장을 벡터 대신 사용하는 것은 아닙니다. 작은 문서로 동작을 살펴보는 구성이고, 실제 적용할 때는 언어와 문서 종류에 맞춰 검색 품질을 평가합니다.

ollama pull embeddinggemma:300m
New-Item -ItemType Directory -Force .\docs

docs 폴더에 UTF-8 텍스트를 넣습니다. ZIP에는 가상의 backup.txt와 network.txt가 들어 있어요. 아래 코드는 PDF를 직접 읽지 않으므로, PDF를 쓰려면 먼저 텍스트를 추출하는 단계가 필요합니다.

청크 생성과 벡터 저장

코드는 semantic_search.py로 저장합니다. 내려받은 파일을 사용해도 됩니다. 표준 라이브러리로 전체 벡터를 순회하는 방식이라 문서 수가 작은 실습에 적합합니다.

import json
import math
from pathlib import Path
from llm_http import post

EMBED = "embeddinggemma:300m"
ROOT = Path(__file__).resolve().parent
DOCS = ROOT / "docs"
INDEX = ROOT / "index.json"

def chunks(text, size=500, overlap=80):
    if not 0 <= overlap < size:
        raise ValueError("Invalid overlap")
    for start in range(0, len(text), size - overlap):
        yield text[start:start + size]
        if start + size >= len(text):
            break

def embed(texts):
    return post("/api/embed", {
        "model": EMBED, "input": texts, "truncate": False
    })["embeddings"]

def cosine(a, b):
    if len(a) != len(b):
        raise ValueError("Embedding dimensions differ")
    den = math.sqrt(sum(x*x for x in a) * sum(y*y for y in b))
    return sum(x*y for x, y in zip(a, b)) / den if den else 0.0

def build():
    rows = []
    for path in sorted(DOCS.glob("*.txt")):
        for n, text in enumerate(chunks(path.read_text(encoding="utf-8-sig")), 1):
            if text.strip():
                rows.append({"id": f"{path.name}#{n}", "text": text})
    if not rows:
        raise ValueError("No nonempty docs/*.txt files")
    for row in rows:
        row["vector"] = embed([row["text"]])[0]
    INDEX.write_text(json.dumps({"model": EMBED, "rows": rows},
        ensure_ascii=False), encoding="utf-8")
    print(f"Indexed {len(rows)} chunks")

def search(question, top_k=3):
    saved = json.loads(INDEX.read_text(encoding="utf-8"))
    if saved["model"] != EMBED:
        raise ValueError("Rebuild index with the current embedding model")
    q = embed([question])[0]
    rows = [dict(r, score=cosine(q, r["vector"])) for r in saved["rows"]]
    return sorted(rows, key=lambda r: r["score"], reverse=True)[:top_k]

if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("question", nargs="?")
    parser.add_argument("--build", action="store_true")
    args = parser.parse_args()
    if args.build:
        build()
    elif args.question:
        for r in search(args.question):
            print(r["id"], round(r["score"], 3), r["text"])
    else:
        parser.error("Use --build or provide a question")
.\.venv\Scripts\python.exe .\semantic_search.py --build
.\.venv\Scripts\python.exe .\semantic_search.py "백업은 며칠 보관하나요?"

코사인 유사도 읽기

코사인 유사도는 두 벡터의 내적을 각 길이의 곱으로 나눈 값입니다. 벡터의 크기보다는 방향이 가까운지를 비교해요. 점수가 0.8이라고 정답일 확률이 80%인 것은 아닙니다. 가장 비슷한 문서에도 질문의 답이 없을 수 있습니다.

샘플은 500글자씩 나누고 80글자를 겹칩니다. 경계에서 문장이 끊기는 문제를 줄이기 위한 출발값이며 최적값을 측정한 결과는 아닙니다. 글자 수와 토큰 수가 다르기 때문에 truncate=false로 한도 초과가 드러나도록 했습니다. 자료가 커지면 제목·문단·토큰 기준의 분할도 검토합니다.

검색 실패와 인덱스 갱신

정답 근거가 들어 있는 조각이 상위 3개에 나오는지 확인합니다. 검색에서 빠진 내용을 뒤의 생성 모델이 제대로 보충하리라고 기대하면 안 됩니다. 제품 코드나 숫자처럼 정확한 일치가 중요한 자료라면 키워드 검색을 함께 사용하는 방법도 있습니다.

임베딩 모델을 바꾸거나 원문을 수정·삭제하면 인덱스도 다시 만들어야 합니다. index.json에는 원문 조각과 의미 정보가 들어 있어요. 업무 자료의 인덱스를 공개 폴더나 소스 저장소에 올리지 않도록 주의합니다.

참고 자료

문서·모델 태그 확인 기준: 2026년 10월 18일. 기본 실습 환경: Windows 11 x64.


댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

닉네임·이메일·댓글 내용·IP 주소·브라우저 정보는 댓글 게시 및 관리에 사용됩니다. 이메일과 접속 정보는 공개 댓글에 표시하지 않습니다. 개인정보 요청은 운영자 이메일로 보내 주세요. 개인정보처리방침