"아무것도 이해하지 못했다"에서 RAG 앱 구축까지
요약
NLP 개념을 단순히 읽는 대신, 기초적인 원리부터 직접 구현하며 학습하는 과정을 다룹니다. 단어를 숫자로 변환하는 Bag of Words 기법을 예시로 들어, 이론을 실제 작동하는 RAG 앱 구축으로 연결하는 실무적 접근법을 소개합니다.
핵심 포인트
- 단순한 이론 학습보다 직접 구현하며 원리를 파악하는 것이 중요함
- NLP의 핵심은 텍스트를 의미를 유지하며 숫자로 변환하는 과정임
- Bag of Words는 단어의 빈도를 이용해 텍스트를 벡터화하는 기초 기법임
- scikit-learn을 활용하면 간단한 코드로 단어 빈도 계산이 가능함
어제 저는 AI 엔지니어링 과정 동안 정성스럽게 작성했던 NLP (자연어 처리)에 관한 31페이지 분량의 제 개인 노트를 훑어보는 시간을 가졌습니다. 하지만 제가 그 내용을 거의 이해하지 못하고 있다는 사실을 발견했습니다.
직접 무언가를 적어 두었음에도 그것을 파악하지 못한다는 사실이 이상하고 낙담스럽게 느껴졌습니다. 처음에는 제 문제가 문제라고 생각했습니다. 하지만 그렇지 않았습니다. 진짜 문제는 단순히 노트를 읽는 것이 진정한 학습과 같지 않다는 점이었습니다. 제 노트는 이미 어느 정도 이해를 갖춘 상태의 저를 위해 의도된 정보들로 가득 차 있었고, 요약본을 다시 읽는 것만으로는 이해력을 발달시킬 수 없습니다. 그래서 저는 접근 방식을 바꾸기로 했습니다. 읽는 대신, 기초부터 시작하여 단순한 예시만을 사용하여 한 번에 한 질문씩 개념을 설명받았습니다. 제가 이해할 수 있음을 증명할 때까지 전문 용어 (Technical terms)는 허용되지 않았습니다.
하루가 끝날 무렵, 저는 실무 NLP의 네 가지 주요 개념을 밑바닥부터 재구성했고, 이를 사용하여 제 개인 노트를 기반으로 질문에 답하는 작동 가능한 앱을 만들었습니다. 저를 마침내 이해하게 도와준 동일한 예시들과 함께, 여러분도 따라 할 수 있도록 각 단계의 코드를 포함한 전체 여정을 소개합니다.
가능한 가장 멍청한 질문부터 시작하기
컴퓨터가 이메일을 "스팸"과 "스팸 아님"으로 분류하기를 원한다고 가정해 봅시다. 여기서 문제는 컴퓨터가 평생 단 한 단어도 읽어본 적이 없다는 것입니다. 컴퓨터가 할 수 있는 유일한 일, 즉 지금껏 할 수 있었던 유일한 일은 숫자에 대한 수학 연산뿐입니다.
따라서 단 하나의 이메일을 분류하기 전에, 한 가지 문제가 먼저 발생합니다: 단어가 숫자가 되어야 한다는 것입니다. 이 문장은 모든 것의 토대가 되었습니다. NLP의 이 모든 분야는 단 하나의 작업, 즉 중요한 의미를 잃지 않으면서 텍스트를 숫자로 변환하는 작업일 뿐입니다.
저의 첫 번째 본능은 각 _글자(letter)_를 숫자로 바꾸는 것이었습니다. 이는 합리적인 추측이며, 실제로 컴퓨터가 내부적으로 텍스트를 저장하는 방식이기도 합니다. 하지만 이는 _철자(spelling)_를 포착할 뿐, _의미(meaning)_를 포착하지는 못합니다. "win"과 "bin"은 숫자로 표현했을 때 거의 동일해 보이지만, 의미는 완전히 다릅니다. 이메일의 의미는 글자가 아니라 그 안의 **단어(words)**에 담겨 있습니다.
Bag of Words: 철자가 아닌 개수를 세라
그래서 우리는 전체 단어를 가지고 작업합니다. 세 개의 이메일을 예로 들어보겠습니다:
- "win money now"
- "money money money"
- "see you tomorrow"
모든 고유한 단어를 나열하고(이것이 어휘 사전(vocabulary)입니다), 각 단어에 열(column)을 할당한 뒤, 각 이메일에서 각 단어가 몇 번 나타나는지 계산합니다. "win money now"는 1, 1, 1, 0, 0, 0이 됩니다. "money money money"는 0, 3, 0, 0, 0, 0이 됩니다. 갑자기 모든 이메일은 순수한 숫자의 행(row)이 되었고, 컴퓨터는 이를 비교할 수 있게 됩니다.
scikit-learn은 단 세 줄의 코드로 정확히 이 작업을 수행합니다:
from sklearn.feature_extraction.text import CountVectorizer
emails = ["win money now", "money money money", "see you tomorrow"]
...
이것이 전부입니다. 이것이 바로 위협적으로 들리는 "Bag of Words (단어 가방)"입니다. 저는 이에 대해 읽어본 적은 있지만 이해하지 못하고 넘어갔었습니다. 하지만 여기서 저는 2분 만에 직접 손으로 해보았고, 그다음엔 세 줄의 코드로 해냈습니다.
이것이 _가방(bag)_이라고 불리는 데에는 이유가 있으며, 그 이유는 곧 이 방식의 결함이기도 합니다. 문장의 단어들을 종이 가방에 넣고 흔든다고 상상해 보세요. 어떤 단어가 들어있는지, 그리고 얼마나 많이 들어있는지는 여전히 알 수 있지만, 순서는 사라집니다. "win money now"와 "now win money"는 동일한 행을 생성합니다. 보통은 해롭지 않습니다. 하지만 **"dog bites man" (개가 사람을 문다)**과 **"man bites dog" (사람이 개를 문다)**를 생각해 보세요. 동일한 세 단어이지만, 하나는 지루한 화요일의 일이고 다른 하나는 뉴스 1면을 장식할 사건입니다. Bag of Words에게 이 둘은 동일합니다. 이러한 약점 때문에 나중에 더 정교한 방법들이 발명되어야만 했습니다.
TF-IDF: 모든 단어가 동일한 가중치를 가질 필요는 없다
Bag of Words (BoW)에는 두 번째 문제가 있습니다. 모든 단어를 동일하게 중요한 것으로 취급한다는 점입니다. "the"라는 단어는 거의 모든 이메일에 들어있으므로, 이메일에 "the"가 포함되어 있다는 사실은 아무런 정보도 주지 못합니다. 반면 "viagra"와 같은 희귀한 단어는 강력한 신호가 됩니다. 하지만 Bag of Words는 이들을 동일하게 계산합니다.
TF-IDF는 서로 곱해지는 두 개의 다이얼을 통해 이 문제를 해결합니다:
- TF (term frequency, 단어 빈도): 이 이메일 하나에서 단어가 얼마나 자주 나타나는지를 나타냅니다. 메시지에 "money money money money"라고 적혀 있다면, 그것은 분명히 돈(money)에 관한 내용입니다. 반복은 중요성을 의미합니다.
- IDF (inverse document frequency, 역문서 빈도): 전체 문서 집합에서 해당 단어가 얼마나 _희귀한지_를 나타내며, 얼마나 많은 개별 이메일에 해당 단어가 포함되어 있는지를 통해 측정됩니다. 흔한 단어 → 매우 낮은 점수. 희귀한 단어 → 높은 점수. ("Inverse"는 단순히 뒤집혔다는 뜻입니다: 문서가 많을수록 점수는 작아집니다.)
이 둘을 곱하면 아름다운 일이 일어납니다. "the"를 예로 들어봅시다. 이 단어는 많이 등장하지만(높은 TF) 어디에나 존재하므로(0에 가까운 IDF), 높은 값 × 거의 0 ≈ 0이 됩니다. 단어 스스로가 침묵하게 되는 것입니다. 아무도 컴퓨터에게 무시해야 할 단어 목록을 건네주지 않았습니다. 수학이 스스로 군더더기를 제거했습니다. 반면 스팸 이메일 속의 "viagra"는 반복되면서도(높은 TF) 희귀하기(높은 IDF) 때문에 밝게 빛납니다.
API는 동일하며, 단어 하나만 바뀌었습니다:
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer()
...
저는 "win win win money the the"라는 문구로 이 아이디어를 테스트했고, "win"은 반복되면서도 희귀하기 때문에 가장 높은 점수를 받을 것이라고 정확히 예측한 반면, "the"는 아무것도 아닌 상태로 붕괴되었습니다. 이 방식은 저에게 그 방식이 생각하는 대로 생각하는 법을 가르쳐 주었습니다.
Embeddings: 단어에 지도의 위치를 부여하기
위의 두 방법은 모두 하나의 맹점을 공유합니다. 이들에게 "money"와 "cash"는 "money"와 "banana"만큼이나 서로 관련이 없습니다. 각 단어는 그저 별개의 독립된 열(column)일 뿐이며, 이들을 연결하는 것은 아무것도 없습니다. 이들은 서로 다른 두 단어가 거의 같은 의미를 가질 수 있다는 개념이 없습니다.
임베딩 (Embeddings)은 하나의 멋진 아이디어로 이 문제를 해결합니다. 바로 모든 단어를 의미의 지도 (map of meaning) 위에 놓는 것입니다. 각 단어가 하나의 점이 되는 거대한 지도를 상상해 보세요. 우리는 비슷한 의미를 가진 단어들은 서로 가깝게 배치하고, 관련 없는 단어들은 멀리 떨어뜨려 놓습니다. "cash"는 "money" 바로 옆에 위치하고, "banana"는 "apple" 근처 멀리 떨어져 있습니다. 그 지도 위에서 숫자로 기록된 단어의 좌표가 바로 그 단어의 *임베딩 (embedding)*입니다. "king"은 지도가 의미에 따라 구성되어 있기 때문에 "bicycle"보다는 "queen"에 더 가깝게 위치합니다.
하지만 글을 읽을 수 없는 컴퓨터가 어떻게 각 단어를 어디에 둘지 결정할까요? 바로 **단어가 함께 다니는 동료들 (the company a word keeps)**을 통해서입니다. 컴퓨터는 수백만 개의 문장을 읽으며 "money"와 "cash"가 모두 동일한 이웃들(borrow, pay, bank, withdraw)에 둘러싸여 있다는 사실을 알아차립니다. 같은 동료, 같은 동네인 셈입니다. 반면 "banana"는 다른 동료들(eat, peel, ripe, fruit)과 어울리므로 멀리 떨어지게 됩니다. 컴퓨터는 단어가 무엇을 의미하는지 전혀 알지 못합니다. 그저 문맥의 패턴을 맞출 뿐입니다. (실제 지도는 두 방향의 평면이 아니라 수백 개의 방향을 가지고 있으며, 이것이 임베딩이 긴 숫자 리스트인 이유입니다. 개념은 동일합니다: 가까우면 유사함, 멀면 다름.)
사전 학습된 모델 (pre-trained model)은 이러한 좌표를 여러분에게 직접 제공합니다:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
...
핵심은 이것이 단일 단어에만 국한되지 않는다는 점입니다. 문장 전체, 혹은 노트(note) 전체도 하나의 점이 될 수 있으며, 이것이 바로 다음 단계 전체를 움직이는 핵심 축이 됩니다.
RAG: 내가 실수로 발명했다
여기서 모든 노력이 결실을 맺습니다. 내 위키(wiki)에 있는 모든 노트가 의미 지도 위의 하나의 점이라고 상상해 보세요. 그런 다음 내가 질문을 입력하면, 그 질문 또한 같은 지도 위의 하나의 점이 됩니다. 질문에 답이 되는 노트를 찾기 위해, 나는 그저 가장 가까운 점들을 가져오기만 하면 됩니다.
이것이 바로 RAG (Retrieval-Augmented Generation, 검색 증강 생성)의 핵심 아이디어입니다:
- 질문을 하나의 점으로 변환합니다 (임베딩 (embed)).
- 지도에서 가장 가까운 노트-점들을 찾습니다 (검색 (retrieval)).
- 그 노트들을 언어 모델 (language model)에게 전달하며 "이것들만을 사용하여 답변하세요"라고 말합니다.
- 모델은 추측하는 대신 _나의 실제 노트_에 근거하여 답변을 작성합니다.
대부분의 사람들은 튜토리얼을 복사하며 RAG를 배우기 때문에 내부에서 어떤 일이 일어나고 있는지 전혀 모릅니다. 저는 추론을 통해 RAG에 도달했고, 이는 코드 한 줄을 쓰기 전에 모든 계층을 이해했음을 의미합니다.
Synapse: 실체화하기
그래서 저는 Jupyter notebook에서 이를 구축했고, 이름을 Synapse라고 지었습니다. 시냅스 (synapse)는 두 뉴런 사이의 연결을 의미하는데, 이는 임베딩 (embeddings)이 하는 역할, 즉 의미에 따라 관련된 노트들을 연결하는 역할과 정확히 일치하기 때문입니다.
파이프라인 (pipeline)은 위에서 언급한 네 가지 아이디어를 순서대로 실행하는 것뿐입니다.
1. 모든 노트를 로드 (Load) 합니다:
from pathlib import Path
WIKI = Path("path/to/my/wiki")
...
2. 각 노트를 지도의 점으로 임베딩 (Embed) 합니다:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
...
3. 질문과 가장 가까운 노트를 검색 (Retrieve) 합니다:
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
...
4. 해당 노트들로부터 근거 있는 답변을 생성 (Generate) 합니다:
import anthropic
client = anthropic.Anthropic() # 환경 변수에서 ANTHROPIC_API_KEY를 읽어옵니다
...
마지막 지시 사항("만약 답변이 노트에 없다면, 모른다고 말하세요")은 환각 (hallucination) 방지 가드레일입니다. 이것이 장난감과 신뢰할 수 있는 도구 사이의 차이점입니다.
저는 다음과 같이 물었습니다: "어간 추출 (stemming)과 표제어 추출 (lemmatization)의 차이점은 무엇인가요?" 모델은 정확히 알맞은 노트들을 가져왔고, 제가 작성했던 비교표를 그대로 재현한 답변을 작성했습니다: 도구들, "studies → studi" 예시, 그 모든 것 말입니다. 모델의 학습 데이터에서 나온 것이 아닙니다. 검색되어 나에게 다시 읽어준 _나의 뇌_에서 나온 것입니다.
무엇이 실제로 깨달음을 주었나
이 모든 과정을 시작한 지 한 시간 만에, 저는 "아무것도 이해하지 못했다"에서 스스로 RAG를 유도해내는 단계로 나아갔습니다. 이 교훈은 사실 NLP (자연어 처리)에 관한 것이 아니었습니다. 그것은 바로 _학습하는 방법_에 관한 것이었습니다:
읽는 것이 곧 이해하는 것은 아닙니다. 저는 노트를 가지고 있는 것을 지식을 가지고 있는 것으로 착각했습니다. 해결책은 능동적인 발견(active discovery)이었습니다. 질문을 받고, 한 번에 한 걸음씩 답을 찾아 나서며, 모든 추상적인 아이디어를 구체적인 무언가(종이봉투, 지도, 스팸 이메일 등)에 연결하는 것이었습니다. 그리고 나서 _구축(building)_하는 것입니다. 왜냐하면 무언가를 실제로 작동시켜 보는 것보다 자신이 무엇을 이해하지 못하고 있는지 더 빠르게 드러내 주는 것은 없기 때문입니다.
만약 머릿속에 들어오지 않는 자료를 멍하니 바라보고 있다면, 이렇게 해보세요. 읽는 것을 멈추십시오. 대신 누군가 당신에게 질문을 던지게 만드세요. 가능한 가장 단순한 질문부터 시작해서, 각 부분이 진정으로 이해될 때까지 다음으로 넘어가는 것을 거부하세요. 당신은 믿기 힘들 정도로 하루 만에 더 멀리 나아갈 수 있습니다.
Synapse의 다음 단계: 더 날카로운 검색(retrieval)을 위한 노트 청킹(chunking), 출처 인용, 실제 프론트엔드, 그리고 로컬 모델(local-model) 버전입니다. 여정은 계속됩니다. 그 내용도 곧 작성하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기