2026-03-13
pgvector로 블로그 관련 글 추천 구현하기
왜 관련 글 추천인가
블로그에 글이 쌓이면 독자가 비슷한 주제의 글을 발견하기 어렵다. 태그 기반 필터링은 정확하지만, "이 글과 내용이 비슷한 글"을 찾아주진 못한다. 벡터 유사도 검색을 쓰면 글의 의미적 유사성을 기반으로 추천할 수 있다.
전체 구조
구현은 크게 세 단계로 나뉜다.
임베딩 생성 — 글 저장 시 OpenAI API로 텍스트를 벡터로 변환
벡터 저장 — PostgreSQL + pgvector 확장에 벡터 저장
유사도 검색 — 코사인 거리로 가장 비슷한 글 조회
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의 코사인 거리 연산은 수만 개의 벡터에서도 충분히 빠르다.
관련 글
벡터 유사도 기반블로그 검색에 임베딩 기반 벡터 유사도를 도입한 이야기
LIKE 검색의 한계를 넘어 OpenAI 임베딩과 pgvector를 활용한 하이브리드 검색을 구현한 과정. 청킹 전략, 스코어링 방식, 점수 보정까지.
100% 일치개인 블로그에 RAG 기반 AI 챗봇 구축하기
pgvector + OpenAI 임베딩 + Groq LLM으로 블로그 콘텐츠 기반 RAG 챗봇을 만든 과정을 정리했습니다.
98% 일치OpenAI vs Gemini Embedding 2 — 임베딩 모델 비교 분석
text-embedding-3-large와 Gemini Embedding 2의 가격, 성능, 멀티모달 지원을 비교하고 실제 프로덕션에서의 선택 기준을 정리한다.