블로그에 임베딩 기반 관련 글 추천 시스템 구축하기

정기창·

왜 임베딩 기반 관련 글 추천인가?

블로그에 "관련 글" 기능은 이미 있었다. 태그가 겹치는 글을 찾아서 보여주는 단순한 방식이었는데, 몇 가지 한계가 있었다.

  • 태그를 꼼꼼하게 달지 않으면 관련 글이 아예 안 나온다

  • 태그가 같아도 실제 내용이 다른 경우가 있다

  • 반대로 태그는 다르지만 내용이 비슷한 글을 놓친다

결국 "글의 실제 내용"을 기반으로 유사도를 계산해야 정확한 관련 글을 찾을 수 있다. 이걸 가능하게 하는 기술이 바로 텍스트 임베딩이다.

임베딩이란?

임베딩은 텍스트를 고차원 벡터(숫자 배열)로 변환하는 기술이다. 비슷한 의미의 텍스트는 벡터 공간에서 가까운 위치에 배치된다.

예를 들어:

  • "React Hooks 사용법" → [0.12, -0.34, 0.56, ...]

  • "React useState 가이드" → [0.11, -0.33, 0.57, ...] (비슷한 벡터)

  • "요리 레시피" → [-0.45, 0.23, -0.12, ...] (다른 벡터)

두 벡터 간의 유사도를 계산하면 글이 얼마나 비슷한지 수치로 알 수 있다. 이걸 코사인 유사도라고 한다.

기술 스택

  • Gemini Embedding API (gemini-embedding-001): 3072차원 벡터 생성

  • NestJS: 백엔드 API

  • MongoDB: 임베딩 벡터 저장

  • MCP (Model Context Protocol): Claude Code에서 직접 호출

구현 과정

1. EmbeddingService 구현

NestJS에 EmbeddingService를 추가했다. 핵심 기능은 세 가지:

@Injectable()
export class EmbeddingService {
  // 1. 단일 글 임베딩 생성
  async generateEmbedding(slug: string, language: string, force: boolean)
  
  // 2. 전체 글 일괄 임베딩 (Rate Limiting 적용)
  async generateAllEmbeddings(language: string, force: boolean)
  
  // 3. 유사도 계산 및 관련 글 업데이트
  async updateRelatedPostsBidirectional(slug: string, language: string)
}

2. 변경 감지로 불필요한 API 호출 방지

임베딩 생성은 API 호출 비용이 발생한다. 글이 바뀌지 않았는데 매번 임베딩을 새로 만들 필요는 없다.

// 콘텐츠 해시로 변경 감지
const newHash = generateContentHash(text);
if (!force && post.contentHash === newHash) {
  return { skipped: true, skipReason: '콘텐츠 변경 없음' };
}

SHA-256 해시로 콘텐츠 변경을 감지하고, 변경이 없으면 임베딩 생성을 스킵한다.

3. Rate Limiting 처리

Gemini 무료 티어는 분당 15 요청 제한이 있다. 40개 글을 한 번에 처리하면 에러가 발생한다.

// 1개씩 + 5초 대기 = 분당 12 요청 (안전)
for (let i = 0; i < posts.length; i += rateLimit) {
  const batch = posts.slice(i, i + rateLimit);
  await Promise.all(batch.map(post => this.generateEmbedding(post.slug)));
  
  // Rate limiting
  await new Promise(resolve => setTimeout(resolve, 5000));
}

4. 양방향 관련 글 업데이트

A 글의 관련 글에 B가 있다면, B 글의 관련 글에도 A가 있어야 자연스럽다.

async updateRelatedPostsBidirectional(slug: string) {
  // 1. 대상 글과 모든 글의 유사도 계산
  const similarities = await this.calculateSimilarity(slug);
  
  // 2. 대상 글의 relatedPosts 업데이트
  await this.blogPostModel.updateOne(
    { slug },
    { $set: { relatedPosts: similarities.slice(0, 5) } }
  );
  
  // 3. 관련 글들의 relatedPosts에도 대상 글 추가 (양방향)
  for (const related of similarities.slice(0, 5)) {
    // related 글의 relatedPosts에 현재 글 추가
    // 이미 있으면 점수 업데이트, 없으면 추가
  }
}

5. 게시 시 자동 처리

가장 중요한 부분이다. 글을 게시할 때 자동으로 임베딩과 관련 글이 처리되어야 한다.

async publishDraft(draftId: string) {
  const draft = await this.findDraft(draftId);
  
  // 1. slug 생성 (없으면)
  if (!draft.slug) {
    draft.slug = generateSlug(draft.title);
  }
  
  // 2. 임베딩 + 관련 글 처리 (게시 전에 실행!)
  await this.generateEmbeddingAndRelatedPosts(draft.slug);
  
  // 3. 성공 후에만 게시 상태로 저장
  draft.isDraft = false;
  draft.isPublished = true;
  return draft.save();
}

핵심 포인트: 임베딩 생성을 게시 저장 전에 실행한다. 임베딩 실패 시 예외가 발생하고, 글은 초안 상태로 유지된다. 이렇게 해야 "게시됐는데 임베딩이 없는" 불일치 상태를 방지할 수 있다.

데이터 구조

MongoDB에 저장되는 임베딩 관련 필드:

interface BlogPost {
  // 기존 필드들...
  
  // 임베딩 관련
  embedding: {
    vector: number[];      // 3072차원 벡터
    model: string;         // "gemini-embedding-001"
    updatedAt: string;     // ISO 날짜
  };
  contentHash: string;     // 변경 감지용 해시
  previousContent: string; // 변경률 계산용
  
  // 관련 글
  relatedPosts: Array<{
    slug: string;
    title: string;
    relevanceScore: number;  // 0-100
    reason: string;          // "임베딩 유사도: 85%"
    analyzedAt: string;
  }>;
}

MCP 도구 확장

Claude Code에서 직접 임베딩 작업을 할 수 있도록 MCP 도구도 추가했다:

  • generate_embedding: 단일 글 임베딩 생성

  • generate_all_embeddings: 전체 글 일괄 처리

  • calculate_similarity: 유사도 계산

  • update_related_posts_bidirectional: 관련 글 양방향 업데이트

덕분에 "이 글의 관련 글 업데이트해줘"라고 말하면 Claude가 알아서 처리해준다.

결과

40개 글에 임베딩을 생성하고 관련 글을 업데이트했다. 예를 들어 "Claude Code에서 Grafana MCP 연동하기" 글의 관련 글:

  1. Claude Code에서 Coolify MCP 연동하기 - 86%

  2. Claude Code에서 블로그 글 작성하기: MCP 활용 - 84%

  3. NestJS + Prometheus + Grafana 모니터링 구축기 - 77%

  4. Claude Code 사용량 관리 크롬 익스텐션 - 75%

  5. 요즘 고민하는 것: 추상화 - 74%

MCP 관련 글, 모니터링 관련 글이 상위에 올라왔다. 태그만으로는 찾기 어려웠을 연관성을 임베딩이 잡아낸 것이다.

비용

Gemini Embedding API는 무료 티어가 넉넉하다:

  • 분당 15 요청

  • 일일 1,500 요청

개인 블로그 수준에서는 충분하다. 40개 글 전체 처리에 약 4분 정도 걸렸다.

마무리

태그 기반에서 임베딩 기반으로 관련 글 추천을 개선했다. 구현하면서 느낀 점:

  • 임베딩은 생각보다 쉽다: API 호출 한 번이면 벡터가 나온다

  • Rate Limiting이 핵심: 무료 티어 제한을 고려한 배치 처리 필수

  • 상태 일관성 중요: 게시-임베딩 순서를 잘못 잡으면 불일치 발생

  • 양방향 업데이트: 사용자 경험을 위해 필요한 로직

AIEmbeddingNestJSGemini API블로그 개발