개발 · 2026. 8. 22.1

임베딩 검색 기능 도입기(1)

devlog-porklog(8/9)

  1. 1.태그 기능 개발 일지
  2. 2.블로그 포스팅 검색기능 개선 작업
  3. 3.관리자용 통계 화면 개발일지
  4. 4.E2E 테스트 적용기
  5. 5.데이빗또, 이건 기초적인 블로그 기능이다. (예약발행 및 비공개 기능 추가)
  6. 6.Porklog는 누가 들어오는거야? GA4 적용기(1)
  7. 7.Porklog는 누가 들어오는거야? GA4 적용기(2)
  8. 8.임베딩 검색 기능 도입기(1)
  9. 9.임베딩 검색 기능 도입기(2)
목차

지금 검색이 못 하는 것

이 블로그의 검색은 src/app/page.tsx에서 이렇게 돌아간다.

or(ilike(posts.title, `%${query}%`), ilike(posts.content, `%${query}%`))

제목이나 본문에 그 글자가 그대로 들어 있어야 걸린다. "리액트 상태관리"라고 치면, 상태관리를 다룬 글이라도 본문에 그 표현이 없으면 안 나온다. 검색이라기보다 문자열 필터다.

만들 때는 이걸로 충분하다고 봤다. 그런데 쓸수록 걸렸다. 요즘은 어디서 검색을 하든 대충 쳐도 관련된 걸 찾아주는 게 기본값이 됐는데, 그 감각에 익숙해진 상태에서 내 블로그만 글자 하나 다르면 못 찾는 검색을 달고 있는 게 좀 뒤처져 보였다.

Postgres에 내장된 전문검색(tsvector)도 후보였다. 이건 한국어에서 잘 안 통한다. 조사를 떼어낼 형태소 분석기가 있어야 하는데 Neon 같은 관리형 Postgres에는 없다. "상태관리를"과 "상태관리"가 다른 단어로 취급되는 수준이면 지금이랑 크게 다를 게 없다. 직접 붙여보진 않았고, 여기까지 읽고 접었다.

그래서 임베딩 검색 쪽을 알아보게 됐다.

임베딩 검색이 뭔가

텍스트를 숫자 배열로 바꾸는 게 출발점이다.

"고양이"를 숫자 몇백 개짜리 배열로 바꾸고 "야옹이"도 같은 방식으로 바꾸면, 두 배열이 서로 비슷한 값을 갖게 된다. "컴파일러"는 전혀 다른 값이 나온다. 이 배열을 임베딩, 혹은 벡터라고 부른다.

검색은 여기서 따라온다.

  1. 글을 미리 벡터로 바꿔서 DB에 저장해둔다.
  2. 검색어가 들어오면 검색어도 같은 방식으로 벡터로 바꾼다.
  3. 저장된 것 중 검색어 벡터와 가장 비슷한 걸 찾는다.

ilike가 글자를 비교한다면 이쪽은 의미를 비교한다. 본문에 "리액트 상태관리"라는 표현이 한 번도 안 나오는 Zustand 글이 걸릴 수 있는 게 이 때문이다.

준비물은 텍스트를 벡터로 바꿔줄 모델, 벡터를 담을 저장소, 비슷한 걸 찾는 쿼리다. 저장소 때문에 Pinecone 같은 별도 벡터 DB를 떠올리기 쉬운데, Postgres에는 pgvector라는 확장이 있다. 벡터 전용 컬럼 타입과 비교 연산자를 추가해준다. 지금 쓰는 Neon Postgres에 확장 하나 켜면 저장소 문제는 끝난다.

Drizzle도 0.31부터 pgvector를 기본 지원해서 vector 컬럼 타입과 cosineDistance 헬퍼를 그대로 쓸 수 있다. 다만 확장 활성화는 Drizzle이 대신 해주지 않는다. 빈 마이그레이션 파일을 만들어 CREATE EXTENSION IF NOT EXISTS vector를 직접 넣어야 한다.

이 방식의 운영 부담이 내 경우엔 거의 없다

임베딩 검색의 실질적인 단점은 따로 있다. 글을 쓰거나 고칠 때마다 벡터를 다시 만들어야 한다는 점이다.

ilike는 글을 DB에 넣으면 끝이다. 검색은 저장된 텍스트를 그때그때 훑으면 된다. 임베딩 검색은 저장 시점에 외부 API를 한 번 더 거쳐야 하고, 본문을 한 글자만 고쳐도 그 글의 벡터는 전부 무효가 된다.

문서가 수만 건이고 여러 사람이 동시에 고치는 환경이면 이게 꽤 큰 일이 된다. 저장 요청이 몰릴 때 API 호출을 어떻게 흘려보낼지, 실패한 건 어떻게 다시 시도할지, 대량 수정이 들어왔을 때 어디까지 갱신됐는지를 어떻게 추적할지. 큐와 워커를 붙이는 순간 이건 검색 기능이 아니라 하나의 파이프라인이 된다.

여기는 그런 환경이 아니다. 글을 쓰는 사람이 나 하나고, 많이 써봐야 일주일에 4~5개다. 글 하나 저장할 때 API를 몇 번 더 다녀오는 걸로 끝난다. 큐도 워커도 필요 없고 저장 액션 안에서 순서대로 처리하면 된다. 요금도 이 정도 양이면 무료 티어를 안 넘는다.

개인 블로그는 이 기술의 가장 큰 운영 부담이 애초에 발생하지 않는 조건이다.

그리고 AI를 붙일 수 있는 곳엔 붙여보고 싶다

이유가 하나 더 있는데, 이건 순수하게 해보고 싶어서다.

앞서 Tech Digest 기능을 만들면서 Gemini를 붙여봤다. 그때는 AI를 글 요약에 썼다. 같은 API를 이번엔 검색에 쓰면 뭐가 어떻게 달라지는지 직접 만들어보고 싶었다. 임베딩이 실제로 어느 정도 잘 통하는지, 어디서 무너지는지는 남의 글로 읽는 것보다 내 데이터로 굴려보는 게 빠르다.

솔직히 글이 수십 개인 지금은 체감 이득이 크지 않을 거라고 본다. "안 나오던 게 나온다"는 경험은 글이 어느 정도 쌓여야 생긴다. 그래도 지금 해두는 게 낫다. 나중에 몰아서 처리할 데이터가 적고, 검색 결과가 이상하면 눈으로 바로 확인할 수 있는 규모다.

설계 1: 글 하나에 벡터 하나인가, 여러 개인가

첫 번째 결정이다.

쓰려는 모델(gemini-embedding-001)은 한 번에 넣을 수 있는 양이 2,048토큰으로 제한돼 있다. 한국어 기술 글은 이걸 쉽게 넘는다. 글 전체를 벡터 하나로 만들면 앞부분만 들어가고 뒷부분은 통째로 검색에서 사라진다.

길이 문제가 없어도 걸리는 게 하나 더 있다. 한 글에 여러 주제가 섞여 있을 때 그걸 벡터 하나로 뭉개면 평균값처럼 되어버린다. "Drizzle 스키마 설계"와 "Vercel Cron 설정"을 같이 다룬 글은 두 주제 어느 쪽으로도 선명하게 걸리지 않는다.

그래서 헤딩 단위로 자르기로 했다. 마크다운의 ##를 기준으로 나누고, 잘라낸 조각마다 벡터를 하나씩 만든다. 이 조각 하나를 청크(chunk)라고 부른다. post_chunks 테이블이 하나 늘어난다.

부수적인 이득도 있다. 어느 청크가 걸렸는지 알 수 있으니 검색 결과에서 글 맨 위가 아니라 해당 섹션으로 바로 보낼 수 있다. 이 블로그는 이미 rehype-slugsrc/lib/toc.ts에서 heading id를 만들고 있으니 같은 방식으로 뽑아서 청크에 같이 저장해두면 된다.

설계 2: 기존 ilike 검색을 대체할 것인가, 같이 쓸 것인가

알아보면서 알게 된 건데, 임베딩은 useState, pnpm, Drizzle 같은 정확한 단어를 찾는 데는 오히려 약하다. 고유명사나 코드 식별자는 의미로 따지면 구분이 흐릿해서, "정확히 이 단어가 있는 글"을 찾는 상황에서는 단순 문자열 매칭이 이긴다.

완전히 갈아타면 지금 잘 되던 검색이 나빠지는 경우가 생긴다는 뜻이다. 그래서 둘 다 쓴다.

  • ilike 결과를 먼저 뽑는다. 지금 동작 그대로다.
  • 벡터 검색 결과를 따로 뽑는다.
  • 합치되, ilike에서 이미 나온 글은 빼고 벡터 쪽 결과를 뒤에 붙인다.

두 검색 결과의 순위를 정교하게 섞는 기법(RRF 같은)도 있지만 한 페이지에 10개 나오는 검색에는 과하다. "기존 결과 + 추가로 더 나오는 것" 정도의 단순한 구조로 간다. 이러면 벡터 쪽이 이상하게 동작해도 기존 검색이 나빠지지 않는다.

카테고리·태그 필터와 비공개·예약 글 제외 조건은 벡터 쿼리에도 똑같이 걸어야 한다.

설계 3: 벡터 차원을 몇으로 잡을 것인가

여기서 차원은 벡터 하나에 들어가는 숫자의 개수다. 이 모델은 기본으로 3,072개짜리를 내놓고, 원하면 768개나 1,536개로 줄일 수 있다. 줄여도 성능 차이는 거의 없다고 한다.

기본값을 그냥 쓰면 될 것 같지만 함정이 있다.

나중에 글이 많아져서 검색이 느려지면 인덱스를 붙여야 한다. 그런데 pgvector의 벡터 인덱스는 2,000차원까지만 지원한다. 3,072로 저장해두면 그때 가서 인덱스를 못 붙인다. 차원을 바꾸려면 모든 글을 다시 임베딩해야 한다.

지금은 글이 수십 개라 인덱스 없이도 충분히 빠르다. 그래서 인덱스는 안 만들되 차원만 768로 잡아둔다. 저장 용량도 1/4이고 나중에 여지도 남는다. 3,072보다 작게 받으면 저장 전에 값을 정규화해줘야 한다는 조건이 붙는데, 몇 줄짜리라 부담은 아니다.

차원 문제를 찾다가 딸려 나온 게 하나 더 있다. 지금은 겪지 않지만 인덱스를 붙이는 순간 겪을 문제다. 유사도를 1 - cosineDistance(...)로 계산해서 높은 순으로 정렬하는 예제를 자주 보게 되는데, 이렇게 쓰면 인덱스를 안 탄다. 인덱스는 거리 값 자체에 걸려 있기 때문이다. 정렬은 cosineDistance 오름차순으로 하고, 화면에 보여줄 점수만 따로 계산해야 한다. 지금은 인덱스가 없어서 티가 안 나지만, 나중에 이걸 모르면 "인덱스를 만들었는데 왜 안 빨라지지"로 한참 헤맬 것 같다.

설계 4: 임베딩을 언제 생성할 것인가

앞에서 적은 대로 큐나 워커를 붙일 이유가 없다. post-actions.tscreatePost/updatePost 안에서 순서대로 처리하는 게 제일 단순하다. 글을 저장하고, 본문을 청크로 자르고, 각 청크를 Gemini에 보내 벡터를 받고, post_chunks에 넣는다.

문제는 중간에 실패했을 때다. 임베딩 생성 실패가 글 저장을 막으면 안 된다. 예를 들어 글을 저장하는 순간 Gemini API가 응답하지 않으면 이렇게 된다.

  1. 글 본문은 posts 테이블에 정상 저장된다. 글쓰기 화면에서는 저장 성공이다.
  2. 청크와 벡터는 만들어지지 않는다. embeddedAt은 비어 있는 채로 남는다.
  3. 이 글은 벡터 검색에서만 안 걸린다. 기존 ilike 검색으로는 그대로 나온다.

최악의 경우가 "이 글에 한해 검색이 예전 수준으로 돌아가는 것"이지, 글이 날아가거나 저장 버튼이 안 먹는 게 아니다. 썸네일 blob 삭제를 실패해도 넘어가게 해둔 것과 같은 방식이다.

그러면 임베딩이 밀린 글을 나중에 찾아낼 방법이 필요하다. postsembeddedAt 컬럼을 두고, 이 값이 없거나 updatedAt보다 오래됐으면 다시 만들어야 할 글로 본다. 밀린 것들을 한 번에 처리하는 스크립트를 하나 만들어두면 재시도와 기존 글 일괄 처리가 같이 해결된다.

구현할 때 확인해야 할 것

여기까지가 설계다. 구현은 아직 시작하지 않았지만, 나중에 구현한다면 필수적으로 확인해야 할 사항은 다음과 같을 것이다.

1. 자르기

글 하나를 넣었을 때 ## 헤딩마다 청크가 정확히 하나씩 나오는지 본다. 한 섹션의 본문이 다음 청크까지 넘어가거나, 반대로 한 섹션이 여러 개로 쪼개지면 잘못 자른 것이다. 헤딩이 아예 없는 글과 헤딩이 하나뿐인 글도 같이 넣어본다. 이 경우 글 전체가 청크 하나로 나와야 한다.

2. 저장

글을 수정했을 때 이전 청크가 남지 않는지 본다. 예를 들어 섹션이 5개였던 글을 3개로 줄여 저장했는데 post_chunks에 여전히 5개가 있으면, 사라진 섹션 2개가 계속 검색에 걸린다. 실제 글에 없는 내용이 검색 결과로 나오는 상태다. 수정 시 해당 글의 청크를 전부 지우고 다시 만드는 쪽으로 갈 텐데, 그게 제대로 도는지 확인한다.

3. 검색

두 가지를 나눠서 본다.

  • 새로 되어야 하는 것: "리액트 상태관리"로 검색했을 때, 본문에 그 표현이 한 번도 안 나오는 Zustand 글이 결과에 뜨는가. 이게 이번에 추가한 기능 그 자체다.
  • 원래 되던 것: useState로 검색했을 때 기존과 같은 글이 같은 순서로 뜨는가. 벡터 검색을 덧붙였다가 오히려 기존 결과를 밀어내면 개선이 아니라 퇴보다. 그래서 이쪽을 먼저 확인한다.

아직 정하지 않은 것

어느 정도 비슷해야 검색 결과에 넣을지, 그 기준값은 지금 정하지 않는다. 예제마다 0.5니 0.7이니 하는 숫자가 나오는데 내 글로 돌려보기 전에는 의미가 없다. 결과에 점수를 같이 찍어보고 눈으로 확인한 다음 정하려고 한다.

인덱스도 지금은 안 만든다. 글이 늘어나서 검색이 느려지는 게 실제로 확인되면 그때 붙인다. 그때 붙일 수 있게 벡터 차원을 768로 잡아둔 것까지가 지금 할 수 있는 준비다.