RAG와 임베딩 · 글 11편RAG와 임베딩
  1. RAG란 무엇인가 - 넣기, 쪼개기, 찾기, 답하기
  2. 문서를 붙여 넣을까, RAG를 만들까
  3. 임베딩이란 무엇인가
  4. 로컬 임베딩 모델 고르기
  5. 문서를 청크로 나누기
  6. 키워드 검색, 의미 검색, 하이브리드 검색
  7. 로컬 의미 검색 만들기 - 젬마 임베딩 실습 따라 읽기
  8. RAG 답변 평가와 자주 생기는 실패
  9. RAG, 긴 컨텍스트, 파인튜닝, LLM Wiki 비교
  10. RAG가 잘 되도록 사무 문서 준비하기
  11. 내부 문서를 색인할 때의 개인정보와 보안

이 글에서 다루는 것젬마 계열 임베딩 모델로 만든 로컬 사진 검색 실습 프로젝트의 코드를 단계별로 읽습니다. 색인, 캐시, 질문 임베딩, 유사도 계산의 구조를 본 뒤 같은 뼈대를 문서 검색으로 바꾸는 방법을 정리합니다.

무엇을 만든 프로젝트인가

신성진 강사가 만든 실습 프로젝트를 읽습니다. 폴더에 넣어 둔 사진을 한국어 문장으로 찾는 로컬 검색 서비스입니다. “붉은 노을이 물드는 저녁 바다”라고 입력하면 노을 사진이 위에 나옵니다. 사진 한 장을 올려 비슷한 사진을 찾는 기능도 있습니다.

문서가 아니라 사진을 다루지만, 구조는 문서 검색과 같습니다. 임베딩 검색의 뼈대를 가장 짧은 코드로 볼 수 있어서 이 프로젝트를 골랐습니다. 글 끝에서 문서 검색으로 바꾸는 방법을 다룹니다.

항목 내용
임베딩 모델 젬마 계열 임베딩 모델. 글과 이미지를 같은 벡터 공간에 놓는 모델을 sentence-transformers로 불러옴
실행 장치 CPU만 사용
서버 FastAPI
벡터 저장 JSON 파일 하나
유사도 계산 NumPy 행렬 곱셈
벡터 데이터베이스 쓰지 않음

파일은 다음과 같습니다.

파일 역할
prepare_samples.py 연습용 그림 12장을 코드로 그려서 만듦(노을, 숲, 야경, 설산 등)
preindex.py 사진 폴더를 훑어 임베딩을 미리 계산하고 캐시에 저장
server.py 검색 API와 화면을 제공
test_embedding.py 모델이 제대로 불러와지는지 확인
test_client.py 서버에 질문 다섯 개를 보내 결과를 출력

연습용 그림을 코드로 만든 점을 눈여겨봅니다. 실제 사진이나 내부 자료 없이 구조부터 검증할 수 있습니다.

1단계: 모델 불러오기

model = SentenceTransformer(
    "google/embeddinggemma-2",
    device="cpu",
    config_kwargs={"audio_config": None}
)

device="cpu"로 그래픽카드 없이 실행합니다. audio_config를 비워 소리 처리 부분을 빼고 글과 이미지 부분만 불러옵니다. 서버 코드는 이 설정으로 실패하면 기본 설정으로 다시 불러오도록 예외 처리를 해 두었습니다.

모델 이름은 프로젝트 코드에 적힌 그대로입니다. 직접 해 볼 때는 모델 카드에서 현재 이름과 사용 조건을 확인합니다. 텍스트 전용 임베딩 모델을 고르는 기준은 로컬 임베딩 모델 고르기에 있습니다.

2단계: 미리 색인하기

preindex.py가 하는 일입니다.

  1. 캐시 파일이 있으면 읽습니다.
  2. 사진 폴더에서 jpg, jpeg, png, webp 파일 목록을 만듭니다.
  3. 캐시에 없거나 수정 시각이 달라진 파일만 고릅니다.
  4. 고른 파일을 하나씩 임베딩하고, 길이를 1로 맞춰(정규화) 저장합니다.

새 파일만 고르는 부분입니다.

to_process = []
for f in image_files:
    mtime = f.stat().st_mtime
    if f.name not in cache or cache[f.name].get("mtime") != mtime:
        to_process.append(f)

임베딩하고 저장하는 부분입니다.

emb = model.encode({"image": img_rgb})
norm_v = normalize_vector(emb)
cache[f.name] = {
    "vector": norm_v,
    "mtime": f.stat().st_mtime,
    "size": f.stat().st_size
}

여기서 배울 점은 두 가지입니다.

바뀐 것만 다시 계산합니다. 임베딩은 시간이 걸리는 작업입니다. 파일 이름과 수정 시각을 함께 저장해 두고, 달라진 파일만 다시 임베딩합니다. 문서 검색에서도 같은 방식을 씁니다.

저장할 때 정규화합니다. 길이를 1로 맞춰 두면 검색할 때 곱셈만으로 코사인 유사도가 나옵니다. 원리는 임베딩이란 무엇인가에서 설명했습니다.

3단계: 캐시 파일

캐시는 JSON 파일 하나입니다. 구조는 이렇습니다.

{
  "sunset_beach.jpg": {
    "vector": [-0.0595, -0.0563, ...],
    "mtime": 1700000000.0,
    "size": 48213
  }
}

파일 이름이 열쇠이고, 그 아래에 벡터와 수정 시각, 크기가 있습니다. 서버가 켜질 때 이 파일을 통째로 메모리에 올립니다.

사진 수십~수백 장, 문서 조각 수천 개 규모에서는 이 방식으로 충분합니다. 조각이 수만 개를 넘어 파일을 읽는 시간이나 메모리가 부담될 때 벡터 데이터베이스를 검토합니다.

4단계: 질문으로 검색하기

서버의 텍스트 검색 부분입니다. 실제 코드를 순서대로 봅니다.

먼저 질문을 임베딩합니다.

query_emb = model.encode(req.query, prompt_name="SearchQuery")
q_vec = get_truncated_vector(query_emb, dim)

prompt_name="SearchQuery"는 이 입력이 검색 질문이라는 것을 모델에 알려 주는 설정입니다. get_truncated_vector는 벡터를 지정한 차원으로 자르고 다시 정규화합니다.

다음으로 저장된 벡터를 행렬 하나로 모으고 한 번에 곱합니다.

img_matrix = np.array(matrix, dtype=np.float32)   # (N, dim)
scores = np.dot(img_matrix, q_vec)                # 코사인 유사도
top_indices = np.argsort(scores)[::-1][:req.top_k]

scores에는 사진마다 점수가 하나씩 들어 있습니다. 큰 순서로 정렬해 위에서 top_k개를 고릅니다. 화면에는 -1에서 1 사이의 점수를 0~100으로 바꿔 보여 줍니다.

"percentage": round(float((scores[idx] + 1) / 2 * 100), 1)

이 퍼센트는 보기 편하게 바꾼 숫자입니다. “90%“가 “90% 확률로 정답”이라는 뜻은 아닙니다.

5단계: 화면과 주소

서버는 다음 주소를 제공합니다.

주소 하는 일
GET /api/status 모델, 색인된 사진 수, 지원 차원 확인
POST /api/scan-and-index 폴더를 다시 훑어 새 사진 색인
POST /api/upload 사진 한 장을 올리고 바로 색인
POST /api/search/text 문장으로 사진 검색
POST /api/search/image 사진으로 비슷한 사진 검색
GET /api/benchmark-mrl 차원별 결과 비교

서버는 127.0.0.1에서만 열립니다. 같은 PC의 브라우저에서만 접속할 수 있고, 같은 네트워크의 다른 PC에서는 접속할 수 없습니다. 내부 자료를 다룰 때 안전한 기본값입니다.

6단계: 차원을 줄여도 되는지 재 보기

이 프로젝트는 768차원 벡터를 128, 256, 512차원으로 잘라 쓸 수 있습니다. 줄여도 결과가 유지되는지 확인하는 기능이 benchmark-mrl입니다. 같은 질문으로 768차원의 상위 5개를 기준으로 삼고, 줄인 차원의 상위 5개가 몇 개나 겹치는지 계산합니다.

overlap = len(set(base_top5) & set(top5_d)) / 5.0 * 100

판단을 감으로 하지 않고 숫자로 확인하는 방식입니다. 설정을 바꿀 때마다 “바꾸기 전과 결과가 얼마나 같은가”를 재는 습관은 문서 검색에서도 그대로 씁니다.

문서 검색으로 바꾸기

같은 뼈대에서 세 곳을 바꿉니다.

단계 사진 검색 문서 검색
색인 대상 사진 파일 문서에서 나눈 조각
임베딩 입력 이미지 조각의 텍스트
캐시의 열쇠 파일 이름 조각 번호(chunk_id)
캐시에 함께 저장 수정 시각, 크기 문서 번호, 위치, 원문 텍스트, 원본 수정 시각
검색 결과 사진 조각과 출처
그다음 화면에 표시 상위 조각을 대화 모델에 넣어 답변 작성

색인 부분은 이렇게 바뀝니다. 조각을 만드는 방법은 문서를 청크로 나누기를 따릅니다.

texts = [c["text"] for c in chunks]
vecs = model.encode(texts)
vecs = vecs / np.linalg.norm(vecs, axis=1, keepdims=True)

검색 부분은 사진 검색과 같습니다. 질문을 임베딩하고, 행렬 곱셈으로 점수를 구하고, 상위 몇 개를 고릅니다. 달라지는 것은 결과로 사진 대신 조각의 텍스트와 위치를 돌려준다는 점입니다.

문서용으로 바꿀 때 확인할 점입니다.

  • 모델에 따라 문서를 임베딩할 때도 접두어나 프롬프트 이름을 지정해야 합니다. 모델 카드의 예시를 따릅니다.
  • 조각이 모델의 입력 길이 제한을 넘지 않는지 확인합니다.
  • 마지막 단계가 하나 더 붙습니다. 찾은 조각을 대화 모델에 넣어 답하게 하는 부분입니다. 코드와 프롬프트는 RAG란 무엇인가에 있습니다.

코딩 에이전트에게 맡길 때

이 정도 프로젝트는 코딩 에이전트에게 설계서 한 장을 주고 만들게 할 수 있습니다. 강의에서 쓰는 형식입니다.

## 목표
docs/ 폴더의 모든 PDF를 읽어 질문에 답하는 CLI 검색 도구를 만든다.

## 처리
- 텍스트 추출 → 500자 청크, 50자 겹침
- 로컬 임베딩 모델로 임베딩, 정규화해서 파일로 저장
- 바뀐 문서만 다시 색인

## 출력
- python rag.py index docs
- python rag.py query "연차 규정"  → 답변 + 출처 3건

## 검수
- 정답 20건으로 정답률 측정
- 모든 응답에 출처 포함
- 절대 경로 금지, 상대 경로만 사용

사람이 맡는 일은 문서를 넣는 것과 검수입니다. 검수 방법은 다음 글에서 다룹니다.

다음에 읽을 글