2026-03-09

블로그 검색에 임베딩 기반 벡터 유사도를 도입한 이야기

블로그에 검색 기능을 붙이려고 했을 때, 처음에는 단순한 LIKE 검색으로 충분할 거라고 생각했다. 제목이나 태그에 키워드가 포함되어 있으면 찾아주면 되니까. 그런데 실제로 써보니 금방 한계가 느껴졌다.

"비동기 처리"라고 검색하면 "비동기와 병렬 처리" 글은 나오는데, "CompletableFuture"나 "WebFlux" 같은 관련 글은 안 나온다. 사용자가 찾고 싶은 건 "비동기"라는 단어 자체가 아니라, 비동기와 관련된 개념들인데 말이다.

이 문제를 해결하기 위해 임베딩 기반 벡터 유사도 검색을 도입했고, 키워드 검색과 결합한 하이브리드 방식으로 구현했다. 이 글에서는 그 과정을 정리한다.


임베딩이란

임베딩은 텍스트를 고차원 숫자 벡터로 변환하는 것이다. "스프링 시큐리티"와 "인증 인가"는 글자는 전혀 다르지만, 의미적으로는 가까운 개념이다. 임베딩은 이런 의미적 유사성을 숫자로 표현해준다.

이 블로그에서는 OpenAI의 text-embedding-3-large 모델을 사용한다. 입력 텍스트를 3072차원의 벡터로 변환해주는데, 차원이 높을수록 미세한 의미 차이를 구분할 수 있다.

// embedding.ts
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
 
export async function getEmbedding(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: 'text-embedding-3-large',
    input: text,
  });
  return res.data[0].embedding;
}

함수 자체는 단순하다. 텍스트를 넣으면 숫자 배열이 나온다. 진짜 고민은 "뭘 넣느냐"에 있다.

청킹 — 글을 어떻게 쪼갤 것인가

블로그 글 하나를 통째로 임베딩하면 안 된다. 글이 길면 핵심 의미가 희석되고, 모델의 토큰 제한에도 걸린다. 그래서 글을 적절한 크기의 조각(chunk)으로 나눠야 한다.

처음에는 단순히 글자 수로 잘랐다. 1000자마다 끊는 식으로. 그런데 이렇게 하면 문단 중간에서 잘리면서 맥락이 끊겼다. "이 방식의 장점은"이라는 문장이 잘려서 앞 chunk에만 들어가고, 실제 장점 설명은 다음 chunk에 들어가는 식이다.

그래서 단락(paragraph) 단위로 먼저 분리하고, 단락들을 합쳐서 1000자 근처가 되면 하나의 chunk로 만드는 방식으로 바꿨다.

export function chunkText(
  title, description, tags, content, chunkSize = 1000
) {
  const cleanContent = stripHtml(content);
  const prefix = [title, description, tags.join(' ')].filter(Boolean).join(' ');
  const chunks = [];
  const paragraphs = cleanContent.split(/\n\n+/);
  let current = '';
 
  for (const para of paragraphs) {
    if ((current + '\n\n' + para).length > chunkSize && current.length > 0) {
      chunks.push(`${prefix}\n\n${current.trim()}`);
      current = para;
    } else {
      current = current ? `${current}\n\n${para}` : para;
    }
  }
 
  if (current.trim()) {
    chunks.push(`${prefix}\n\n${current.trim()}`);
  }
  return chunks;
}

여기서 핵심적인 설계 결정이 하나 있다. 모든 chunk의 앞에 글 제목, 설명, 태그를 prefix로 붙인다는 것이다. 이렇게 하면 본문 중간 조각만으로도 "이 chunk가 어떤 글에 속하는지"가 임베딩에 반영된다. prefix 없이 본문만 임베딩하면 검색 정확도가 눈에 띄게 떨어졌다.

저장 — PostgreSQL + pgvector

임베딩 벡터를 저장하고 검색하기 위해 PostgreSQL의 pgvector 확장을 사용한다. Neon(서버리스 PostgreSQL)에서 기본 지원하기 때문에 별도 설정 없이 바로 쓸 수 있었다.

글이 저장될 때 chunk별로 임베딩을 생성해서 post_chunks 테이블에 넣는다.

async function createPostEmbeddings(postId, title, description, tags, content) {
  const chunks = chunkText(title, description, tags, content, 1000);
  for (let i = 0; i < chunks.length; i++) {
    const embedding = await getEmbedding(chunks[i]);
    await sql`
      INSERT INTO post_chunks (post_id, chunk_index, chunk_text, embedding)
      VALUES (${postId}, ${i}, ${chunks[i]}, ${embeddingStr}::vector)
    `;
  }
}

임베딩 생성은 비동기로 처리하고, 실패해도 글 저장은 유지된다. 검색이 안 되는 것보다 글이 사라지는 게 더 나쁘니까.

검색 — 하이브리드 스코어링

검색은 두 가지 방식을 결합한다.

  1. 키워드 매칭 — 제목, 설명, 태그에 검색어가 직접 포함되어 있는지 (ILIKE)

  2. 벡터 유사도 — 검색어의 임베딩과 각 chunk 임베딩 간의 코사인 유사도

최종 점수는 이 둘을 가중합한다:

finalScore = 0.4 * keywordScore + 0.6 * vectorScore

벡터 유사도에 60%의 가중치를 준 이유는, 키워드가 정확히 일치하지 않아도 관련 글을 찾아주는 게 이 검색의 핵심 가치이기 때문이다. 하지만 키워드가 정확히 일치하는 경우에는 확실히 상위에 올려야 하므로 40%의 보너스를 준다.

SQL 쿼리에서는 각 글의 chunk 중 가장 유사도가 높은 것만 선택한다 (DISTINCT ON). 글 하나가 5개 chunk로 나뉘었다면, 검색어와 가장 잘 맞는 chunk 하나의 점수로 그 글을 대표한다.

SELECT DISTINCT ON (c.post_id)
  c.post_id,
  1 - (c.embedding <=> ${embeddingStr}::vector) AS similarity
FROM post_chunks c
ORDER BY c.post_id, c.embedding <=> ${embeddingStr}::vector

<=>는 pgvector의 코사인 거리 연산자다. 1 - 거리 = 유사도로 변환한다.

점수 보정 — raw 유사도의 함정

코사인 유사도의 raw 값은 직관적이지 않다. "스프링"이라고 검색했을 때 스프링 관련 글의 유사도가 0.35, 전혀 관련 없는 글이 0.15 정도 나온다. 0.35가 높은 건지 낮은 건지 사용자는 알 수 없다.

그래서 scaleSimilarity 함수로 보정한다.

export function scaleSimilarity(raw: number): number {
  const min = 0.10;  // 이 이하는 무관한 글
  const max = 0.65;  // 이 이상이면 매우 관련 있는 글
  const scaled = (raw - min) / (max - min);
  return Math.max(0, Math.min(1, scaled));
}

0.10~0.65 범위를 0~1로 정규화한다. 이 범위는 text-embedding-3-large 모델에서 실제 검색 결과를 관찰하면서 잡은 값이다. 모델이 바뀌면 이 범위도 다시 튜닝해야 한다.

보정 후 최종 점수가 0.1(10%) 미만인 결과는 아예 제외한다. 관련 없는 글이 목록에 나오는 것보다 결과가 적은 게 낫다.

클라이언트 — 즉각 반응 + 서버 보강

검색 UX에서 중요한 건 반응 속도다. 임베딩 검색은 OpenAI API 호출이 필요해서 300ms~1초 정도 걸린다. 이 시간 동안 사용자가 빈 화면을 보고 있으면 안 된다.

그래서 2단계로 나눴다:

  1. 즉시 (클라이언트) — 현재 페이지에 로드된 글들을 대상으로 초성 + 키워드 필터링. 타이핑과 동시에 결과가 바뀐다.

  2. 300ms 후 (서버) — 디바운스 후 서버에 벡터 검색 요청. 결과가 오면 클라이언트 결과와 병합한다.

병합할 때는 서버 결과를 우선하되, 클라이언트에만 있는 결과를 뒤에 추가한다. 이렇게 하면 사용자는 타이핑 즉시 결과를 보면서, 잠시 후 더 정확한 결과로 자연스럽게 업데이트되는 경험을 한다.

비용과 트레이드오프

이 구조에서 비용이 발생하는 지점은 두 곳이다:

  • 글 저장 시 — chunk 수만큼 OpenAI 임베딩 API 호출. 보통 글 하나당 2~5개 chunk.

  • 검색 시 — 검색어 1개에 대해 임베딩 API 1회 호출.

text-embedding-3-large의 비용은 토큰당 매우 저렴하기 때문에 개인 블로그 규모에서는 사실상 무시할 수 있다. 글이 수천 개가 되면 pgvector의 인덱스 전략(IVFFlat, HNSW)을 고민해야 하겠지만, 현재 규모에서는 brute-force 스캔으로 충분하다.

정리

돌아보면 결국 핵심은 세 가지였다.

  1. 청킹 전략 — 단락 단위 분리 + 메타데이터 prefix로 맥락 보존

  2. 하이브리드 스코어링 — 키워드 40% + 벡터 60%로 정확성과 발견성 균형

  3. 점수 보정 — raw 코사인 유사도를 실측 기반으로 0~1 스케일에 매핑

완벽한 검색은 아니다. 글이 많아지면 튜닝할 부분도 생길 것이다. 하지만 LIKE 검색만 있던 때와 비교하면, "트랜잭션"을 검색했을 때 "격리 수준"이나 "JPA" 관련 글도 함께 나오는 경험은 확실히 다르다.

관련 글

벡터 유사도 기반