홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

개인 블로그에 AI 검색 달기 (2) - MongoDB Atlas Vector Search 구현

정기창·2026년 2월 2일

MongoDB Atlas Vector Search 설정하기

하이브리드 검색의 핵심은 벡터 검색입니다. MongoDB Atlas에서 Vector Search를 사용하려면 먼저 벡터 인덱스를 생성해야 합니다.

벡터 인덱스 생성

MongoDB Atlas 콘솔에서 Search Indexes 탭으로 이동해서 JSON Editor로 인덱스를 생성합니다.

{
  "fields": [
    {
      "type": "vector",
      "path": "embedding.vector",
      "numDimensions": 3072,
      "similarity": "cosine"
    },
    {
      "type": "filter",
      "path": "isPublished"
    },
    {
      "type": "filter",
      "path": "language"
    }
  ]
}

몇 가지 설정을 설명하면:

  • numDimensions: 3072 - Google Gemini embedding-001 모델이 생성하는 벡터 차원
  • similarity: cosine - 코사인 유사도 사용 (텍스트 임베딩에 일반적)
  • filter 필드 - $vectorSearch에서 필터링할 필드들을 미리 지정

$vectorSearch의 필터 제한사항

여기서 중요한 점이 있습니다. $vectorSearch에서 필터를 사용하려면 반드시 인덱스에 해당 필드를 filter로 등록해야 합니다. 등록하지 않은 필드로 필터링하면 에러가 발생합니다.

// ✅ 인덱스에 등록된 필드로 필터링 - 동작함
{
  $vectorSearch: {
    filter: { isPublished: { $eq: true } }
  }
}

// ❌ 인덱스에 없는 필드로 필터링 - 에러
{
  $vectorSearch: {
    filter: { deletedAt: { $eq: null } }  // filter 인덱스 없음
  }
}

null 값 필터링 문제

구현하면서 가장 골치 아팠던 문제입니다. 소프트 삭제를 위해 deletedAt 필드를 사용하는데, 삭제되지 않은 문서는 이 필드가 null이거나 아예 존재하지 않습니다.

처음에는 이렇게 시도했습니다:

// 시도 1: deletedAt을 filter 인덱스에 추가하고 필터링
{
  $vectorSearch: {
    filter: {
      isPublished: { $eq: true },
      deletedAt: { $eq: null }  // null 비교
    }
  }
}

인덱스에 deletedAt을 추가했는데도 검색이 제대로 안 됐습니다. 알고 보니 MongoDB Atlas의 $vectorSearch는 필드가 존재하지 않는 문서를 { $eq: null }로 매칭하지 못합니다.

일반 쿼리에서는 deletedAt: null이 "필드가 null이거나 존재하지 않는 문서"를 모두 찾아주는데, $vectorSearch에서는 다르게 동작하는 것입니다.

해결 방법: $match 단계로 분리

해결책은 간단했습니다. null 필터링을 $vectorSearch가 아닌 $match 단계에서 처리하면 됩니다.

const results = await this.blogPostModel.aggregate([
  {
    $vectorSearch: {
      index: 'vector_index',
      path: 'embedding.vector',
      queryVector: queryEmbedding,
      numCandidates: 100,
      limit: 30,
      filter: {
        isPublished: { $eq: true },  // boolean 필터는 $vectorSearch에서
      },
    },
  },
  {
    $match: {
      deletedAt: null,  // null 필터는 $match에서 처리
    },
  },
  // ... 나머지 파이프라인
]);

$vectorSearch에서는 확실하게 동작하는 필터(boolean, string 등)만 사용하고, null 비교는 후속 $match 단계에서 처리합니다. 성능상 약간의 오버헤드가 있을 수 있지만, 블로그 규모에서는 체감하기 어려운 수준입니다.

NestJS에서 임베딩 서비스 구현

이제 NestJS에서 실제 하이브리드 검색을 구현합니다.

임베딩 생성

먼저 검색 쿼리를 벡터로 변환하는 함수입니다:

import { GoogleGenerativeAI } from '@google/generative-ai';

@Injectable()
export class EmbeddingService {
  private genAI: GoogleGenerativeAI;
  private readonly MODEL_NAME = 'gemini-embedding-001';

  constructor() {
    this.genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
  }

  async generateEmbedding(text: string): Promise<number[]> {
    const model = this.genAI.getGenerativeModel({ model: this.MODEL_NAME });
    const result = await model.embedContent(text);
    return result.embedding.values;
  }
}

하이브리드 검색 구현

벡터 검색과 키워드 검색을 동시에 수행하고 RRF로 결합합니다:

async hybridSearch(
  query: string,
  language: SupportedLanguage = 'ko',
  limit: number = 10,
  vectorWeight: number = 0.7,
  keywordWeight: number = 0.3,
): Promise<HybridSearchResponse> {
  const startTime = Date.now();

  // 1. 쿼리 임베딩 생성
  const queryVector = await this.generateEmbedding(query);

  // 2. 벡터 검색 실행
  const vectorResults = await this.blogPostModel.aggregate([
    {
      $vectorSearch: {
        index: 'vector_index',
        path: 'embedding.vector',
        queryVector,
        numCandidates: 100,
        limit: 30,
        filter: {
          isPublished: { $eq: true },
        },
      },
    },
    { $match: { deletedAt: null } },
    {
      $project: {
        slug: 1, title: 1, excerpt: 1, tags: 1, thumbnail: 1,
        score: { $meta: 'vectorSearchScore' },
      },
    },
  ]);

  // 3. 키워드 검색 실행
  const keywordResults = await this.keywordSearch(query, language);

  // 4. RRF로 결과 결합
  const combined = this.combineWithRRF(
    vectorResults, keywordResults,
    vectorWeight, keywordWeight,
  );

  return {
    results: combined.slice(0, limit),
    query,
    totalResults: combined.length,
    searchType: this.determineSearchType(vectorResults, keywordResults),
    durationMs: Date.now() - startTime,
  };
}

RRF 알고리즘 구현

Reciprocal Rank Fusion은 각 검색 결과의 순위를 기반으로 점수를 계산합니다:

private combineWithRRF(
  vectorResults: SearchResult[],
  keywordResults: SearchResult[],
  vectorWeight: number,
  keywordWeight: number,
): CombinedResult[] {
  const k = 60; // RRF 상수
  const scoreMap = new Map<string, CombinedResult>();

  // 벡터 검색 결과 처리
  vectorResults.forEach((result, index) => {
    const rrfScore = 1 / (k + index + 1);
    scoreMap.set(result.slug, {
      ...result,
      vectorScore: result.score,
      keywordScore: 0,
      combinedScore: vectorWeight * rrfScore,
    });
  });

  // 키워드 검색 결과 병합
  keywordResults.forEach((result, index) => {
    const rrfScore = 1 / (k + index + 1);
    const existing = scoreMap.get(result.slug);

    if (existing) {
      existing.keywordScore = result.score;
      existing.combinedScore += keywordWeight * rrfScore;
    } else {
      scoreMap.set(result.slug, {
        ...result,
        vectorScore: 0,
        keywordScore: result.score,
        combinedScore: keywordWeight * rrfScore,
      });
    }
  });

  // 최종 점수 기준 정렬
  return Array.from(scoreMap.values())
    .sort((a, b) => b.combinedScore - a.combinedScore);
}

유사도 임계값 튜닝

벡터 검색 결과를 그대로 사용하면 관련 없는 문서도 포함될 수 있습니다. 유사도 점수가 너무 낮은 결과는 필터링해야 합니다.

여러 값을 테스트해봤습니다:

  • 0.3 - 너무 느슨함. 관련 없는 글도 많이 포함
  • 0.5 - 중간. 일부 노이즈 존재
  • 0.7 - 적당함. 관련성 높은 결과만 포함

최종적으로 0.7을 선택했습니다. 벡터 검색 점수가 0.7 미만이면서 키워드 매칭도 없는 경우 결과에서 제외합니다:

// 임계값 필터링
const filtered = combined.filter(result => {
  // 벡터 점수가 임계값 이상이거나 키워드 매칭이 있으면 포함
  if (result.vectorScore >= 0.7) return true;
  if (result.keywordScore > 0) return true;
  return false;
});

검색 로그 저장

검색 품질 분석을 위해 로그를 저장합니다. 어떤 검색어가 많이 사용되는지, 검색 결과가 몇 개나 나오는지 파악하면 검색 기능 개선에 도움이 됩니다.

// search-log.schema.ts
@Schema({ collection: 'search_logs', timestamps: true })
export class SearchLog {
  @Prop({ required: true, maxlength: 500 })
  query: string;

  @Prop({ required: true, enum: ['hybrid', 'vector', 'keyword'] })
  searchType: string;

  @Prop({ required: true, min: 0 })
  resultCount: number;

  @Prop({ required: true, min: 0 })
  durationMs: number;

  @Prop({ default: 'ko' })
  language: string;

  @Prop()
  ip?: string;

  @Prop()
  userAgent?: string;

  @Prop({ type: Number })
  topScore?: number;

  @Prop({ type: [String], default: [] })
  topResultSlugs: string[];
}

검색 완료 후 비동기로 로그를 저장합니다. 로그 저장이 검색 응답 속도에 영향을 주지 않도록 await 없이 처리합니다:

// 검색 완료 후 로그 저장 (비동기, 응답 지연 방지)
this.saveSearchLog({
  query,
  searchType,
  resultCount: combined.length,
  durationMs: Date.now() - startTime,
  language,
  ip: logOptions?.ip,
  userAgent: logOptions?.userAgent,
  topScore: combined[0]?.combinedScore,
  topResultSlugs: combined.slice(0, 5).map(r => r.slug),
}).catch(err => {
  this.logger.warn(`Failed to save search log: ${err}`);
});

다음 글에서는

이번 글에서는 MongoDB Atlas Vector Search 설정과 NestJS 백엔드 구현을 다뤘습니다. 다음 글에서는 프론트엔드와 운영 측면을 다룹니다.

  • React 검색 모달 UI 구현
  • 디바운스 처리와 키보드 네비게이션
  • Rate limiting (분당 5회 제한)
  • 429 에러 핸들링과 사용자 친화적 메시지

프론트엔드에서 검색 경험을 어떻게 개선했는지, 그리고 API 남용을 방지하기 위한 rate limiting 설정까지 공유하겠습니다.

MongoDB AtlasVector SearchNestJSGemini EmbeddingsRRF 알고리즘임베딩

관련 글

개인 블로그에 AI 검색 달기 (1) - 왜 하이브리드 검색인가

블로그 검색 기능을 개선하면서 키워드 검색의 한계를 느꼈습니다. 벡터 검색과 키워드 검색을 결합한 하이브리드 검색을 선택한 이유와 아키텍처 설계 과정을 공유합니다.

관련도 97%

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

Gemini Embedding API와 코사인 유사도를 활용해 블로그에 관련 글 추천 시스템을 구축한 경험을 공유합니다. 태그 기반의 단순한 방식에서 벗어나, 글의 실제 내용을 분석해 더 정확한 관련 글을 추천하는 방법을 소개합니다.

관련도 91%

내가 Next.js ISR을 선택한 이유: 블로그 SEO, 그 고민의 시작과 해결

Next.js ISR을 선택하여 블로그 SEO 문제를 해결하는 방법을 알아보세요. React CSR의 한계를 극복하고, 검색 엔진 최적화와 소셜 미리보기를 완벽 지원하는 ISR의 핵심 원리를 소개합니다.

관련도 86%