
호기심에 벡터 검색(Vector Search)을 직접 구현해 보았고, 제가 저지른 실수도 발견했습니다
요약
벡터 검색의 내부 작동 원리를 이해하기 위해 numpy만을 사용하여 직접 구현해 본 과정을 다룹니다. 임베딩 모델과 벡터 데이터베이스 없이 코사인 유사도와 내적을 활용해 유사한 벡터를 찾아내는 메커니즘을 설명합니다.
핵심 포인트
- 벡터 검색의 핵심은 벡터 정규화와 내적(dot product)을 통한 유사도 계산임
- 단위 벡터의 경우 내적 값은 곧 코사인 유사도와 동일함
- 임베딩 모델이 생성한 벡터를 활용해 유사한 데이터를 분류하는 과정을 직접 구현함
요약(TL;DR): 저는 해커톤에서 의미 기반으로 노트를 검색하는 "두 번째 뇌(second brain)" 앱을 출시했는데, 정작 검색 부분의 원리는 이해하지 못하고 있었습니다. 그래서 numpy를 사용하여 8차원 공간에 20개의 가짜 제품을 두고 직접 구현해 보았습니다. 메커니즘은 고작 10줄 정도였습니다. 흥미로웠던 점은 이 장난감 같은 구현체가 제가 만든 4개의 카테고리 중 2개가 서로 겹쳐 있다는 사실을 조용히 알려주었다는 것입니다. 저는 제가 그런 실수를 했다는 것조차 전혀 몰랐습니다. 이것이 바로 실제 임베딩(embeddings)이 결코 당신에게 보여주지 않는 부분입니다.
시작하게 된 질문
해커톤에서 저는 노트를 기억처럼 저장하고, 정확한 단어가 아닌 의미로 검색하는 second-brain을 만들었습니다. 이 앱은 sentence-transformers (all-MiniLM-L6-v2)를 실행하여 텍스트를 384차원 벡터(vectors)로 변환하고, Qdrant를 사용하여 이를 저장하고 검색합니다. 잘 작동했습니다. 대략적인 아이디어를 입력하면, 단어가 하나도 겹치지 않더라도 의도한 노트를 찾아낼 수 있었습니다.
하지만 저는 그것이 어떻게 작동하는지 모른 채 연결만 해둔 상태였습니다. 그저 embed() 호출과 Qdrant 쿼리일 뿐이었고, 마치 마법처럼 느껴졌습니다. 제 프로젝트에서 마법이 일어나는 것은 저를 불편하게 만듭니다. 그래서 저는 실제 질문을 적어보았습니다:
embed(query)와 결과가 돌아오는 사이에 어떤 일이 일어나는가?
"임베딩(embedding)이란 무엇인가"가 아닙니다. 그것은 암기해서 말할 수 있습니다. 제가 원한 것은 벡터 더미를 가져와서 유사한 것들을 골라내는 메커니즘, 즉 그 과정이었습니다. 그래서 저는 직접 만들어 보았습니다. 모델도, 벡터 데이터베이스(vector database)도, 프레임워크도 없이 말이죠. 오직 numpy만을 사용했고, 4개의 카테고리(전자제품, 의류, 요리, 문구류)에 걸친 20개의 가공된 제품을 만들었습니다. 각 제품은 8차원 공간에서 직접 선택한 중심점 주변에 클러스터링(clustered)되었으며, 완전히 동일하지 않도록 노이즈(noise)를 추가했습니다.
import numpy as np
# 각 카테고리는 8차원 공간에서의 클러스터 중심(cluster center)입니다
...
가짜 벡터를 사용한 것은 지름길이 아니라 이 작업의 핵심 목적이었습니다. 저는 트랜스포머(transformer)가 어떻게 벡터를 생성하는지 배우고 싶었던 것이 아닙니다. 제가 이해하지 못하는 부분, 즉 벡터를 확보한 이후에 그 벡터들로 무엇을 하는지를 분리해서 알고 싶었습니다. 위에 언급한 4개의 중심점을 기억하세요. 이 글의 가장 흥미로운 부분으로 나중에 다시 등장할 것입니다.
질문 1: 실제로 두 벡터를 어떻게 비교하나요?
답변은 짜증이 날 정도로 짧습니다. 모든 벡터를 단위 길이(unit length)로 정규화(Normalize)한 다음, 내적(dot product)으로 순위를 매기면 됩니다. 단위 벡터(unit vector)의 경우, 내적은 곧 코사인 유사도(cosine similarity)입니다. 코사인 공식의 분모는 단순히 두 벡터의 크기(magnitude)인데, 이미 두 크기를 모두 1로 만들었기 때문입니다.
def normalize(vectors: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms = np.where(norms == 0, 1e-10, norms) # 0으로 나누기 방지
...
이것이 엔진의 전부입니다. 코드는 잘 작동했고, 모든 쿼리(query)에 대해 올바른 카테고리가 최상단에 결과로 돌아왔습니다.
Query: 'Cooking'
1. [0.9948] Ceramic pan
2. [0.9816] Iron cast pan
...
여기서 그 어떤 독서로도 깨닫지 못했던 두 가지 사실이 머릿속에 명확히 박혔습니다. 정규화(Normalizing)는 단순한 의식이 아니라, 나중에 저렴한 비용으로 내적을 계산할 수 있게 해주는 수단이라는 점입니다. 그리고 정규화는 search()가 아니라 add() 단계에 포함되어야 합니다. 인덱싱(indexing)은 한 번만 수행하지만, 쿼리는 영원히 수행되기 때문입니다.
또 다른 깨달음은 왜 단순한 유클리드 거리(Euclidean distance)가 아니라 코사인 유사도를 사용하는가였습니다. 코사인은 방향만을 고려하므로 크기(magnitude)를 무시합니다. 텍스트 데이터에서는 이것이 매우 중요한데, 동일한 주제를 다루는 긴 문서와 짧은 문서는 방향은 같더라도 크기 면에서는 크게 다를 수 있기 때문입니다. 유클리드 거리는 이들을 멀리 떨어져 있다고 판단하겠지만, 코사인은 이들을 같은 것으로 판단합니다.
Qdrant는 이 기능 위에 많은 것을 추가로 제공하며, 그것이 무엇인지는 나중에 다루겠지만, 제 '제2의 뇌(second-brain)'를 구동하는 핵심은 바로 이 열 줄의 코드입니다.
질문 2: 공간을 볼 수 있나요?
8차원이기 때문에 직접적으로는 불가능합니다. 저는 PCA(주성분 분석)를 사용하여 2D로 투영(project)한 뒤, 데이터(products)는 점으로, 쿼리(queries)는 별 모양으로 시각화했습니다. 네 개의 클러스터(cluster)가 깔끔하게 분리되었고, 각 쿼리 별은 해당 클러스터 내부에 안착했습니다. 매우 만족스러웠습니다.
그다음, 이 두 성분이 실제로 분산(variance)을 얼마나 포착했는지 출력해 보았습니다:
PC1: 45.92%
PC2: 26.45%
Total (PC1 + PC2): 72.36%
따라서 제가 기쁘게 믿고 있던 그림은 구조의 약 72%만을 보여주고 있었습니다. 이 벡터들을 구분 짓는 요소 중 4분의 1 이상이 해당 플롯(plot)에는 전혀 나타나지 않으며, 8차원은 장난감 수준에 불과합니다. 실제 임베딩 (embedding)은 384 또는 1536 차원입니다. t-SNE나 UMAP 플롯이 어떻게 보이든 간에, 정직한 해석은 그것이 정보 손실이 매우 심한 스케치이며, 그림상에서 두 점이 겹쳐 있다고 해서 실제 공간에서 서로 가까이 있다는 뜻은 아닐 수도 있다는 것입니다.
이것이 제가 직접 구현을 통해 얻은, 예상치 못했던 첫 번째 결과였습니다. 즉, 제 자신의 것을 포함하여 제가 지금까지 무심코 고개를 끄덕이며 받아들였던 모든 임베딩 시각화 (embedding visualization)에 대해 건강한 불신을 갖게 된 것입니다.
질문 3: 높은 점수가 결과가 옳다는 증거인가?
Top-3 결과는 항상 완벽해 보였는데, 이것이 바로 문제입니다. 왜냐하면 검색 UI가 사용자에게 보여주는 것은 오직 Top-3뿐이기 때문입니다. 그래서 저는 상위 결과만 보는 것을 멈추고, 쿼리 (query)를 20개 전체 제품과 대조하여 점수를 매긴 뒤 정렬했습니다.
그때부터 실행 결과가 흥미로워졌습니다. 다음은 각 쿼리의 꼬리 부분, 즉 점수가 가장 높은 잘못된 카테고리 (wrong-category) 아이템입니다:
| 쿼리 (Query) | 최악의 카테고리 내 점수 | 최선의 잘못된 카테고리 점수 | 마진 (Margin) |
|---|---|---|---|
| Clothing | 0.9572 | 0.4457 (wodden spoon) | 0.51 |
| ... |
여기 있는 모든 쿼리에서 약 0.8 정도의 컷오프 (cutoff)가 정답과 오답을 구분해주고 있으므로, 이 장난감 모델은 실패하지 않았습니다. 하지만 마진 (margin)을 보십시오. Clothing은 문구류 (stationary)보다 두 배의 여유 공간을 가지고 있습니다. 동일한 코드, 동일한 노이즈, 동일한 모든 조건입니다. 왜 어떤 카테고리는 다른 카테고리보다 두 배나 더 쉬운 걸까요?
제가 찾아 나서지 않았던 것
쿼리(Queries)들은 클러스터 중심점(cluster centers) 근처에서 생성되었습니다. 따라서 만약 어떤 카테고리들이 서로 혼동되고 있다면, 아마도 중심점 자체가 문제일 수 있습니다. 저는 그것들을 확인해 볼 생각을 전혀 하지 못했는데, 왜냐하면
payload = {
"vector": embed(query), # 의미적 유사도 (semantic similarity)
"limit": limit,
...
코사인 유사도 (Cosine similarity)는 정확히 한 가지 일만 수행합니다. 바로 가장 가까운 벡터를 찾는 것입니다. '가장 가깝다'는 것이 '정확하다'는 뜻은 아니며, 결코 '허용된다'는 뜻도 아닙니다. 따라서 유사도는 후보군을 찾아낼 뿐이며, 그 외의 모든 것은 규칙을 강제해야 합니다. "지난주에 '업무' 태그가 달린 메모"라는 요청은 검색창을 쓰고 있는 데이터베이스 쿼리이지, 유사도에 관한 질문이 아닙니다. 임베딩 (embedding)의 품질이 아무리 좋아지더라도 이 질문은 유사도 질문으로 변하지 않습니다.
동일한 논리가 제가 실무에서 가장 많이 맞닥뜨린 실패를 설명해 줍니다. 즉, 정확한 일치 (exact matches)가 더 좋아지는 것이 아니라 오히려 악화된다는 점입니다. 파일 이름, ID, 또는 태그를 검색하면, 의미론적 검색 (semantic search)은 의미가 유사한 무언가를 자신 있게 내놓으며 정확한 일치 항목을 묻어버립니다. 이 경우에는 단순 문자열 매칭 (string matching)이 압도적으로 유리하며, 그 차이는 매우 큽니다.
또한 저는 이제 score_threshold: 0.5라는 숫자가 제가 임의로 만들어낸 수치라고 생각합니다. 앱이 터무니없는 결과를 반환하는 것을 멈췄다는 의미에서는 작동한다고 볼 수 있습니다. 하지만 저에게는 원칙적인 근거가 없으며, 위에 제시한 마진 테이블 (margin table)이 바로 제가 고민하는 이유입니다. 제가 직접 구축한 데이터셋에서 네 가지 카테고리에 걸쳐 정답과 오답 사이의 간격이 2배나 변했습니다. 실제 임베딩 공간 (embedding space)에서는 그 수치가 어떠할지 전혀 알 수 없습니다.
제가 남긴 질문들
메커니즘을 구현하는 데는 오후 한나절이 걸렸습니다. 하지만 다음 질문들은 해결되지 않았습니다.
점수 임계값 (score threshold)을 어떻게 정직하게 정할 것인가? 저의 방식은 그냥 '느낌'입니다. 제대로 하려면 레이블이 지정된 쿼리(query)와 결과(result) 쌍이 필요하고, 점수 분포가 실제로 교차하는 지점을 살펴봐야 합니다. 이는 제 메모에 대한 정답 (ground truth)이 필요하다는 뜻인데, 저에게는 그것이 없습니다. 이것이 제가 다음에 해결하고 싶은 문제입니다.
384차원에서는 기하학적 구조가 어떻게 변하는가? 위의 모든 예시는 8차원이며, 여기서는 단일 축이 공유되는 것을 추론할 수 있습니다. 차원이 증가함에 따라 유사도 점수들이 뭉친다는 글을 읽은 적이 있는데, 이 경우 모든 것이 모든 것과 적당히 유사해 보이기 시작합니다. 저는 이를 테스트해보지 않았으며, 여기서의 제 모든 직관은 대표성을 갖기에는 너무 작은 공간에서 형성되었습니다.
실제 인덱스는 어떻게 모든 항목을 비교하는 것을 피할까요? 저의 search() 함수는 모든 벡터에 대해 내적 (dot product)을 수행하므로 쿼리당 O(n)의 복잡도를 가집니다. 이는 제품 20개에 대해서는 괜찮지만, 백만 개에 대해서는 터무니없는 수치입니다. Qdrant는 근사치 (approximate)를 구하기 위해 HNSW를 사용합니다. 즉, 속도를 위해 의도적으로 틀릴 수 있다는 뜻입니다. 저는 다음 단계로 그것을 직접 구축하여 "근사 (approximate)"가 실제로 어떤 비용을 치르는지 알아보고 싶습니다. 왜냐하면 지금의 저는 그 트레이드오프 (trade-off)를 그저 믿음으로 받아들이고 있기 때문입니다.
필터링 (Filtering)과 근사 검색 (approximate search)은 서로 충돌할 수밖에 없습니다. 만약 HNSW가 근접 이웃 (near neighbors)을 찾기 위해 그래프를 탐색하고 있는데, 제가 동시에 importance >= 0.3이라는 조건을 요구한다면, 그래프를 탐색하기 전에 필터링을 할까요, 아니면 탐색 후에 할까요? 탐색 후에 필터링을 하면 요청한 것보다 적은 결과가 반환될 수 있습니다. 탐색 전에 필터링을 한다면 그래프가 여전히 어떻게 작동하는지 불분명해집니다. Qdrant가 이 문제를 해결한다는 것은 알고 있지만, 어떻게 하는지는 모릅니다.
쿼리를 올바른 검색 방식으로 어떻게 라우팅 (route)할까요? 완전 일치 (exact-match) 문제는 하이브리드 검색 (hybrid search)이라는 알려진 해답이 있습니다. 키워드 검색과 벡터 검색을 실행하고 그 순위 (rankings)를 병합하는 방식입니다. 제가 아직 해결하지 못한 부분은, 특정 쿼리가 어떤 경로를 거쳐야 하는지 결정하는 깔끔한 방법, 혹은 그냥 항상 두 방식을 모두 실행하고 융합할지 여부입니다. 이 지점에서 저는 막혀 있습니다.
만약 여러분이 프로덕션 환경에서 하이브리드 검색 (hybrid retrieval)을 구축해 보셨다면, 어떻게 라우팅 결정을 내리는지, 그리고 점수 임계값 (score threshold)을 직접 조정하셨는지 아니면 저처럼 임의로 선택하셨는지 알고 싶습니다. 이것이 저의 솔직한 현 상태입니다.
Second-brain이 여기 있습니다:
GitHub logo CoffeeAurCode / second-brain-ai_MVP
second-brain-ai (qdrant-based)
이 프로젝트는 Qdrant를 주요 벡터 메모리 저장소로 사용하는 최소 기능의 Second brain AI 시스템입니다.
실행 방법
-
로컬에서 Qdrant 시작:
docker run -p 6333:6333 qdrant/qdrant -
의존성 설치:
pip install -r requirements.txt -
데모 파일 중 하나 실행:
python Demo_1.py/Demo_2.py/Demo_3.py
데모는 지식을 저장하고, 관련 메모리를 검색하며, 메모리 진화 (memory evolution)를 적용한 뒤 다시 검색을 수행합니다.
의존성
이 프로젝트는 Qdrant를 주요 벡터 검색 엔진 (vector search engine)으로 사용합니다. Qdrant는 공식적으로 권장되는 배포 방식인 Docker를 사용하여 로컬에서 실행됩니다.
Docker는 Qdrant 서비스를 시작하는 데에만 필요합니다. 모든 애플리케이션 로직은 Python에서 실행됩니다.
메모리 아키텍처 (Memory Architecture)
시스템은 Qdrant를 주요 메모리 저장소 (memory store)로 사용합니다.
세 가지 유형의 메모리 컬렉션 (memory collections)이 정의되어 있습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기