2026-03-13

pgvector로 블로그 관련 글 추천 구현하기

왜 관련 글 추천인가

블로그에 글이 쌓이면 독자가 비슷한 주제의 글을 발견하기 어렵다. 태그 기반 필터링은 정확하지만, "이 글과 내용이 비슷한 글"을 찾아주진 못한다. 벡터 유사도 검색을 쓰면 글의 의미적 유사성을 기반으로 추천할 수 있다.

전체 구조

구현은 크게 세 단계로 나뉜다.

  1. 임베딩 생성 — 글 저장 시 OpenAI API로 텍스트를 벡터로 변환

  2. 벡터 저장 — PostgreSQL + pgvector 확장에 벡터 저장

  3. 유사도 검색 — 코사인 거리로 가장 비슷한 글 조회

1단계: pgvector 설정

PostgreSQL에 pgvector 확장을 활성화하고, 벡터를 저장할 테이블을 만든다.

CREATE EXTENSION IF NOT EXISTS vector;
 
CREATE TABLE post_chunks (
  id SERIAL PRIMARY KEY,
  post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
  chunk_index INTEGER NOT NULL,
  chunk_text TEXT NOT NULL,
  embedding vector(3072)  -- text-embedding-3-large 차원
);

vector(3072)는 OpenAI의 text-embedding-3-large 모델이 반환하는 3072차원 벡터를 저장하기 위한 타입이다. 글 전체를 하나의 벡터로 만들지 않고 청크(chunk) 단위로 분할하는 이유는 긴 글의 의미를 더 정확하게 포착하기 위해서다.

2단계: 텍스트 청킹과 임베딩 생성

글을 저장할 때 HTML 콘텐츠에서 태그를 벗기고, 단락 단위로 청크를 나눈다.

export function chunkText(
  title: string,
  description: string,
  tags: string[],
  content: string,
  chunkSize = 1000,
  date?: string
): string[] {
  const cleanContent = stripHtml(content);
  const prefix = [title, description, tags.join(' ')].filter(Boolean).join(' ');
  const chunks: string[] = [];
  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;
}

핵심 포인트:

  • 각 청크 앞에 제목, 설명, 태그를 prefix로 붙인다. 이렇게 하면 본문 일부만 들어있는 청크도 글의 맥락을 유지한다.

  • 단락 단위로 자르기 때문에 문장이 중간에 끊기지 않는다.

  • 청크 크기는 1000자로 설정했다. 너무 크면 의미가 희석되고, 너무 작으면 맥락을 잃는다.

각 청크를 OpenAI API로 임베딩한다.

import OpenAI from 'openai';
 
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;
}

3단계: 글 저장 시 임베딩 자동 생성

글을 발행할 때 after() API를 사용해 백그라운드에서 임베딩을 생성한다. 응답 시간에 영향을 주지 않는다.

// API route에서 글 저장 후
if (status !== 'draft') {
  after(async () => {
    const chunks = chunkText(title, description, tags, content, 1000);
    for (let i = 0; i < chunks.length; i++) {
      const embedding = await getEmbedding(chunks[i]);
      const embeddingStr = \`[\${embedding.join(',')}]\`;
      await sql\`
        INSERT INTO post_chunks (post_id, chunk_index, chunk_text, embedding)
        VALUES (\${postId}, \${i}, \${chunks[i]}, \${embeddingStr}::vector)
      \`;
    }
  });
}

after()는 Next.js의 서버리스 환경에서 응답을 보낸 뒤 백그라운드 작업을 실행하는 API다. 임베딩 생성이 실패해도 글 저장 자체에는 영향을 주지 않도록 try-catch로 감싸두었다.

4단계: 관련 글 쿼리

이 글의 핵심이다. 현재 글의 첫 번째 청크 임베딩을 기준으로, 다른 모든 글의 청크와 코사인 유사도를 계산한다.

WITH current_embedding AS (
  SELECT embedding FROM post_chunks
  WHERE post_id = :postId
  ORDER BY chunk_index ASC LIMIT 1
)
SELECT p.slug, p.title, p.description,
       MAX(1 - (pc.embedding <=> ce.embedding)) AS similarity
FROM post_chunks pc
JOIN posts p ON pc.post_id = p.id
CROSS JOIN current_embedding ce
WHERE pc.post_id != :postId AND p.status = 'published'
GROUP BY p.id, p.slug, p.title, p.description, p.tags, p.created_at
HAVING MAX(1 - (pc.embedding <=> ce.embedding)) > 0.15
ORDER BY similarity DESC
LIMIT 3;

쿼리 분석:

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

  • MAX()로 글별 최고 유사도를 취한다. 글 A의 3번째 청크가 현재 글과 가장 비슷하면 그 점수를 쓴다.

  • HAVING > 0.15로 최소 임계값을 설정해 관련 없는 글을 필터링한다.

  • 첫 번째 청크(제목+설명+태그+본문 시작)를 기준으로 비교하는 이유는 글의 전체 주제를 가장 잘 대표하기 때문이다.

유사도 스케일링

코사인 유사도 원시값은 직관적이지 않다. text-embedding-3-large 모델 기준으로 0.15~0.65 범위가 대부분이라, 이를 0~100%로 스케일링한다.

export function scaleSimilarity(raw: number): number {
  const min = 0.15;
  const max = 0.65;
  const scaled = (raw - min) / (max - min);
  return Math.max(0, Math.min(1, scaled));
}

이렇게 하면 관련 글 카드에 "72% 일치"처럼 사람이 이해할 수 있는 수치를 보여줄 수 있다.

검색에도 활용하기

같은 임베딩을 블로그 검색에도 재활용할 수 있다. 키워드 매칭과 벡터 유사도를 하이브리드로 결합하면 정확도가 크게 올라간다.

-- 키워드 40% + 벡터 유사도 60% 가중 결합
ORDER BY (0.4 * keyword_score + 0.6 * vector_similarity) DESC

키워드 검색은 정확한 단어 매칭에 강하고, 벡터 검색은 의미적 유사성에 강하다. 둘을 합치면 "Next.js"를 검색했을 때 "Next.js"가 제목에 있는 글(키워드)과 React 서버 컴포넌트에 대한 글(의미적 유사)이 모두 나온다.

운영 팁

임베딩 재생성

스크립트로 DB에 직접 INSERT한 글이나, 임베딩 생성 중 에러가 난 글은 임베딩이 없을 수 있다. 관리자 페이지에서 임베딩이 없는 글만 찾아 재생성하는 API를 만들어두면 편하다.

-- 임베딩 없는 글 조회
SELECT p.id, p.title
FROM posts p
LEFT JOIN post_chunks pc ON p.id = pc.post_id
GROUP BY p.id
HAVING COUNT(pc.id) = 0;

비용

text-embedding-3-large는 토큰당 $0.00013으로, 1000자 글 하나에 약 $0.001 정도다. 글 수백 개 수준에서는 비용을 거의 무시할 수 있다.

임계값 튜닝

0.15는 경험적으로 설정한 값이다. 너무 낮으면 관련 없는 글이 나오고, 너무 높으면 추천이 비어 보인다. 글이 30개 이상 쌓이면 값을 조정해보는 게 좋다.

결과

구현 후 글 하단에 "관련 글" 섹션이 자동으로 표시된다. 유사도 퍼센트와 함께 최대 3개의 관련 글이 노출되며, 같은 임베딩으로 블로그 검색까지 지원한다. pgvector의 코사인 거리 연산은 수만 개의 벡터에서도 충분히 빠르다.

관련 글

벡터 유사도 기반