키워드만으로는 부족합니다: Next.js 앱에 벡터 검색이 필요한 이유
Source: Dev.to
ELSER를 활용한 의미 검색
키워드 및 의미 검색을 위한 코드 예시
하이브리드 검색 구현
성능 고려사항
실제 사례 비교
각 접근 방식을 언제 사용해야 하는지
소개
제가 YouTube Search Library를 만들 때, Elasticsearch의 BM25 알고리즘을 사용한 전통적인 키워드 검색으로 시작했습니다. 오타를 처리하고, 일치 항목을 강조 표시하며, 빠른 결과를 제공하는 등 잘 작동했습니다.
하지만 실제 쿼리로 테스트해 보면서 근본적인 한계를 발견했습니다: 키워드 검색은 의미를 이해하지 못합니다.
예시 시나리오
| Query | BM25 결과 | 놓친 (의미적으로 관련된) |
|---|---|---|
| “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만 | 의미론만 | 하이브리드 |
|---|---|---|---|
| 정밀도 @10 | 0.68 | 0.74 | 0.78 |
| 재현율 @10 | 0.55 | 0.71 | 0.77 |
| 평균 질의 지연시간 | 45 ms | 120 ms | 85 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”에 대한 의미적 일치
언제 어떤 접근 방식을 사용해야 할까 (확장 버전)
| Situation | Recommended 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' }
});
}