← ~/blog
2026. 06. 08. Creative Engine

글자에서 좌표로 — 우리가 이름 없이 풀던 의미 공간

돈전지와 앞다리살은 글자가 한 자도 안 겹치는데 같은 재료다. 밥비서에서 흩어진 재료를 하나로 묶으며, 우리는 임베딩과 의미 공간을 이름도 모른 채 먼저 썼다. 언어가 숫자가 되는 과정의 기록.

연재RAG에서 Agent까지1 / 5
글자에서 좌표로 — 우리가 이름 없이 풀던 의미 공간

밥비서를 만들면서 우리는 사소해 보이는 벽에 부딪혔다. 돼지 앞다리, 앞다리살, 돈전지 — 세 표기는 모두 같은 부위를 가리킨다. 그런데 돈전지앞다리살은 글자가 한 자도 겹치지 않는다. 글자를 맞춰 보는 검색으로는 이 둘을 영원히 다른 재료로 둘 수밖에 없다. 레시피 수천 개를 코퍼스로 쌓으려면, 흩어진 표기를 하나의 대표 재료로 묶어야 했다.

우리가 택한 방법은 단순했다. 글자 말고 의미를 비교하자. 재료 이름을 숫자 덩어리로 바꾸고, 그 숫자들이 가리키는 방향이 비슷하면 같은 재료로 묶었다. 작동했다. 그때 우리는 이 기법의 이름을 몰랐다. 교과서는 그걸 임베딩(embedding), 그 검색을 semantic search라 부른다. 우리는 이름표를 보기 전에 그 길을 먼저 걸은 셈이다.

이 글은 새 시리즈의 첫 편이다. 엔진을 만들며 first principle로 손수 풀었던 문제들에, 정확한 이름과 그 안에서 벌어지는 메커니즘을 하나씩 붙여 나간다 — 토큰에서 시작해 Agent까지. (한국어 표기를 다듬는 정규화 자체는 지난 글에서 다뤘다. 여기서는 그 표기들이 어떻게 한 좌표로 모이는지, 의미 공간의 관점에서 본다.)

따라갈 표본 하나를 고정하자. 돼지고기 앞다리살이라는 일곱 글자가, 기계가 더하고 곱할 수 있는 무언가로 바뀌는 여정이다.

기계는 글자를 보지 않는다 — 토큰

컴퓨터에게 돼지고기 앞다리살은 처음엔 의미도 경계도 없는 글자의 나열이다. 임베딩 모델이든 LLM이든 텍스트를 곧장 먹지 못한다. 먼저 텍스트를 토큰(token)이라는 조각으로 자르고, 각 조각을 사전의 줄번호 같은 정수 id로 바꾼다. 이 자르는 일을 하는 게 토크나이저다.

토큰은 ‘단어’도 ‘글자’도 아닌 중간, 서브워드(subword) 단위다. 단어 사전은 처음 보는 단어에서 막히고, 글자 단위는 시퀀스가 너무 길어진다. 그래서 자주 붙어 다니는 덩어리일수록 더 크게 한 토큰으로 합친다.

한국어는 여기서 특히 불리하다. 조사·어미가 명사에 들러붙는 교착어라, 같은 뜻이라도 영어보다 토큰이 1.5~2배쯤 더 나온다. 이건 그대로 입력 비용이고, 더 빨리 차는 컨텍스트다. 수천 개 레시피를 통째로 임베딩에 태우는 코퍼스라면 이 배수가 호출 비용에 곱해진다. 한글 텍스트의 API 청구서가 유난히 비싸게 느껴졌다면, 이유가 여기 있다.

tokenize("돼지고기 앞다리살")
// → [12044, 833, 9921, 5102, 771, 2310]
// 돼지 / 고기 / 앞 / 다리 / 살 … 음절·부분으로 잘게 갈린 예시

일곱 글자짜리 재료명 하나가 벌써 토큰 여섯이다. 그리고 이 id들이 모델이 실제로 보는 입력이다. 모델은 글자 를 보지 않는다 — id의 시퀀스를 본다. 단, 이 줄번호엔 아직 의미가 없다. id가 하나 크다고 뜻이 가까운 게 아니다. 사전을 만든 순서일 뿐이다. 의미는 다음 단계에서 비로소 생긴다.

의미를 좌표로 — 가까우면 가깝게

이 무의미한 번호를 ‘의미를 담은 좌표’로 바꾸는 일이 임베딩이다. 직관은 하나다. 모든 의미 단위를 아주 높은 차원의 공간 속 한 점으로 옮기되, 의미가 가까우면 점도 가깝게 놓는다. 고구마감자는 같은 동네에 놓인다 — 둘 다 뿌리채소, 탄수화물 위주, 비슷한 조리. 하지만 맛도 식감도 다르니 똑같은 점은 아닌, 살짝 떨어진 이웃이다.

여기서 keyword 검색과의 결정적 차이가 드러난다. 우리를 가로막았던 돈전지앞다리살은 글자가 0% 겹친다. 문자 매칭으로는 절대 못 묶는다. 그러나 의미 좌표 위에서는 둘 다 ‘돼지 앞다리 부위’라는 같은 동네에 모인다. 표기를 한 자도 공유하지 않아도 의미로 묶이는 것 — 이게 semantic search의 본질이고, 우리가 재료를 하나로 모을 때 빌려 쓴 바로 그 힘이다.

공간의 언어로 옮기면 우리가 한 일은 이렇게 정리된다. 여러 표기의 점이 거의 한 자리에 모이고, 우리는 그 무리에 대표(canonical) 이름표 하나를 붙였다. “한 개념이 여러 표현으로 흩어져 있을 때 대표를 정한다”던 직관은, 공간 위에서 정확히 군집에 라벨을 붙이는 일이었다.

차원 이야기를 잠깐 하자. 우리는 흔히 2차원 종이에 점을 찍어 직관을 얻지만, 실제 좌표는 수백~수천 개의 숫자다. 우리가 쓴 Gemini의 임베딩 모델(text-embedding-004)은 한 조각을 768개의 숫자로 표현한다. 오해 하나를 미리 막자 — 768개 중 7번째가 ‘단맛 축’, 200번째가 ‘고기 축’ 같은 깔끔한 대응은 없다. 의미는 여러 차원에 분산돼 함께 인코딩된다. 고구마감자는 ‘뿌리채소·탄수화물’에 관여하는 차원 묶음에선 바짝 붙지만, ‘단맛’이나 ‘식감’에 관여하는 묶음에선 슬쩍 벌어진다. 같은 두 점이 어떤 결로는 한 동네, 어떤 결로는 남남인 동시성 — 이게 차원이 여럿이어야 하는 이유다.

embed("돼지고기 앞다리살")
// → [0.018, -0.046, 0.113, ..., 0.031]   // 길이 768짜리 벡터

돈전지를 같은 모델에 넣으면, 글자는 전혀 다른데도 이 다발과 거의 같은 방향을 가리키는 또 하나의 다발이 나온다. 그게 우리가 둘을 한 재료로 묶을 수 있었던 근거다.

chunk가 벡터가 되는 순간

그런데 토큰 조각 몇 개가 대체 어떻게 이런 좌표가 될까. 모델 안을 열어 보면 세 단계다.

1단계 — 룩업. 모델 안엔 토큰 사전 크기만 한 거대한 표가 있고, id마다 학습된 초기 벡터가 한 줄씩 적혀 있다. id를 줄번호 삼아 토큰마다 벡터를 하나씩 꺼낸다. 토큰 여섯이면 벡터 여섯, 각자 이미 768차원이다. 단 이 초기 벡터는 아직 문맥 없는 사전적 의미다 — 사과가 과일인지 사죄인지 아직 모른다.

2단계 — 문맥 반영(self-attention). 이 벡터들이 트랜스포머 층을 여러 겹 통과한다. 각 층에서 모든 토큰이 서로를 ‘쳐다보며’ 자기 값을 갱신한다. 사과먹다 옆이면 과일 쪽으로, 미안 옆이면 사죄 쪽으로 움직인다. 앞다리살도 그냥 ‘살’이 아니라 ‘돼지 앞다리 부위의 살’로 물든다.

3단계 — 풀링(pooling). 우리가 원하는 건 토큰별 벡터 여럿이 아니라 조각 전체를 대표하는 벡터 하나다. 그래서 마지막에 토큰 벡터들을 하나로 모은다(보통 자리별 평균). 덕분에 토큰이 여섯이든 마흔이든, 길이에 상관없이 항상 같은 768차원 벡터 하나가 나온다. 가변 길이 입력 → 고정 길이 출력. 모든 조각이 같은 모양의 좌표를 가져야 비로소 서로 비교할 수 있으니, 이게 검색에서 결정적이다.

"돼지고기 앞다리살"
   │ 토크나이저
[12044, 833, 9921, 5102, 771, 2310]   토큰 6개
   │ ① 룩업: 토큰별 초기 벡터
[v1][v2][v3][v4][v5][v6]            각 768차원
   │ ② 트랜스포머 ×N: 어텐션으로 문맥 반영
[v1'][v2'] … [v6']                  각 768차원
   │ ③ 풀링: 자리별 평균으로 하나로
[0.018, -0.046, …, 0.031]           고정 768차원 벡터 하나

이제 핵심 질문. 왜 하필 ‘의미가 비슷하면 가까이’가 되는가? 룩업 표의 숫자도, 어텐션 가중치도 처음엔 무작위였다. 그걸 의미 지도로 정렬시킨 건 학습 목표다. 임베딩 모델은 수억 쌍의 데이터로, 의미가 가까운 쌍은 서로 끌어당기고 무관한 쌍은 밀어내도록 훈련된다(대조 학습, contrastive learning). 이 밀고 당기기를 수억 번 반복하면 공간 전체가 ‘가까운 것끼리 모인’ 지도로 굳는다.

그러니 768이라는 숫자도 분명해진다. 누가 손으로 정한 축이 아니라, 학습 과정에서 의미의 결을 분산해 담도록 저절로 정해진 좌표다. 차원이 많을수록 미세한 차이를 담지만(표현력↑) 저장과 계산은 무거워진다 — 768이냐 1536이냐는 그 트레이드오프의 선택이다.

같은 종류의 트레이드오프는 검색 인덱스에도 있다. 우리는 이 벡터들을 pgvector에 담고, 재료 임베딩 컬럼에 IVFFlat 인덱스를 걸었다. IVFFlat은 공간을 미리 몇 개 구역으로 나눠 두고 질의와 가까운 구역만 뒤지는 방식이라, 색인이 가볍고 빌드가 빠르다. 정밀도를 더 끌어올리려면 HNSW 같은 그래프 인덱스도 있지만, 메모리와 빌드 비용이 더 든다. 재료 정규화처럼 빌드타임에 한 번 도는 작업에는 가벼운 쪽이 맞았다.

우리가 이름 없이 쓰던 것

정직하게 말하면, 우리는 이 임베딩 모델을 직접 훈련하지 않았다. IVFFlat 인덱스를 손으로 구현하지도 않았다. 우리가 한 일은 이미 의미 지도로 정렬된 공간을 빌려, 그 위에서 흩어진 재료 표기를 한 점으로 모으고 대표 이름을 붙인 것이다. 그거면 충분했다.

여기서 한 가지는 분명히 해 둔다. 이 벡터 검색은 코퍼스를 쌓는 빌드타임 재료 정규화의 일이다. 사용자에게 나가는 주 단위 식단을 짜는 코어 엔진은 이것과 별개로, LLM 없이 도는 결정론 알고리즘이다. 의미 공간은 데이터를 빚는 자리에서 일했고, 식단을 짜는 자리에는 재현 가능한 규칙이 일한다. 둘을 섞지 않는 게 우리 설계의 전제다.

배운 건 이것이다. first principle로 문제를 먼저 풀면, 나중에 정확한 이름을 만났을 때 그 이름이 비로소 살로 붙는다. “글자 말고 의미를 비교하자”는 거친 직관은 임베딩이라는 이름을 얻고서야 — 768차원이 왜 필요한지, 대조 학습이 그 공간을 어떻게 빚었는지, 인덱스를 왜 그렇게 골랐는지까지 — 또렷해졌다. 직관이 먼저고 이름이 나중이어도 괜찮다. 엔진을 만드는 사람에겐 오히려 그 순서가 자연스럽다.

직관에 이름을 붙이면, 다음 문제가 보인다

이제 모든 재료가, 모든 레시피 조각이 좌표를 가졌다. 그러면 곧장 다음 질문이 떠오른다 — 두 좌표가 ‘얼마나’ 가까운지를 어떻게 숫자 하나로 재는가. 수백만 개 중 가까운 것을, 전부 뒤지지 않고 어떻게 찾는가. 그리고 긴 문서는 어디서 잘라 벡터로 만들어야 하는가. 다음 편에서 거리 재기와 근사 최근접 탐색(ANN), 그리고 청킹으로 들어간다.

이름 없이 먼저 풀었던 문제에 정확한 이름을 붙이는 이 작업은, 결국 같은 메커니즘을 다른 도메인에 옮겨 심기 위한 지도다.

#engineering #embeddings #vector-search #rag #nlp #bobbiso
← 목록으로