사이드 프로젝트에 "비슷한 글 찾기"를 붙이고 싶었다. 키워드 매칭(includes)으로는 "배포"로 검색했을 때 "Vercel에 올리기" 글이 안 잡혀서 답답했다. 의미로 찾아야 하는 일, 즉 시맨틱 검색이다.
그런데 pgvector를 깔고 벡터 DB를 띄우자니 글이 고작 80개였다. 이 규모에 인프라를 하나 더 얹는 건 과했다. 결론부터 말하면, 문서가 수백 개 수준이면 벡터 DB 없이 메모리에서 그냥 돌려도 된다. Vercel AI SDK가 필요한 조각을 둘 다 준다 — embedMany와 cosineSimilarity.
문서를 한 번에 임베딩한다
embedMany는 여러 문자열을 한 요청으로 벡터 배열로 바꿔준다. 글 제목+요약을 합쳐서 한 번에 넣었다.
import { embedMany, cosineSimilarity, embed } from 'ai';
import { openai } from '@ai-sdk/openai';
const model = openai.embedding('text-embedding-3-small');
// 빌드 타임에 한 번만 계산해서 JSON으로 떨궈둔다
const posts = allPosts.map((p) => `${p.title}\n${p.description}`);
const { embeddings } = await embedMany({ model, values: posts });
const index = allPosts.map((p, i) => ({ slug: p.slug, vector: embeddings[i] }));
핵심은 빌드 타임에 한 번만 돌리는 것. 글이 바뀔 때만 다시 계산하면 되니까 런타임 비용이 거의 없다. 결과는 index.json으로 저장해두면 끝이다.
검색은 코사인 유사도 정렬
쿼리도 같은 모델로 임베딩한 뒤, 저장해둔 벡터들과 cosineSimilarity로 점수를 매겨 정렬하면 그게 검색 결과다. 직접 내적 계산을 짤 필요도 없다.
const { embedding: query } = await embed({ model, value: '배포 자동화' });
const results = index
.map((item) => ({ slug: item.slug, score: cosineSimilarity(query, item.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
cosineSimilarity는 -1~1 사이 값을 돌려준다. 1에 가까울수록 의미가 가깝다. 실제로 돌려보니 0.3 아래는 거의 무관한 글이라, score > 0.3 정도로 잘라내니 결과가 깔끔했다.
정리
벡터 DB는 문서가 수천, 수만 개로 늘어 전수 비교가 느려질 때 필요한 물건이다. 그 전까지는 embedMany로 미리 벡터를 구워두고, 요청마다 cosineSimilarity로 선형 탐색하는 걸로 충분하다. 80개 기준 정렬까지 1ms도 안 걸렸다. 인프라를 늘리기 전에, 지금 규모에 그게 정말 필요한지부터 재보는 게 먼저다.