RAG와 임베딩 · 글 11편
RAG와 임베딩이 글에서 다루는 것젬마 계열 임베딩 모델로 만든 로컬 사진 검색 실습 프로젝트의 코드를 단계별로 읽습니다. 색인, 캐시, 질문 임베딩, 유사도 계산의 구조를 본 뒤 같은 뼈대를 문서 검색으로 바꾸는 방법을 정리합니다.
무엇을 만든 프로젝트인가
신성진 강사가 만든 실습 프로젝트를 읽습니다. 폴더에 넣어 둔 사진을 한국어 문장으로 찾는 로컬 검색 서비스입니다. “붉은 노을이 물드는 저녁 바다”라고 입력하면 노을 사진이 위에 나옵니다. 사진 한 장을 올려 비슷한 사진을 찾는 기능도 있습니다.
문서가 아니라 사진을 다루지만, 구조는 문서 검색과 같습니다. 임베딩 검색의 뼈대를 가장 짧은 코드로 볼 수 있어서 이 프로젝트를 골랐습니다. 글 끝에서 문서 검색으로 바꾸는 방법을 다룹니다.
| 항목 | 내용 |
|---|---|
| 임베딩 모델 | 젬마 계열 임베딩 모델. 글과 이미지를 같은 벡터 공간에 놓는 모델을 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가 하는 일입니다.
- 캐시 파일이 있으면 읽습니다.
- 사진 폴더에서 jpg, jpeg, png, webp 파일 목록을 만듭니다.
- 캐시에 없거나 수정 시각이 달라진 파일만 고릅니다.
- 고른 파일을 하나씩 임베딩하고, 길이를 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건으로 정답률 측정
- 모든 응답에 출처 포함
- 절대 경로 금지, 상대 경로만 사용
사람이 맡는 일은 문서를 넣는 것과 검수입니다. 검수 방법은 다음 글에서 다룹니다.