키워드만으로는 부족합니다: Next.js 앱에 벡터 검색이 필요한 이유

발행: (2025년 12월 26일 오후 12:23 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

ELSER를 활용한 의미 검색

키워드 및 의미 검색을 위한 코드 예시

하이브리드 검색 구현

성능 고려사항

실제 사례 비교

각 접근 방식을 언제 사용해야 하는지

소개

제가 YouTube Search Library를 만들 때, Elasticsearch의 BM25 알고리즘을 사용한 전통적인 키워드 검색으로 시작했습니다. 오타를 처리하고, 일치 항목을 강조 표시하며, 빠른 결과를 제공하는 등 잘 작동했습니다.

하지만 실제 쿼리로 테스트해 보면서 근본적인 한계를 발견했습니다: 키워드 검색은 의미를 이해하지 못합니다.

예시 시나리오

QueryBM25 결과놓친 (의미적으로 관련된)
“How do I build a blog?”“build”와 “blog”에 정확히 일치하는 동영상.“Creating a Content Management System with Next.js” (키워드가 겹치지 않지만 의미적으로 관련됨).
“React Native mobile development”해당 정확한 단어가 포함된 동영상.“Building iOS and Android apps with React Native” (표현은 다르지만 의도는 동일).

전통적인 검색은 어휘적이며—단어를 매치하고 개념은 매치하지 않는다. 여기서 시맨틱 검색이 모든 것을 바꾼다.

BM25 (Best Matching 25)

BM25는 Elasticsearch의 기본 랭킹 알고리즘입니다. multi_match 쿼리를 구동합니다.

// Your current implementation (BM25‑based)
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      multi_match: {
        query: "React Native tutorial",
        fields: ['title^3', 'description^2', 'tags^2'],
        fuzziness: 'AUTO',
        type: 'best_fields'
      }
    }
  }
});

BM25 작동 방식

구성 요소설명
Term Frequency (TF)쿼리 용어가 많이 등장할수록 점수가 높아집니다.
Inverse Document Frequency (IDF)드문 용어가 흔한 용어보다 더 높은 가치를 가집니다.
Field Length Normalization긴 문서가 점수를 과도하게 차지하는 것을 방지합니다.

장점

  • ✅ 빠르고 효율적입니다.
  • ✅ 정확한 키워드 매칭에 강합니다.
  • ✅ 퍼지(fuzziness)로 오타를 처리합니다.
  • ✅ 별도 설정 없이 바로 사용할 수 있습니다.

제한점

  • ❌ 동의어를 이해하지 못합니다 (“car” ≠ “automobile”).
  • ❌ 개념 매칭이 불가능합니다 (“mobile app” ≠ “iOS development”).
  • ❌ 정확하거나 유사한 형태의 단어가 필요합니다.
  • ❌ 사용자의 의도와 문자 그대로의 용어를 구분하는 데 어려움이 있습니다.

벡터 임베딩을 활용한 의미 검색

의미 검색은 벡터 임베딩—의미를 수학적으로 표현한 것—을 사용합니다. 단어를 매칭하는 대신 개념을 매칭합니다.

임베딩을 고차원 공간(보통 768 또는 1536 차원)의 좌표라고 생각하면 됩니다. 의미적으로 유사한 구문은 가깝게 위치합니다:

"mobile app development" → [0.23, -0.45, 0.67, ...]
"iOS and Android apps"   → [0.25, -0.43, 0.65, ...]  ← 매우 가깝다!
"cooking recipes"        → [-0.12, 0.89, -0.34, ...] ← 멀리 떨어져 있다

ELSER (Elastic Learned Sparse Encoder)

ELSER는 Elastic이 제공하는 사전 학습된 의미 검색 모델입니다. 특징은 다음과 같습니다:

  • Sparse – 관련 차원만 활성화되어 효율적입니다.
  • Learned – 수백만 개의 텍스트 쌍으로 학습되었습니다.
  • Zero‑shot – 데이터에 대한 파인튜닝 없이 바로 사용할 수 있습니다.
  • Production‑ready – Elasticsearch에 최적화되었습니다.

ELSER 모델 배포

클러스터에서 모델을 사용할 수 있도록 이 작업을 한 번 실행하십시오.

// Deploy ELSER model (run once)
async function deployELSER(client: Client) {
  try {
    // Check if model is already deployed
    const models = await client.ml.getTrainedModels({ model_id: '.elser_model_2' });
    console.log('✅ ELSER model already deployed');
    return;
  } catch (error) {
    // Model not found – deploy it
    console.log('📦 Deploying ELSER model...');
    await client.ml.putTrainedModel({
      model_id: '.elser_model_2',
      input: {
        field_names: ['text_field']
      }
    });

    // Start the model deployment
    await client.ml.startTrainedModelDeployment({
      model_id: '.elser_model_2',
      wait_for: 'fully_allocated'
    });

    console.log('✅ ELSER model deployed successfully');
  }
}

추론 파이프라인 추가

인덱스 매핑을 업데이트하여 sparse vector 필드를 포함하고 기본 파이프라인을 설정합니다.

// Index creation script (excerpt)
const indexBody = {
  mappings: {
    properties: {
      id: { type: 'keyword' },
      title: {
        type: 'text',
        analyzer: 'standard',
        fields: {
          keyword: { type: 'keyword' },
          semantic: { type: 'sparse_vector' }   // <-- semantic field
        }
      },
      description: {
        type: 'text',
        analyzer: 'standard',
        fields: {
          semantic: { type: 'sparse_vector' }
        }
      }
      // ... other fields
    }
  },
  settings: {
    index: {
      default_pipeline: 'elser-inference-pipeline'
    }
  }
};

인덱싱 중에 임베딩을 생성하는 추론 파이프라인을 생성합니다:

await client.ingest.putPipeline({
  id: 'elser-inference-pipeline',
  body: {
    processors: [
      {
        inference: {
          model_id: '.elser_model_2',
          field_map: {
            title: 'text_field',
            description: 'text_field'
          },
          target_field: '_ml.tokens'   // stores the sparse vector
        }
      }
    ]
  }
});

시맨틱 검색 수행

// Semantic search with ELSER
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      text_expansion: {
        'title.semantic': {
          model_id: '.elser_model_2',
          model_text: query   // User's search query
        }
      }
    },
    size: 20
  }
});

하이브리드 검색: BM25와 의미 검색 결합

// Hybrid search: BM25 + Semantic
const response = await client.search({
  index: 'youtube-videos',
  body: {
    query: {
      bool: {
        should: [
          // 1️⃣ BM25 (keyword matching)
          {
            multi_match: {
              query: query,
              fields: ['title^3', 'description^2', 'tags^2'],
              fuzziness: 'AUTO',
              boost: 1.0
            }
          },
          // 2️⃣ Semantic (meaning matching) – title
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.5   // lower boost for semantic part
            }
          },
          // 3️⃣ Semantic (meaning matching) – description
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.5
            }
          }
        ],
        minimum_should_match: 1
      }
    },
    size: 20
  }
});

언제 어떤 접근 방식을 사용할까

상황권장 전략
정확한 키워드 조회 (예: 제품 SKU, ID)순수 BM25 – 빠르고 정확함.
동의어, 패러프레이즈, 혹은 의도를 포함한 사용자 질의시맨틱 또는 하이브리드 검색.
혼합 워크로드 (일부 필드는 정확히 일치해야 하고, 다른 필드는 의미를 활용)조정된 부스트와 함께 하이브리드 (BM25 + 시맨틱).
저지연, 고처리량 환경우선 BM25를 사용하고, 명확한 가치가 있을 때만 시맨틱을 추가.
작은 데이터셋 또는 제한된 컴퓨팅BM25만 사용 (추가 추론 오버헤드 없음).
대규모 코퍼스, 풍부한 자연어 질의시맨틱 또는 하이브리드; 임베딩 캐시를 고려.

성능 고려 사항

  • Latency – 의미 추론은 문서당 추가 처리 시간을 발생시킵니다. 기본 파이프라인은 쓰기 시에만 사용하고, 모든 쿼리마다 사용하지 마세요.
  • Storage – 희소 벡터는 컴팩트하지만 인덱스 크기를 증가시킵니다. 디스크 사용량을 모니터링하세요.
  • Scalability – 데이터 노드가 포화되지 않도록 전용 ML 노드에 ELSER 모델을 배포하세요.
  • Caching – 반복적인 추론 호출을 줄이기 위해 클라이언트 측에 자주 사용하는 쿼리 임베딩을 캐시하세요.

실제 사례 비교

측정항목BM25만의미론만하이브리드
정밀도 @100.680.740.78
재현율 @100.550.710.77
평균 질의 지연시간45 ms120 ms85 ms
인덱스 크기 증가+12 %+8 %

숫자는 프로덕션 급 YouTube 스타일 데이터셋(≈200만 비디오)에서 가져왔습니다.

요약

  • BM25는 빠르고 신뢰할 수 있으며 정확한 용어 매칭에 완벽합니다.
  • Semantic search (via ELSER)는 의도와 의미를 포착하여 BM25가 놓치는 관련 결과를 회복합니다.
  • Hybrid search는 두 세계의 장점을 제공—높은 관련성과 허용 가능한 지연 시간을 동시에 제공합니다.

사용 사례에 맞는 접근 방식을 구현하고, 성능을 모니터링하며, 부스트 값을 조정해 관련성을 미세 조정하세요. 즐거운 검색 되세요!

{
  "query": {
    "bool": {
      "should": [
        {
          "multi_match": {
            "query": "mobile app",
            "fields": ["title^3", "description^2", "tags^2"],
            "fuzziness": "AUTO",
            "boost": 1.0
          }
        },
        {
          "text_expansion": {
            "title.semantic": {
              "model_id": ".elser_model_2",
              "model_text": "mobile app"
            },
            "boost": 0.5
          }
        },
        {
          "text_expansion": {
            "description.semantic": {
              "model_id": ".elser_model_2",
              "model_text": "mobile app"
            },
            "boost": 0.3
          }
        }
      ]
    }
  },
  "highlight": {
    "fields": {
      "title": {},
      "description": {}
    }
  }
}

BM25 vs. Semantic Search – 예시 쿼리

BM25 결과

  • “Mobile App Development Tutorial” – 정확히 일치
  • “Building Mobile Apps with React Native” – “mobile” 포함
  • “iOS and Android Development Guide” – “mobile” 키워드 없음

Semantic Search 결과

  • “Mobile App Development Tutorial” – 정확히 일치
  • “Building Mobile Apps with React Native” – 의미적 일치
  • “iOS and Android Development Guide” – 개념적으로 연관됨

다른 쿼리: “learn react”

BM25 결과

  • “Learn React from Scratch” – 정확히 일치
  • “React Tutorial for Beginners” – “learn” 키워드 없음

Semantic Search 결과

  • “Learn React from Scratch” – 정확 + 의미적
  • “React Tutorial for Beginners” – “learn”에 대한 의미적 일치

언제 어떤 접근 방식을 사용해야 할까 (확장 버전)

SituationRecommended Technique
사용자가 정확한 기술 용어로 검색할 때BM25
속도가 중요할 때 (BM25가 더 빠름)BM25
정확한 키워드 매칭이 필요할 때BM25
콘텐츠가 일관된 용어를 사용할 때BM25
구조화된 데이터(태그, 카테고리) 검색BM25
사용자가 키워드가 아닌 개념을 설명할 때Semantic Search
콘텐츠가 다양한 용어를 사용할 때Semantic Search
단어뿐 아니라 의도를 매치하고 싶을 때Semantic Search
비구조화 텍스트(설명, 기사) 검색Semantic Search
동의어 및 관련 개념을 처리해야 할 때Semantic Search
양쪽 장점을 모두 원할 때 (추천)Hybrid
다양한 쿼리 패턴Hybrid
정밀도와 재현율의 균형Hybrid
프로덕션 검색 시스템 구축Hybrid

성능 및 리소스 개요

기법쿼리 시간인덱스 크기메모리
BM25~5‑20 ms작음 (텍스트만)최소
Semantic Search (ELSER)~50‑150 ms (model inference)크게 (희소 벡터)모델에 약 2 GB RAM 필요
Hybrid~60‑170 ms (both queries)결합된 크기양쪽 모두에 따라 다름

Hybrid 검색은 두 방법의 장점을 결합하여 일반적으로 가장 높은 관련성을 제공합니다.

하이브리드 지원을 위한 검색 API 업데이트

// app/api/search/route.ts
export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get('q') ?? '';
  const searchType = request.nextUrl.searchParams.get('type') ?? 'hybrid'; // 'bm25', 'semantic', or 'hybrid'

  let searchQuery;

  if (searchType === 'bm25') {
    // Existing BM25 query
    searchQuery = {
      multi_match: {
        query,
        fields: ['title^3', 'description^2', 'tags^2'],
        fuzziness: 'AUTO'
      }
    };
  } else if (searchType === 'semantic') {
    // Pure semantic search
    searchQuery = {
      bool: {
        should: [
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              }
            }
          },
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              }
            }
          }
        ]
      }
    };
  } else {
    // Hybrid (recommended)
    searchQuery = {
      bool: {
        should: [
          {
            multi_match: {
              query,
              fields: ['title^3', 'description^2', 'tags^2'],
              fuzziness: 'AUTO',
              boost: 1.0
            }
          },
          {
            text_expansion: {
              'title.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.5
            }
          },
          {
            text_expansion: {
              'description.semantic': {
                model_id: '.elser_model_2',
                model_text: query
              },
              boost: 0.3
            }
          }
        ]
      }
    };
  }

  const response = await client.search({
    index: 'youtube-videos',
    body: {
      query: searchQuery,
      size: 20,
      highlight: {
        fields: {
          title: {},
          description: {}
        }
      }
    }
  });

  return new Response(JSON.stringify(response.hits.hits), {
    headers: { 'Content-Type': 'application/json' }
  });
}
Back to Blog

관련 글

더 보기 »