like 기반 검색은 너무 허접해
내 블로그의 포스팅 검색 기능은 제목이나 본문에 그 글자가 그대로 들어 있어야 걸린다. 그래서 개발일지 포스팅을 썻지만, 본문이나 제목에 "개발"라는 표현을 한 번도 안 썼다면 "개발" 이라는 키워드로 검색해서는 해당 포스팅을 찾을 수 없다. 검색이라기보다 문자열 필터에 가깝다.
이딴건 검색이라고 하면 안된다.
그 허접한 검색기능을 가리기위해서 태그 기능 도입해봤지만, 태그를 수십 수백개를 만들어도, 사용자는 불편할 것이다. 그러던 와중에 "임베딩 검색"이라는 개념을 봤다. 유투브던가, 커뮤니티 게시글이던가.. 어디선가 들어본 단어이긴 한데 정확한 내용은 몰랐다. 블로그에 구현하는 것 자체는 어려워 보이지 않았지만, 뭔지도 모르고 붙일 수는 없어서 먼저 알아보기로 했다.
임베딩은 어디서 나온 개념인가
출발점은 언어학 쪽의 오래된 가설이다. 단어의 의미는 그 단어가 어떤 단어들과 함께 등장하는지로 결정된다는 것. "커피"와 "홍차"는 서로 다른 단어지만, 앞뒤에 붙는 말("마시다", "따뜻한", "카페")이 겹친다. 그러면 두 단어의 의미도 비슷할 것이라고 보는 관점이다.
이걸 실제로 계산 가능한 형태로 만든 것이 2013년 구글에서 나온 Word2Vec이다. 대량의 문서에서 단어들이 함께 등장하는 패턴을 학습해서, 단어 하나를 300개짜리 숫자 배열로 바꾼다. 이 논문이 유명해진 건 결과가 신기했기 때문이다. 단어를 숫자로 바꿔놨더니 이런 계산이 성립했다.
vector("King") - vector("Man") + vector("Woman") ≈ vector("Queen")
"왕"에서 "남자"라는 성분을 빼고 "여자"를 더하면 "여왕" 근처에 도착한다. 의미가 좌표 위의 방향으로 표현된다는 뜻이다.
여기서 단어 하나가 아니라 문장이나 문단 전체를 하나의 숫자 배열로 바꾸는 쪽으로 발전했고, 지금은 그걸 API 한 번 호출로 받아올 수 있게 됐다. 이 숫자 배열을 임베딩(embedding), 혹은 벡터라고 부른다.
텍스트가 숫자 배열이 되면 어떤 모양인가
말로만 하면 잘 안 잡히니 실제 모양을 보자. Gemini의 임베딩 API에 문장 하나를 넣으면 이런 게 나온다.
입력: "고양이는 야옹 하고 운다"
출력: [0.0213, -0.0451, 0.0038, 0.0192, ... ] ← 숫자 3072개
각 숫자가 무슨 뜻인지는 사람이 해석할 수 없다. 47번째 숫자가 "동물스러움"을 나타낸다든가 하는 식의 의미는 없다. 모델이 학습 과정에서 알아서 정한 좌표계고, 우리가 쓸 수 있는 건 좌표 그 자체가 아니라 좌표들 사이의 거리뿐이다.
숫자 개수를 차원이라고 부른다. 위 예시는 3072차원이다. 3차원 공간의 점 하나가 (x, y, z) 세 개 숫자로 표현되는 것과 같은 얘기인데, 축이 3072개인 공간이라고 보면 된다. 머릿속에 그릴 수는 없지만 거리 계산은 3차원일 때와 똑같이 돌아간다.
같은 모델에 다른 문장들을 넣어보면 위치가 이렇게 잡힌다.
"고양이는 야옹 하고 운다" ─┐
"우리 집 냥이가 울었다" ─┤ 서로 가까움
"강아지가 짖는다" ─┘ (조금 떨어져 있음)
"제네릭 타입 추론이 실패한다" ← 아주 멀리
"고양이"와 "냥이"는 글자가 하나도 안 겹치는데도 가깝게 놓인다. 두 단어가 등장하는 문맥이 비슷하기 때문이다. 앞의 가설이 여기서 그대로 작동한다.
가까움은 어떻게 재는가
거리를 재는 방식은 여러 가지가 있는데, 텍스트 검색에서는 보통 코사인 유사도를 쓴다. 두 벡터가 원점에서 뻗어나가는 방향이 얼마나 비슷한지를 보는 방법이다. 방향이 완전히 같으면 1, 무관하면 0에 가까워진다.
왜 방향만 보느냐면, 길이를 무시하고 싶어서다. 같은 주제를 다룬 200자짜리 요약글과 5000자짜리 상세글이 있다고 하자. 두 글은 내용이 비슷하니 벡터의 방향도 비슷하게 잡힌다. 여기서 길이까지 따지는 방식을 쓰면 분량 차이가 거리에 섞여 들어간다. 방향만 보면 그 영향이 사라진다.
실무에서는 이 계산을 직접 짤 일이 거의 없다. Postgres라면 <=> 연산자 하나면 되고, Google 문서에 따르면 Gemini 임베딩은 정규화된 벡터를 돌려주기 때문에 코사인·내적·유클리드 거리 중 무엇을 써도 순위가 같게 나온다. 단 3072차원을 그대로 쓸 때의 얘기고, 차원을 줄여 쓰면 gemini-embedding-001에서는 직접 정규화해야 한다.
검색은 어떤 순서로 돌아가는가
원리를 알고 나면 검색 흐름은 자연스럽게 따라온다.
글을 저장할 때
- 글 본문을 임베딩 API에 넣는다.
- 돌아온 벡터를 DB에 저장한다. 본문 옆에 숫자 배열 컬럼이 하나 더 생기는 셈이다.
검색할 때
- 사용자가 입력한 검색어를 같은 모델로 벡터로 바꾼다.
- 저장된 벡터들과의 거리를 계산해서 가까운 순으로 정렬한다.
2번에서 "같은 모델로"가 중요하다. 모델마다 자기만의 좌표계를 쓰기 때문에, A 모델로 만든 글 벡터와 B 모델로 만든 검색어 벡터는 비교 자체가 성립하지 않는다. 숫자는 나온다. 그 숫자가 아무 의미가 없을 뿐이다.
실제로 쓰려면 무엇이 먼저 있어야 하는가
텍스트를 벡터로 바꿔줄 모델. OpenAI, Gemini, Cohere 같은 API를 쓰거나 오픈소스 모델을 직접 돌린다.
벡터를 담을 저장소. Pinecone이나 Qdrant 같은 전용 벡터 DB도 있고, 이미 Postgres를 쓰고 있다면 pgvector 확장을 켜서 기존 테이블 옆에 벡터 컬럼을 붙이는 방법도 있다.
거리를 계산하는 쿼리. pgvector 기준으로는 ORDER BY embedding <=> $1 LIMIT 10 정도의 모양이 된다.
여기까지는 어느 글에나 나온다. 그런데 찾아보면서 알게 된 네 번째가 있었다.
무엇을 한 덩어리로 볼 것인지 정하는 일. 글 하나를 벡터 하나로 만들지, 잘라서 여러 개로 만들지의 문제다. 별것 아닌 것 같지만 검색 품질을 크게 좌우한다.
5000자짜리 글이 있고, 그 안에 배포 설정 이야기와 이미지 최적화 이야기가 같이 들어 있다고 하자. 이걸 통째로 벡터 하나로 만들면 두 주제가 섞인 어중간한 좌표가 나온다. 배포 이야기 쪽으로도 이미지 이야기 쪽으로도 확실히 가깝지 않은, 그 사이 어딘가에 놓인다. 그래서 "Vercel 배포 설정"으로 검색해도 이 글이 상위에 안 뜰 수 있다. 정작 그 내용이 안에 들어 있는데도.
잘라서 넣으면 각 조각이 자기 주제에 맞는 위치를 갖는다. 이 잘린 조각을 **청크(chunk)**라고 부른다. 대신 테이블이 하나 늘고, 글을 수정할 때마다 청크를 다시 만들어야 한다.
시작하기 전에 알아둬야 할 제약
찾아보면서 "이건 미리 알았어야 했겠다" 싶었던 것들이다.
입력 길이에 제한이 있다. gemini-embedding-001은 한 번에 2048토큰까지 받는다. 한국어는 대략 한 글자당 12토큰이니 10002000자쯤 된다. 긴 글을 그대로 넣으면 뒷부분이 조용히 잘려 나간다. 에러가 나는 게 아니라 잘린 채로 벡터가 만들어져서, 결과만 보고는 잘렸는지 알기 어렵다. 앞에서 청킹이 선택이 아니라 필수에 가까운 이유가 여기 있다.
차원을 몇으로 할지 처음에 정해야 한다. Gemini는 기본 3072차원인데, 768이나 1536으로 줄여서 받을 수도 있다. 줄이면 품질이 조금 떨어지는 대신 저장 공간과 계산량이 준다. pgvector 기준으로 벡터 하나가 차지하는 크기는 4 × 차원 + 8 바이트라서, 3072차원이면 약 12KB, 768차원이면 약 3KB다. 청크 1000개면 12MB와 3MB의 차이가 된다.
그런데 이보다 중요한 게 따로 있었다. pgvector에서 인덱스를 걸 수 있는 차원은 2000까지다. 기본값인 3072를 그대로 쓰면 인덱스를 못 붙인다. halfvec이라는 절반 정밀도 타입을 쓰면 4000까지 올라가지만, 처음부터 768로 잡아두면 이 고민 자체가 없어진다. 문서 여기저기 흩어져 있어서 다 읽고 나서야 연결된 부분이었다.
모델을 바꾸면 기존 벡터가 전부 무효가 된다. 앞에서 "같은 모델로"라고 했던 것의 대가다. 이건 이론적인 경고가 아니라 실제로 벌어진 일이다. Google 문서는 gemini-embedding-001과 후속 모델 gemini-embedding-2의 임베딩 공간이 호환되지 않으므로, 업그레이드하려면 기존 데이터를 전부 다시 임베딩해야 한다고 명시하고 있다. 글이 100개면 웃고 넘어갈 일이고, 100만 개면 예산 회의를 해야 하는 일이다.
인덱스를 붙이는 순간 정확도를 조금 내주게 된다. pgvector는 인덱스 없이도 동작하는데, 이때는 저장된 벡터를 전부 훑어서 거리를 계산한다. 느린 대신 정확한 답을 준다. 벡터가 많아지면 이 방식이 버티지 못해서 HNSW 같은 근사 인덱스를 붙이게 되는데, 이건 전체를 다 보지 않고 그래프를 타고 다니며 "가까울 법한" 후보만 확인한다. pgvector 문서가 직접 경고하듯 일반 인덱스와 달리 근사 인덱스를 붙이면 같은 쿼리인데도 결과가 달라진다. 1등이어야 할 글이 3등에 오거나 아예 빠질 수 있다는 뜻이다. 인덱스를 붙였다 뺐다 하며 결과를 비교하면 눈으로 확인된다.
공짜는 아니다. Gemini 임베딩은 100만 입력 토큰당 $0.15다. 개인 블로그 규모에서는 신경 쓸 수준이 아니지만, 글을 수정할 때마다 다시 만들어야 한다는 점은 기억해둘 만하다.
잘하는 것과 못하는 것
잘하는 쪽은 지금까지 말한 그대로다. 표현이 달라도 의미가 통하면 찾아준다. "리액트 상태관리"로 검색했을 때 본문에 그 표현이 없는 Zustand 글이 걸린다. 오타가 조금 섞여도 방향이 크게 흔들리지 않아서 어느 정도 견딘다.
못하는 쪽이 더 흥미로웠다.
정확히 그 글자를 찾아야 하는 검색에 약하다. useEffect를 검색하는 사람은 "리액트 생명주기 관련 글"을 원하는 게 아니라 useEffect가 나오는 글을 원한다. 그런데 임베딩은 useEffect와 useLayoutEffect와 componentDidMount를 전부 가까운 곳에 놓는다. 의미가 비슷하다고 판단하니까. 함수명, 라이브러리 이름, 에러 코드처럼 글자 자체가 곧 검색어인 경우에는 오히려 ilike가 정확하다.
아주 짧은 검색어에서 흔들린다. "훅" 두 글자로는 문맥이 거의 없어서 벡터가 어디로 튈지 예측하기 어렵다.
"안 된다"와 "된다"를 잘 구분하지 못한다. 부정 표현은 문장 전체에서 차지하는 비중이 작아서 벡터에 잘 반영되지 않는다. "SSR에서 window를 쓰면 안 된다"와 "SSR에서 window를 쓸 수 있다"가 비슷한 위치에 놓일 수 있다.
그래서 실제 서비스들은 기존 키워드 검색을 걷어내지 않고 둘을 같이 쓴다. pgvector 문서도 Postgres 전문검색과 함께 쓰는 하이브리드 방식을 안내하고 있다. ilike가 글자를 본다면 임베딩은 방향을 보는데, 검색어에 따라 필요한 쪽이 다르니 둘 다 두고 결과를 합치는 것이다.
알아보고 나서
찾아보기 전에는 "임베딩 검색 = 똑똑한 검색"이라고 막연히 생각했다. 실제로는 잘하는 영역과 못하는 영역이 꽤 뚜렷하게 갈리는 도구였고, 기존 검색을 대체하는 물건이 아니라 옆에 붙이는 물건에 가까웠다.
붙이는 것 자체는 여전히 어렵지 않아 보인다. 다만 차원을 몇으로 할지, 글을 어떻게 자를지, 기존 검색과 어떻게 합칠지를 먼저 정해야 한다는 걸 알게 됐다. 이건 코드를 쓰기 전에 끝내야 하는 결정들이다.
내 블로그에 실제로 어떻게 붙일지는 따로 정리하려 한다.