테디노트의 RAG 동작 과정 EP01과 실행 단계 EP02를 보다가 임베딩 모델의 dimension=1024라는 값을 만났다. 처음에는 이 값을 텍스트를 담을 수 있는 공간 크기처럼 받아들였다.
내 첫 이해는 대략 이랬다.
차원이 1024라면 1024자 정도를 담는 공간 아닐까? 긴 문서를 청킹하면 청크들이 차원을 하나씩 차지하는 것 아닐까? 벡터로 저장된 청크를 LLM은 어떻게 읽는 걸까?
세 질문이 한꺼번에 섞여 있었다. 청크의 길이, 청크의 개수, 벡터의 차원, LLM에 전달되는 데이터를 같은 흐름 안에서 구분하지 못했다. 특히 문서 저장과 질문 검색을 동시에 생각하면서 숫자 벡터가 원문을 대체한다고 착각했다.
지금은 다음 문장으로 먼저 고정한다.
청크 개수는 벡터 공간에 찍히는 점의 개수이고, 임베딩 차원은 점 하나를 나타내는 좌표축의 개수다. 벡터는 검색에 사용하고, 생성 LLM에는 검색된 원문 텍스트를 전달한다.
먼저 네 가지 크기를 분리하기
RAG를 볼 때 다음 값들은 서로 다른 축이다.
| 구분 | 의미 | 예시 |
|---|---|---|
| 문서 길이 | 원문 전체의 문자·토큰 수 | 40,000자 |
| 청크 크기 | 검색 단위 하나에 넣을 문자·토큰 수 | 800자 |
| 청크 개수 | 문서를 나눈 결과의 수 | 약 50개 |
| 임베딩 차원 | 청크 하나를 나타내는 숫자의 수 | 1,024차원 |
여기서 문자 수와 토큰 수도 같지 않다. 청커는 문자 기준으로 자를 수도 있고 tokenizer 기준으로 자를 수도 있다. 임베딩 모델의 최대 sequence length는 보통 토큰 기준이다.
예를 들어 BGE-M3 모델 카드는 dimension과 sequence length를 각각 1024, 8192로 구분한다. 1024는 출력 벡터의 좌표 수이고, 8192는 모델이 처리할 수 있는 입력 token sequence 길이 쪽의 값이다. 같은 숫자표에 있어도 역할이 전혀 다르다.
4만 자 문서를 800자씩 자르면 1024차원이 되는가
아니다. 오버랩이 없다면 계산은 단순하다.
40,000자 / 800자 = 50개 청크
오버랩을 100자로 두면 다음 청크는 700자씩 이동한다.
chunk size = 800
overlap = 100
stride = 700
청크 개수 ≈ ceil((40,000 - 800) / 700) + 1 = 57개
실제 semantic chunking은 문장이나 의미 경계를 따르므로 청크 길이와 개수가 고정되지 않을 수 있다. 그래도 청크 수가 임베딩 차원을 결정하지 않는다는 점은 같다.
1024차원 임베딩 모델을 사용한다면 결과의 모양은 다음과 같다.
chunk 01 → vector 01: [1024개의 숫자]
chunk 02 → vector 02: [1024개의 숫자]
...
chunk 50 → vector 50: [1024개의 숫자]
전체 모양: [50, 1024]
문서 하나가 50차원 벡터가 되는 것이 아니다. 문서 하나에 연결된 1024차원 벡터 50개가 생긴다.
내가 이 부분을 이해할 때 가장 잘 맞았던 비유는 방과 공이었다.
3차원 방에 공 700개를 놓아도 방은 3차원이다.
1024차원 공간에 청크 700개를 넣어도 공간은 1024차원이다.
공의 개수가 데이터 포인트 수이고, 공 하나의 위치를 표현하는 데 필요한 좌표 수가 차원이다. 청크 수에 임베딩 차원이 제한되는 것도 아니다. 저장 공간과 검색 시스템이 감당한다면 같은 1024차원 공간에 더 많은 청크 벡터를 저장할 수 있다.
벡터의 숫자 하나는 무엇을 뜻하는가
청크 하나가 다음과 같다고 하자.
강아지는 산책을 좋아한다.
매일 주인과 함께 공원을 걷는다.
임베딩 결과는 다음과 같은 숫자 배열이다.
[0.12, -0.31, 0.08, ..., 0.27]
처음에는 각 숫자에 사람이 읽을 수 있는 이름이 붙어 있다고 생각했다.
dimension 1 = 강아지
dimension 2 = 산책
dimension 3 = 공원
이런 대응으로 이해하면 안 된다. dense embedding에서 한 차원은 사람이 미리 정한 단어 칸이 아니다. 텍스트 의미는 여러 차원의 값과 방향에 분산되어 표현된다. 숫자 하나를 떼어 “이 값은 산책을 뜻한다”고 해석하기보다 1024개 값이 함께 만든 위치를 비교해야 한다.
임베딩 모델은 관련 있는 텍스트의 벡터는 가까워지고 관련 없는 텍스트는 멀어지도록 학습된다. 그래서 검색 시에는 각 좌표의 이름을 해석하지 않고 cosine similarity나 dot product 같은 계산으로 벡터 사이의 가까움을 비교한다.
"강아지가 공원을 걷는다" ─┐
├─ 가까운 위치
"반려견과 산책을 나갔다" ─┘
"PostgreSQL 인덱스 스캔" ─── 먼 위치
내 생각에는 벡터를 원문의 번역본이라고 부르는 것보다 의미 검색용 좌표라고 부르는 편이 덜 헷갈린다. 번역본이라고 생각하면 숫자에서 원문을 다시 읽을 수 있을 것 같지만, 임베딩은 원문을 그대로 복원하기 위한 표현이 아니다.
Pooling 문서에서 이해가 연결됐다
여러 문장과 토큰이 어떻게 1024개의 숫자 하나로 정리되는지가 가장 오래 막혔다. 이때 Sentence Transformers의 Pooling 문서가 직접적인 단서가 됐다. 문서는 Pooling을 길이가 달라질 수 있는 token embedding들로부터 고정 크기의 sentence embedding을 만드는 과정으로 설명한다.
전형적인 흐름을 shape로 적으면 다음과 같다.
청크 텍스트
↓ tokenize
T개의 토큰
↓ Transformer
token embeddings: [T, 1024]
↓ Pooling
sentence/chunk embedding: [1024]
설명을 단순하게 하기 위해 batch 축은 생략했고, pooling mode 하나를 사용하는 경우로 한정했다. 여러 pooling mode의 결과를 이어 붙이는 설정이라면 최종 출력 차원도 그만큼 커질 수 있다.
T는 청크마다 달라질 수 있다.
짧은 청크: [35, 1024] → Pooling → [1024]
긴 청크: [420, 1024] → Pooling → [1024]
Pooling이 줄이는 핵심 축은 1024개의 feature 축이 아니라 **토큰 위치 축 T**다. 이 문장을 이해하면서 “문장 수가 벡터 차원이 되는 것 아닌가?”라는 오해가 풀렸다.
현재 Sentence Transformers의 Pooling 소스는 cls, max, mean, weightedmean, lasttoken 등의 방식을 지원한다. mean pooling 경로는 padding을 attention mask로 제외한 뒤 token embedding을 token 축으로 더하고 유효 token 수로 나눈다. 실제 계산은 소스 196~213행에서 확인할 수 있다.
mean pooling을 단순화해 쓰면, 각 dimension j에 대해 다음 계산을 한다.
chunk_vector[j]
= 유효한 모든 token_embedding[i][j]의 합
/ 유효 token 개수
그렇다고 단어 벡터를 문맥 없이 단순 평균한다는 뜻은 아니다. Pooling 전에 Transformer가 주변 토큰을 반영한 contextual token embedding을 만든다. Pooling은 그 결과를 검색에 사용할 고정 길이 표현으로 모으는 단계다. 어떤 pooling 방식이 맞는지는 모델이 학습된 방식과 설정을 따라야 한다.
이 과정은 정보 압축이기도 하다. 800자의 모든 표현을 숫자 1024개에서 완벽하게 복원할 수는 없다. 청크가 지나치게 크면 서로 다른 주제가 한 벡터에 섞여 검색 신호가 흐려질 수 있다. 반대로 너무 작게 자르면 답에 필요한 문맥이 청크 경계에서 끊긴다. 청킹은 단순한 저장 최적화가 아니라 검색 단위의 의미 경계를 정하는 작업이다.
청크는 숫자로 바뀌어 사라지는가
여기서 두 번째 오해가 있었다.
청킹한 문서가 숫자 벡터로 저장된다면 LLM은 그 숫자를 어떻게 읽고 답을 만들까?
일반적인 2-step RAG에서는 LLM이 임베딩 숫자를 읽지 않는다. 청크 원문과 임베딩 벡터를 논리적으로 연결해 보관한다.
{
"id": "document-1-chunk-3",
"text": "환불은 구매 후 7일 이내에 가능합니다.",
"embedding": [0.12, -0.31, 0.08, "..."],
"metadata": {
"documentId": "document-1",
"page": 12
}
}
제품에 따라 텍스트와 벡터를 같은 vector store에 넣을 수도 있고, 벡터에는 ID만 연결하고 원문은 별도 저장소에 둘 수도 있다. 중요한 계약은 같다.
- 벡터: 관련 청크를 찾기 위한 검색 인덱스
- 원문: 검색된 뒤 생성 LLM이 실제로 읽는 내용
- 메타데이터: 문서 ID, 페이지, 권한, 날짜 같은 필터와 출처 정보
LangChain의 Retrieval 문서도 text splitter, embedding model, vector store, retriever를 서로 다른 구성 요소로 나눈다. Text splitter는 큰 문서를 개별 검색 가능한 청크로 만들고, embedding model은 텍스트를 벡터로 바꾸며, vector store는 그 벡터를 저장·검색하고, retriever는 질문에 맞는 문서를 반환한다.
저장 단계와 질문 단계를 분리해서 보기
내가 가장 크게 헷갈린 이유는 indexing과 retrieval/generation을 한 번에 상상했기 때문이다. 시간을 두 단계로 나누면 구조가 단순해진다.
1. 사전 저장 단계: indexing
문서 로드
→ 청킹
→ 청크별 임베딩
→ 원문 + 벡터 + 메타데이터 저장
4만 자 문서 하나를 50개로 나눴다면 50개의 레코드가 생긴다. 각 레코드는 같은 documentId를 가질 수 있지만 서로 다른 chunkId와 1024차원 벡터를 가진다.
이 단계에는 아직 사용자 질문도, 답변 생성도 없다. 나중에 검색할 지식 베이스를 만드는 과정이다.
2. 질문 단계: retrieval과 generation
사용자 질문
→ 같은 임베딩 모델 또는 호환되는 query encoder로 질문 벡터 생성
→ 저장된 청크 벡터와 유사도 검색
→ 후보 청크 반환
→ 선택적으로 reranking
→ top-k 원문 텍스트 선택
→ 질문 + 원문을 생성 LLM에 전달
→ 답변 생성
질문이 다음과 같다고 하자.
환불은 언제까지 가능한가요?
질문도 청크와 비교할 수 있는 같은 1024차원 공간으로 바꾼다.
query vector: [1, 1024]
chunk matrix: [50, 1024]
similarity: [50]
유사도가 높은 청크의 ID를 찾으면 연결된 원문을 가져온다.
chunk 03
score: 0.94
text: "환불은 구매 후 7일 이내에 가능합니다."
생성 LLM에 들어가는 내용은 대략 다음과 같다.
[지시]
다음 참고 자료를 기준으로 답하세요.
[검색된 원문]
환불은 구매 후 7일 이내에 가능합니다.
제품이 훼손된 경우 환불이 제한될 수 있습니다.
[질문]
환불은 언제까지 가능한가요?
LLM은 이 원문을 token으로 읽고 답을 생성한다. 보통 다음 숫자 배열을 프롬프트로 읽는 것이 아니다.
[0.12, -0.31, 0.08, ...] # 검색용
내 머릿속에서는 이제 임베딩 벡터를 책 내용이 아니라 책을 찾기 위한 의미 기반 색인으로 둔다. 색인이 후보 페이지를 찾아주고, LLM은 그 페이지의 실제 문장을 읽는다.
Top-K와 reranking에서 바로잡은 부분
처음에는 “LLM이 rerank 결과와 top-k를 합산해서 답을 만든다”고 생각했다. 절반만 맞는 표현이다.
Top-K
Top-K는 별도의 추론 방식이라기보다 점수가 높은 결과 K개를 선택하는 설정이다. reranker가 없다면 retriever가 바로 최종 top-k를 반환할 수 있다. reranker가 있다면 retriever가 더 많은 N개 후보를 먼저 가져오고, reranker가 재정렬한 뒤 최종 K개를 선택하는 구성이 흔하다.
retrieval 후보 N개
chunk A: 0.94
chunk B: 0.91
chunk C: 0.85
chunk D: 0.72
reranking
→ 순서 재평가
→ final top-k = 3
Reranking
초기 검색 후보를 질문과 다시 비교해 순서를 정교하게 바꾼다. reranker는 별도 cross-encoder일 수도 있고 LLM 기반일 수도 있다. RAG마다 반드시 있는 단계는 아니다.
retrieval 순서: A → B → C
rerank 순서: B → A → C
Generation
선택된 청크들의 벡터나 유사도 점수를 일반적으로 수학적으로 합산해 답을 만들지는 않는다. top-k의 원문 텍스트를 context에 넣고 생성 LLM이 질문과 함께 읽어 답을 구성한다.
retriever: 어떤 원문을 가져올지 결정
reranker: 후보 원문의 순서를 다시 평가
LLM: 선택된 원문과 질문을 읽고 답변 생성
고급 RAG에서는 query rewriting, hybrid search, 반복 retrieval, answer validation 등이 들어갈 수 있다. 하지만 처음 구조를 익힐 때는 LangChain이 구분하는 2-step RAG처럼 retrieval이 먼저, generation이 나중이라고 놓는 편이 이해하기 쉬웠다.
지금 내가 이해한 RAG의 데이터 계약
내가 다시 헷갈리지 않기 위해 계약 단위로 정리했다.
문서 계약
원문은 여러 청크로 나뉜다.
임베딩 계약
청크 하나는 고정된 D차원 벡터 하나가 된다.
저장 계약
원문, 벡터, 메타데이터는 다시 연결할 수 있어야 한다.
검색 계약
질문과 청크를 서로 호환되는 같은 임베딩 공간에서 비교한다.
생성 계약
LLM에는 검색된 원문 텍스트와 질문을 전달한다.
shape로만 다시 적으면 더 짧다.
N개 청크
→ embedding matrix [N, D]
질문 1개
→ query vector [1, D]
유사도 검색
→ N개 score
top-k 선택
→ k개 원문 청크
LLM 입력
→ 질문 token + 원문 token
여기서 N은 청크 개수이고 D는 임베딩 차원이다. 둘은 같은 값일 이유가 없다.
지금 내 생각
이번에 이해가 바뀐 지점은 “벡터가 문서를 저장한다”와 “벡터가 문서를 찾게 한다”를 구분한 것이다.
물론 vector store가 임베딩 자체를 저장한다는 말은 맞다. 하지만 RAG 전체 관점에서 벡터의 주된 역할은 답변 원문을 대체하는 것이 아니라 질문과 관련된 원문으로 가는 경로를 만드는 것이다. 검색 결과가 나오면 생성 LLM은 다시 텍스트 세계로 돌아온다.
Pooling도 비슷했다. 여러 문장을 1024개의 의미 슬롯에 하나씩 배정한다고 생각했을 때는 설명이 되지 않았다. [T, D] → [D]로 토큰 축이 사라진다고 보니 차원, 입력 길이, 청크 수가 한 번에 분리됐다.
현재 내 머릿속의 가장 짧은 정의는 다음과 같다.
청킹은 검색할 원문의 단위를 정하고, 임베딩은 그 단위를 찾기 위한 좌표를 만들며, retrieval은 좌표로 원문을 찾고, LLM은 찾아온 원문을 읽어 답한다.
다음에 직접 확인할 것
- 같은 문서를
chunk_size와overlap만 바꿔 검색 결과를 비교한다. - 한 청크의 token embedding shape와 pooling 이후 shape를 실제 코드로 출력한다.
- mean pooling을 직접 계산한 값과 Sentence Transformers 출력값을 대조한다.
- cosine similarity의 top-k와 reranker 이후 순서가 어떻게 달라지는지 기록한다.
- 너무 큰 청크에서 서로 다른 주제가 한 벡터에 섞이는 사례를 만든다.
- 검색 실패와 생성 실패를 별도 평가 항목으로 분리한다.
확인한 자료
- 테디노트, EP01. RAG의 동작 과정 쉽게 이해하기
- 테디노트, EP02. RAG의 동작 과정 쉽게 이해하기: 실행 단계
- Sentence Transformers, Pooling 문서
- Sentence Transformers,
Pooling소스 - BAAI, BGE-M3 모델 카드
- LangChain, Retrieval 공식 문서
댓글