
3일 중 1일 차: 단 하나의 AI 모델도 건드리지 않고 RAG의 검색(Retrieval) 부분 구축하기
요약
RAG 시스템 구축의 첫 단계로, AI 모델 없이 검색(Retrieval) 엔진을 독립적으로 구축하는 과정을 다룹니다. 임베딩, 벡터 검색, RAG의 핵심 개념을 설명하며 데이터의 의미론적 검색 구조를 이해하는 데 집중합니다.
핵심 포인트
- 임베딩을 통한 텍스트의 벡터 변환 원리 이해
- 벡터 공간에서의 최근접 이웃 검색 방식 학습
- RAG의 핵심인 검색 단계와 프롬프트 삽입 과정 파악
- 모델 없이 검색 시스템의 독자적 작동 증명
저는 오늘 세션을 향후 3일 동안 모두가 가슴에 새겼으면 하는 문장으로 시작했습니다.
"오늘 일과가 끝날 때쯤, 여러분은 API 엔드포인트(endpoint)를 통해 실제 문서를 업로드하고, 이를 자동으로 청크(chunks)로 분할하며, 벡터(vectors)로 변환하여 단순히 키워드가 아닌 의미에 따라 검색할 수 있도록 저장하는 과정을 수행할 수 있게 될 것입니다. 오늘은 어떤 AI 모델도 건드리지 않을 것입니다. 이는 의도적인 것입니다. 여러분이 검색(retrieval) 기능이 독자적으로 작동하는 것을 먼저 확인하기를 원하기 때문입니다."
이것은 코호트(cohort) 6 인턴들과 함께하는 3일간의 문서 지능(Document Intelligence) API 구축 과정 중 1일 차입니다. 지난주 Grade Tracker를 구축할 때 사용했던 FastAPI 기술인 Depends, 응답 모델(response models), 엔드포인트(endpoints)를 그대로 사용하지만, 그 밑단에는 새로운 종류의 데이터베이스가 깔려 있습니다. 오늘은 검색(retrieval)만 다루었습니다. LLM도, 생성(generation)도, API 키도 필요하지 않았습니다. 그저 시스템의 검색 부분이 실제로 작동한다는 것을 증명하는 과정이었습니다.
진행 과정은 다음과 같습니다.
세 가지 아이디어, 화이트보드만 사용, 10분 소요
코드를 작성하기 전, 저는 보드에 세 가지 아이디어를 그렸고 실내에 있는 사람들에게 말했습니다. "오늘 일과가 끝날 때쯤 여러분이 이것을 기억만으로 다시 그릴 수 있기를 바랍니다."
임베딩 (Embedding): 텍스트 조각을 숫자 리스트, 즉 벡터(vector)로 변환하는 것입니다. 이를 통해 유사한 _의미_를 가진 텍스트가 해당 숫자 공간에서 서로 가까이 위치하게 됩니다. "고양이"와 "개"는 서로 가까운 곳에 위치합니다. "고양이"와 "스프레드시트"는 멀리 떨어져 위치합니다. 모델은 고양이가 무엇인지 배운 적이 없지만, 방대한 양의 텍스트 패턴으로부터 이러한 근접성을 학습했습니다.
벡터 검색 (Vector search): 해당 숫자 공간에서의 최근접 이웃(nearest-neighbour) 검색일 뿐입니다. 질문을 가져와 동일한 방식으로 임베딩한 후, 저장된 벡터 중 어느 것이 가장 가까이 있는지 찾습니다. 여기서 "관련성(relevant)"이 있다는 것은 바로 이 근접성을 의미합니다.
RAG (Retrieval-Augmented Generation, 검색 증강 생성) - 먼저 관련 청크(chunks)를 검색한 다음, 이를 LLM의 프롬프트(prompt)에 붙여넣습니다. 이렇게 하면 LLM이 학습 과정에서 암기한 내용으로 추측하는 대신 여러분의 데이터를 사용하여 답변하게 됩니다.
질문 (Question) --> [임베딩 (embed)] --> [벡터 검색 (search vectors)] --> 상위 k개 청크 (top-k chunks) --> [프롬프트에 삽입 (stuff into prompt)] --> LLM --> 답변 + 출처 (Answer + sources)
제가 이 내용을 가르칠 때마다 매번 천천히 두 번씩 반복하는 문장이 있습니다. "검색 (Retrieval) 단계에서 적절한 청크 (Chunk)를 프롬프트에 넣어주지 않는 한, LLM은 여러분의 문서를 절대 볼 수 없습니다." 만약 첫째 날에 이 단 하나의 개념이 머릿속에 박힌다면, 남은 일주일의 과정이 이해되기 시작할 것입니다. 그 외의 모든 것은 이 하나의 사실을 구현하기 위한 세부 사항일 뿐입니다.
설정하기: 터미널에서 실시간으로 구축하는 실제 폴더 구조
스타터 리포지토리 (Starter repo)도, 압축 파일 (Zip file)도 없습니다. 우리는 터미널에서 아무것도 없는 상태로부터 프로젝트 구조를 함께 구축했습니다.
mkdir interdoc && cd interdoc
mkdir app && cd app
touch ingestion.py vectorstore.py main.py
세 개의 빈 파일입니다. 저는 파일들을 건드리기 전에 칠판에 ingestion.py, vectorstore.py, main.py라는 이름을 미리 적어두고, 우리가 구축할 때마다 각 파일을 가리켰습니다. 이번 코호트 (Cohort) 학생들은 지난주 database.py, models.py, schemas.py, crud.py, main.py를 구축하며 이미 이 리듬에 익숙해져 있습니다. 하나의 파일은 하나의 작업만 수행합니다. 파일들은 서로의 내부 구현에 직접 접근하는 대신, 깔끔한 함수 호출 (Function calls)을 통해 서로 통신합니다.
또한 시작하기 전에 첫째 날의 목표를 체크리스트로 작성해 두었습니다. 임베딩 (Embedding)이란 무엇인지, 그리고 왜 유사한 의미가 인접한 벡터 (Vector)로 나타나는지 설명하기, 키워드 검색 (Keyword search)과 벡터 검색 (Vector search)의 차이 설명하기, 자신만의 엔드포인트 (Endpoint)를 통해 실제 파일을 업로드하고 이를 청킹 (Chunking)하여 저장하기, 청크 오버랩 (Chunk overlap)이 왜 중요한지 설명하기, AI를 전혀 개입시키지 않고 검색 쿼리 (Search query) 실행하기, 그리고 이것을 실제 제품이라고 부르기 위해 무엇이 여전히 부족한지 정확히 파악하기 등입니다.
체크리스트의 마지막 줄인 "hio ni kesho" (그건 내일이야)는 의도적인 경계였습니다. 오늘은 검색 (Retrieval) 단계입니다. 생성 (Generation)은 오늘이 아닌 2일 차의 문제입니다.
파일 1: ingestion.py - 텍스트 추출 및 청킹 (Chunking)
"이 파일의 역할은 단 하나입니다. 업로드된 파일에서 원시 바이트 (raw bytes)를 가져와 임베딩 (embedding)할 준비가 된 깨끗한 텍스트 청크 (text chunks) 리스트로 변환하는 것입니다. 이 파일은 ChromaDB와 통신하지 않으며, FastAPI와도 통신하지 않습니다."
import io
from pypdf import PdfReader
...
io.BytesIO를 사용하면 디스크에 아무것도 저장하지 않고도 메모리에 있는 원시 바이트를 마치 파일인 것처럼 다룰 수 있습니다. 업로드된 파일은 바이트 형태로 도착하며, PdfReader는 파일처럼 동작하는 무언가를 기대하기 때문에 이 방식이 둘 사이를 연결해 줍니다. 저는 수업 중에 다음과 같이 강조했습니다. "우리가 업로드된 파일을 어디에도 저장하지 않는다는 점에 주목하세요. 메모리에서 직접 바이트를 가져와 텍스트를 추출하며, 원본 파일은 사라집니다. 이 프로젝트에서는 괜찮습니다. 우리는 파일 자체가 아니라 그 안의 텍스트에만 관심이 있기 때문입니다."
누군가 질문했습니다. "Word 문서나 스캔된 PDF는 어떻게 하나요?" 솔직한 답변은 이렇습니다. 스캔된 PDF는 이미지에서 텍스트를 읽어내는 별도의 도구인 OCR (광학 문자 인식)이 필요하며, Word 문서는 완전히 다른 라이브러리가 필요합니다. 오늘은 의도적으로 PDF와 일반 텍스트(plain text)로만 제한했습니다. 그래야 수업의 초점이 파일 형식 고고학이 아닌 검색 (retrieval)에 머물 수 있기 때문입니다.
그다음은 이번 세션의 핵심이 될 함수입니다:
def chunk_text(text: str, chunk_size: int = 300, overlap: int = 50) -> list[str]:
words = text.split()
chunks = []
...
"이 함수는 오늘 밤 친구에게 설명할 수 있어야 하는 함수입니다. 문서 전체를 하나의 거대한 벡터 (vector)로 임베딩할 수는 없습니다. 그렇게 하면 스무 가지의 서로 다른 아이디어가 공간상의 한 점에 뒤섞여 너무 모호해질 것입니다. 따라서 우리는 텍스트를 더 작은 조각으로 나누고 각 청크 (chunk)를 별도로 임베딩합니다."
우리는 12개의 단어로 된 문장에서 아주 작은 숫자들인 chunk_size=5, overlap=2를 사용하여 칠판에 직접 손으로 해보았습니다. 첫 번째 청크 (chunk): 0번부터 5번 단어. 다음 시작 지점: 0 + 5 - 2 = 3. 두 번째 청크: 3번부터 8번 단어. 3번과 4번 단어는 두 청크 모두에 나타나는데, 이것이 바로 오버랩 (overlap)입니다. 이것이 왜 중요할까요? 만약 누군가의 질문에 대한 답이 두 청크 사이의 경계선에 딱 걸쳐 있다면, 오버랩이 없을 경우 그 문장을 반으로 잘라버리게 되어 두 조각 모두에서 정보를 놓치게 될 것이기 때문입니다.
REPL의 순간 - 그리고 실제 오타
우리는 다른 무엇을 연결하기 전에, 대화형 Python 셸 (interactive Python shell)에서 chunk_text()를 실시간으로 테스트했습니다.
from app.ingestion import chunk_text
text = "An embedding is a piece of text turned into a list of numbers (vector) - such that text with similar MEANING ends up close together in that number space"
...
3
나는 첫 번째 조각을 확인하기 위해 chunk[0]이라고 입력했습니다. s를 붙이는 것을 잊어버린 것이죠. Python은 즉시 NameError: name 'chunk' is not defined. Did you mean: 'chunks'?라고 답했습니다.
나는 그 화면을 그대로 둔 채 학생들이 소리 내어 읽게 했습니다. 그러고 나서 chunks[0]으로 수정했습니다.
'An embedding is a piece of text turned into a list of numbers (vector) -'
chunks[1]
'list of numbers (vector) - such that text with similar MEANING ends up close together'
나는 반복되는 단어들을 직접 가리켰습니다. chunks[0]을 끝맺었던 것과 동일한 단어인 _"vector"_와 _"-"_가 chunks[1]의 시작 부분에 놓여 있었습니다. 그것이 바로 설계된 대로 정확히 작동하고 있는 오버랩이었으며, 이론으로 설명하는 대신 실제 출력 결과에서 눈으로 확인할 수 있는 모습이었습니다.
나는 강의실에 이렇게 말했습니다. "그 NameError는 이번 주에 여러분 모두에게, 아마도 한 번 이상 발생할 것입니다. Python은 여러분이 무엇을 의도했는지 정확히 알려줍니다. 당황하기 전에 에러를 먼저 읽으세요."
실시간 REPL에서의 오타는 활용 가능한 가장 유용한 교육 순간 중 하나입니다. 에러는 실패가 아니라 정보라는 것을 보여주기 때문입니다.
또한 우리는 의도적으로 overlap >= chunk_size인 경우에 어떤 일이 발생하는지도 살펴보았습니다. start가 앞으로 나아가지 못하거나 뒤로 이동하게 되어 while 루프가 절대 종료되지 않는 상황입니다. 저는 자원봉사자 한 명에게 짧은 문자열로 이를 시도해 보게 하여 프로그램이 멈추는 것을 관찰한 뒤, Ctrl+C로 빠져나오게 했습니다. 자정에 우연히 발견하기보다는, 안전한 샌드박스(sandbox) 환경에서 의도적으로 한 번쯤 경험해 보기 아주 좋은 버그입니다.
파일 2: vectorstore.py : 청크가 벡터가 되는 곳
"두 번째 파일은 청크(chunks)가 실제로 벡터(vectors)로 변환되어 나중에 검색할 수 있는 어딘가에 저장되는 곳입니다. 여기에는 두 가지 새로운 개념이 담겨 있습니다. 텍스트를 숫자로 변환하는 sentence-transformers와, 그 숫자들을 저장하고 검색하는 방법을 알고 있는 ChromaDB입니다."
import chromadb
from chromadb.utils import embedding_functions
...
PersistentClient(path="./data/chroma_db")는 Chroma가 해당 폴더의 디스크에 모든 것을 기록한다는 의미입니다. 서버를 중단하고 내일 다시 시작해도, 업로드한 문서들은 여전히 그곳에 남아 있습니다. 저는 이 문장을 특별히 강조했습니다: "이 한 줄이 우리 벡터 스토어(vector store)를 위한 전체 '데이터베이스 연결'입니다. PostgreSQL에서처럼 백그라운드에서 별도로 실행되는 서버가 없습니다. Chroma는 디스크의 폴더를 읽고 쓰는 라이브러리입니다. 그것이 인프라의 전부입니다."
임베딩 함수(embedding function)는 별도로 언급할 가치가 있습니다: "이 모델은 로컬(locally)에서 실행됩니다. 처음 사용할 때 한 번 다운로드하면, 그 이후의 모든 임베딩은 여러분의 노트북에서 무료로 영구히 수행됩니다. 내일 연결할 유료 API를 호출하는 LLM과 비교해 보세요. 임베딩은 저렴하고 로컬에서 이루어집니다. 비용이 발생하는 부분은 생성(Generation) 단계입니다."
저는 add_chunks()의 metadatas를 직접 가리키며 말했습니다: "이것이 단순한 데모를 실제 제품으로 바꾸는 디테일입니다. 이것이 없다면, 우리는 벡터가 어떤 문서나 문서의 어느 부분에서 왔는지 알 수 없게 되며, 나중에 질문에 답할 때 인용(citation)을 보여줄 방법도 없게 됩니다."
그리고 search_chunks()는 파일의 순간을 가져옵니다: "잠시 이 부분을 생각해보세요. 단 한 줄의 코드가 질문을 임베딩(embed)하고, 저장된 수많은 청크(chunk)들에 대해 최근접 이웃 탐색 (nearest-neighbour search)을 수행한 뒤, 거리(distance) 정보와 함께 가장 적합한 매칭 결과를 반환합니다. 이 이전의 모든 과정은 준비 단계였습니다. 이 한 줄이 바로 검색 증강 생성 (Retrieval-Augmented Generation, RAG)에서의 실제 검색 (retrieval) 단계입니다."
누군가 왜 Pinecone, Weaviate 또는 pgvector가 아니라 ChromaDB를 사용하는지 묻는다면, 솔직한 답변은 이렇습니다. 그것들은 훌륭한 프로덕션 (production)용 선택지들이지만, 각각 계정, API 키 또는 실행 중인 서버 프로세스가 필요합니다. Chroma는 그중 어느 것도 필요하지 않으며, 이것이 바로 이번 주에 Chroma가 적절한 교육 도구인 정확한 이유입니다. 프로덕션급 옵션이 필요한 날이 오면, 여기서 배운 개념들은 그대로 적용될 것입니다.
파일 3: main.py - 의도적으로 작게 만들기
"이 파일이 얼마나 작은지 주목하세요. ingestion.py와 vectorstore.py가 모든 실제 작업을 수행하며, main.py는 단순히 HTTP 요청을 적절한 함수 호출로 연결할 뿐입니다."
from fastapi import FastAPI, UploadFile, File
from app.ingestion import extract_text, chunk_text
from app.vectorstore import add_chunks
...
지난주 crud.py와 동일한 관심사 분리 (separation-of-concerns) 규칙이 적용되었습니다. main.py는 텍스트를 추출하지도, 무언가를 임베딩하지도 않으며, 데이터베이스를 직접 건드리지도 않습니다. HTTP 요청을 받아 적절한 전문 파일로 작업을 넘겨줄 뿐입니다.
"이 엔드포인트(endpoint)가 마치 하나의 문장처럼 읽히는 것을 보세요: 파일을 읽고, 텍스트를 추출하고, 청크로 나누고, 저장소에 추가하고, 작업이 성공했는지 확인합니다. 다섯 줄의 코드, 다섯 개의 명확한 단계. 이번 주에 우리가 작성하는 모든 엔드포인트는 이와 동일한 '수신, 위임, 확인' 구조를 따릅니다."
서버 시작하기 - 또 다른 솔직한 오타
uvicorn app.main:app --reload
첫 번째 시도에서 저는 오타를 냈습니다: --relaod. Uvicorn은 해당 플래그를 인식하지 못하고 에러를 냈습니다. 저는 이를 발견하여 철자를 수정하고 다시 실행했습니다.
[

AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
