Elasticsearch를 이용해 도서 검색 기능을 만들면서 처음부터 벡터 검색만 사용한 것은 아니다.
실제로 서비스를 만들다 보니 사용자가 입력하는 검색어에는 성격이 전혀 다른 것들이 섞여 있었다.
예를 들어 어떤 사용자는 책 제목을 거의 정확하게 알고 검색한다.
채식주의자
처럼 제목을 그대로 입력할 수도 있고,
한강 채식주의자
처럼 제목과 저자를 섞어서 입력할 수도 있다.
반면 어떤 사용자는 책 제목을 전혀 모른다.
직장 생활이 너무 지쳤을 때 읽을 책
초등학생이 우주에 관심을 가질 만한 책
주식 처음 시작하는 사람이 읽기 어렵지 않은 책
처럼 자신이 원하는 상황을 문장으로 입력한다.
도서 검색 서비스를 만들면서 어려웠던 부분이 바로 이것이었다.
정확한 단어를 찾는 검색과 의미가 비슷한 책을 찾는 검색을 하나의 방식으로 처리하기가 생각보다 쉽지 않았다.
그래서 내가 만든 도서 검색 API에서는 Elasticsearch의 텍스트 검색과 벡터 기반 KNN 검색을 비교해서 사용할 수 있도록 검색 방식을 나눴다.
이 글에서는 Elasticsearch의 기능을 일반적으로 설명하기보다는 실제 도서 검색 기능을 만들면서 왜 두 방식을 모두 사용하게 되었고, 검색 쿼리를 어떤 기준으로 구성했는지를 기록해보려고 한다.
검색 대상은 단순히 책 제목 하나가 아니었다
검색할 데이터에는 제목만 있는 것이 아니었다.
실제 검색에 활용할 수 있는 정보는 대략 다음과 같이 나뉘었다.
title
author
keywords
description
isbn13
libCode
embedding
여기에서 특히 검색에 중요하게 사용한 것은 title, keywords, description이었다.
그리고 의미 기반 검색을 위해 별도로 생성한 벡터를 embedding 필드에 저장했다.
문제는 이 필드들의 역할이 서로 다르다는 것이다.
책 제목을 정확하게 입력한 사용자에게는 title이 굉장히 강한 신호다.
반대로,
사람 관계 때문에 스트레스 받을 때 읽을 책
처럼 제목에는 존재하지 않을 가능성이 높은 문장을 입력한다면 제목만 비교해서는 원하는 결과를 찾기 어렵다.
이 경우에는 책의 설명이나 키워드, 더 나아가 문장의 의미 자체를 이용하는 검색이 필요해진다.
Elasticsearch도 현재 검색 방식을 full-text, vector, hybrid 등으로 구분하고 있으며, hybrid search는 텍스트 기반 검색과 의미 기반 검색을 함께 활용할 필요가 있을 때 사용하는 접근이다.
텍스트 검색에서는 title을 다른 필드보다 강하게 봤다
텍스트 검색을 구성할 때 모든 필드를 같은 비중으로 처리하지 않았다.
실제 검색 설정에서는 다음과 같은 형태로 필드 중요도를 줬다.
title^2
keywords
description
title^2로 설정한 이유는 단순했다.
사용자가 실제 책 제목을 입력했을 경우에는 책 설명에 같은 단어가 여러 번 등장하는 문서보다 제목 자체가 맞는 책을 위에 보여주는 것이 자연스럽다고 판단했기 때문이다.
예를 들어 검색어가:
아몬드
라고 들어왔다고 생각해보자.
어떤 책의 description 안에 우연히 ‘아몬드’라는 단어가 포함되어 있는 것보다 제목이 실제로 ‘아몬드’인 책을 우선하는 것이 일반적인 도서 검색에서는 더 자연스럽다.
그래서 제목에는 상대적으로 높은 가중치를 주고, keywords와 description은 검색 범위를 보완하는 역할로 사용했다.
내가 사용한 텍스트 검색 설정의 핵심을 단순화하면 다음과 같다.
query: 사용자가 입력한 검색어
fields:
- title^2
- keywords
- description
operator:
- or
prefix_length:
- 2
operator=or를 사용한 것도 검색어에 포함된 모든 단어가 반드시 문서에 존재해야만 검색되는 상황을 피하고 싶었기 때문이다.
사용자가 검색창에 입력하는 문장은 데이터베이스에 저장된 문장과 정확하게 같지 않은 경우가 많다.
특히 자연어 검색을 받으려면 입력 문장을 지나치게 엄격하게 제한하는 것보다 어느 정도 후보를 확보한 뒤 관련도를 판단하는 방식이 필요했다.
그런데 텍스트 검색만으로 해결되지 않는 검색어가 있었다
제목이나 키워드를 알고 있는 검색에서는 텍스트 검색이 상당히 직관적이다.
문제는 사용자가 책의 정보를 모르는 상태에서 자신의 요구를 설명할 때다.
예를 들어:
회사에서 사람 때문에 스트레스 받을 때 읽을 책
이라는 검색어를 생각해볼 수 있다.
좋은 책의 제목이나 설명에 반드시 다음 단어가 그대로 들어간다는 보장은 없다.
회사
사람
스트레스
실제로 책의 내용은 인간관계, 감정 조절, 번아웃, 직장생활 등에 관한 것인데 사용자가 입력한 표현과 책 메타데이터의 표현이 다를 수 있다.
이런 검색에서는 단어가 동일한지뿐 아니라 사용자 문장과 책 정보의 의미가 얼마나 가까운지를 이용하고 싶었다.
그래서 별도로 KNN 검색을 적용했다.
KNN에서는 검색어도 embedding으로 바꿨다
도서 데이터에는 의미 검색을 위해 embedding 값을 저장해두었다.
검색 과정에서는 사용자의 검색어도 동일한 임베딩 방식으로 벡터화한 뒤 Elasticsearch의 도서 벡터와 비교한다.
흐름을 단순하게 표현하면 다음과 같다.
사용자 검색어
"직장 생활이 너무 힘들 때 읽을 책"
↓
Embedding 생성
↓
Query Vector
↓
Elasticsearch KNN Search
↓
의미적으로 가까운 도서 후보
이 방식의 장점은 검색어와 책 정보가 정확하게 같은 단어를 사용하지 않아도 벡터 공간에서 의미가 가까운 문서를 찾을 수 있다는 것이다.
Elasticsearch의 kNN 검색도 query vector와 저장된 document vector의 유사성을 기준으로 가까운 문서를 검색하는 구조다.
하지만 실제 서비스에 적용하고 보니 벡터 검색도 이것 하나만 사용하면 모든 문제가 해결되는 것은 아니었다.
의미가 비슷하다는 것과 사용자가 찾는 책이라는 것은 같지 않았다
벡터 검색을 처음 접하면 의미가 비슷한 문서를 찾아주는 기능이 텍스트 검색보다 훨씬 발전된 검색처럼 느껴질 수 있다.
하지만 실제 검색 서비스에서는 그렇게 단순하지 않았다.
예를 들어 사용자가 책 제목을 상당히 정확하게 입력했다면 굳이 의미적으로 비슷한 다른 책을 먼저 보여줄 이유가 없다.
검색어:
데미안
이라면 사용자가 원하는 것이 헤르만 헤세의 『데미안』일 가능성이 높다.
이때 검색 시스템이 ‘자아 탐색’, ‘성장’, ‘내면세계’라는 의미가 비슷하다는 이유로 다른 소설을 먼저 보여준다면 검색 기술 자체는 의미를 잘 이해했을지 몰라도 사용자가 기대한 검색 결과는 아니다.
반대 상황도 있다.
검색어:
자존감이 너무 낮아졌을 때 읽을 책
이라면 단순 제목 일치보다 의미 검색의 가치가 커진다.
결국 내가 필요했던 것은:
Keyword Search가 좋다
또는
Vector Search가 좋다
라는 하나의 결론이 아니었다.
사용자가 어떤 종류의 검색어를 입력했는지에 따라 두 검색 방식이 가지고 있는 장점이 달랐다.
Elastic에서도 full-text search는 정확한 단어나 구문이 중요한 경우에 적합하고, vector/semantic search는 일상적인 표현이나 동의어처럼 정확한 단어 일치만으로 찾기 어려운 경우에 활용할 수 있다고 설명한다.
도서관 검색에서는 libCode 필터가 특히 중요했다
내가 만들던 검색 기능에는 일반적인 인터넷 서점 검색과 다른 조건이 하나 있었다.
단순히 관련성 높은 책을 찾는 것에서 끝나는 것이 아니라 특정 도서관에 존재하는 책을 대상으로 검색해야 했다.
도서 데이터에는 도서관을 구분하기 위한 libCode가 있었다.
따라서 검색 흐름은 단순히:
의미적으로 가장 가까운 책 찾기
로 끝나면 안 됐다.
실제로 필요한 것은:
사용자가 선택한 도서관의 책 중에서
+
검색어와 관련성이 높은 책 찾기
였다.
KNN 검색에서도 이 조건을 적용하기 위해 libCode를 필터에 사용했다.
구조를 단순화하면 다음과 같다.
{
"knn": {
"field": "embedding",
"query_vector": "...",
"k": "...",
"num_candidates": "...",
"filter": {
"terms": {
"libCode": ["..."]
}
}
}
}
실제 서비스 관점에서는 이 필터가 검색 알고리즘만큼 중요했다.
사용자에게 의미적으로 완벽하게 맞는 책을 추천했더라도 사용자가 검색하고 있는 도서관에는 없는 책이라면 검색 결과로서의 가치가 떨어지기 때문이다.
Elasticsearch의 kNN 검색에서는 filter를 이용해 조건에 맞는 문서를 대상으로 벡터 검색을 제한할 수 있다.
k와 num_candidates를 같은 값으로 두지 않았다
KNN을 적용하면서 신경 쓴 설정이 k와 num_candidates였다.
내 검색 코드에서는 사용자가 요청한 k를 그대로 내부 후보 크기로 사용하지 않고 약간의 여유를 뒀다.
사용한 방식은 다음과 같은 형태였다.
k = 요청한 k + 10
num_candidates = 요청한 k + 20
예를 들어 외부 API에서 필요한 결과 수를 기준으로 내부 검색 후보를 조금 더 확보하는 식이었다.
여기에서 중요한 점은 +10, +20이 Elasticsearch KNN 검색의 정답이라는 의미가 아니라는 것이다.
이 값은 당시 내가 사용하던 검색 구조에서 잡아둔 운영 파라미터다.
데이터 규모와 shard 구성, 필터 조건, 응답속도 요구사항이 달라지면 적절한 값도 달라질 수 있다.
Elasticsearch에서 num_candidates는 각 shard에서 근접 이웃을 탐색할 때 확보하는 후보 집합과 관련되고, 그 후보들을 이용해 최종 k 결과를 결정한다. 일반적으로 후보 범위를 어떻게 잡느냐는 검색 품질과 검색 비용 사이의 조정 요소가 된다.
이 부분은 앞으로 별도의 글에서 실제 검색어 세트를 만들어 k와 num_candidates를 변경해가며 비교할 생각이다.
지금 단계에서 테스트하지 않은 값을 가지고 “이 값이 가장 빠르다”거나 “정확도가 몇 퍼센트 증가했다”고 적지는 않으려고 한다.
검색 API에 Hybrid와 KNN 모드를 따로 둔 이유
실제 API에서는 검색 방식을 바꿔볼 수 있도록 mode를 두었다.
형태는 다음과 같았다.
mode=hybrid
mode=knn
이 구조를 유지한 이유는 개발 과정에서 꽤 유용했다.
검색 결과가 이상하게 느껴졌을 때 결과만 보고 있으면 왜 이런 책이 올라왔는지 판단하기 어렵다.
하지만 같은 검색어를 서로 다른 방식으로 실행해보면 문제의 위치를 훨씬 좁히기 쉬웠다.
예를 들어 특정 검색어에서:
Hybrid 결과는 괜찮은데
KNN 결과가 이상하다
면 embedding이나 vector search 쪽을 먼저 볼 수 있다.
반대로:
KNN 결과에서는 원하는 책이 나오는데
텍스트 신호를 포함한 결과에서는 뒤로 밀린다
면 필드 가중치나 검색 쿼리 구성을 다시 볼 수 있다.
즉 두 모드를 둔 것은 단순히 사용자에게 검색 옵션 두 개를 제공하기 위한 목적만이 아니라 검색 품질을 분석하는 과정에서도 유용한 비교 기준이 됐다.
내가 검색 품질을 볼 때 가장 먼저 확인하는 것
검색 서비스를 만들기 전에는 검색 결과가 이상하면 Elasticsearch Query부터 바꿔야 한다고 생각하기 쉬웠다.
그런데 실제로 검색 기능을 만들면서는 다음 순서로 보는 편이 더 낫다고 느꼈다.
먼저 사용자가 무엇을 입력했는지 본다.
그다음 내가 기대하는 정상적인 결과가 무엇인지 정한다.
그 후에 실제 Hybrid 결과와 KNN 결과를 비교한다.
마지막으로 왜 순위가 달라졌는지 필드와 벡터를 살펴본다.
예를 들어:
검색어
↓
내가 기대하는 책
↓
Hybrid 결과
↓
KNN 결과
↓
title / keywords / description 확인
↓
embedding 검색 결과 확인
순서로 보면 단순히 _score 숫자 하나만 보는 것보다 문제를 이해하기 쉬웠다.
결국 검색 품질은 Elasticsearch 설정을 복잡하게 만든다고 자동으로 좋아지는 것이 아니라 어떤 검색어에서 어떤 책이 나와야 하는지를 먼저 정해야 조정할 수 있는 문제였다.
특히 테스트해야 하는 검색어를 세 종류로 나눴다
도서 검색에서는 검색어를 한 종류로만 테스트하면 결과를 판단하기 어렵다.
내가 중요하게 보는 검색 유형은 크게 세 가지다.
제목이나 저자를 알고 검색하는 경우
채식주의자
한강 채식주의자
데미안 헤르만 헤세
이 경우에는 정확한 텍스트 신호가 중요하다.
주제만 알고 검색하는 경우
인공지능 입문 책
주식 초보자 책
아이와 함께 읽을 우주 책
제목 일치뿐 아니라 keywords와 description이 중요해진다.
상황을 문장으로 설명하는 경우
요즘 아무것도 하기 싫을 때 읽을 책
사람 관계 때문에 너무 지쳤을 때 읽을 책
처음 코딩을 공부하는 사람이 어렵지 않게 읽을 책
이런 검색은 벡터 검색의 의미를 확인하기 좋은 유형이다.
앞으로 검색 품질을 더 체계적으로 측정한다면 이 세 종류를 섞어서 테스트 세트를 만들 생각이다.
그래야 특정 검색 방식에 유리한 검색어만 골라놓고 결과가 좋아졌다고 판단하는 일을 줄일 수 있다.
지금은 Hybrid와 KNN 중 하나를 버리지 않고 있다
Elasticsearch 검색을 구성하면서 처음 기대했던 것은 결국 하나의 가장 좋은 검색 방법을 찾는 것이었다.
하지만 실제 도서 검색을 만들면서 생각이 달라졌다.
정확한 책 정보를 알고 검색하는 사용자와 자신이 원하는 상황을 자연어로 설명하는 사용자는 애초에 검색 의도가 다르다.
그래서 지금은:
텍스트 검색 = 오래된 방식
벡터 검색 = 새로운 방식
처럼 생각하지 않는다.
두 방식이 해결하려는 문제가 조금 다르다고 보는 편이 더 맞았다.
Elasticsearch의 현재 공식 문서에서도 hybrid search는 full-text와 vector search를 함께 사용해 정확한 단어 일치와 의미 기반 검색의 장점을 하나의 검색 흐름에서 활용하는 방식으로 설명하고 있다.
내 서비스에서도 같은 이유로 텍스트 필드와 embedding을 모두 유지하고 있다.
앞으로 직접 비교해보고 싶은 부분
현재 설정값을 사용하고 있다고 해서 이것을 최적값이라고 생각하지는 않는다.
오히려 다음 단계에서는 검색어를 고정한 상태에서 파라미터를 변경해 결과가 어떻게 달라지는지 직접 기록하려고 한다.
특히 확인하고 싶은 것은 다음과 같다.
k 변경
num_candidates 변경
title boost 변경
검색어 유형별 결과 차이
필터 적용 전후
상위 검색 결과 순위 변화
이때는 “검색 품질이 좋아진 것 같다”는 식으로 평가하지 않고, 미리 정한 검색어와 기대 도서를 기준으로 결과 순위를 기록할 생각이다.
예를 들어 검색어 20개를 정하고 각 검색어에서 기대하는 도서를 직접 표시한 뒤 Hybrid와 KNN의 TOP 5 결과를 비교하면 지금보다 훨씬 객관적으로 판단할 수 있다.
이 결과는 별도의 글에서 실제 데이터와 함께 정리할 예정이다.
정리하며
도서 검색에 Elasticsearch를 적용하면서 가장 크게 느낀 것은 검색 방식에는 항상 사용자의 검색 의도가 먼저 와야 한다는 점이었다.
제목을 알고 있는 사용자는 정확한 결과를 기대한다.
반대로 어떤 책을 읽어야 할지조차 모르는 사용자는 자신의 상황을 설명하고 그 의미에 맞는 결과를 기대한다.
이 둘을 같은 방식으로만 처리하려고 하면 어느 한쪽에서 불편함이 생길 가능성이 높다.
그래서 현재 도서 검색 구조에서는 title, keywords, description을 이용한 텍스트 검색 신호와 embedding을 이용한 KNN 검색을 모두 활용하고 있다.
텍스트 검색에서는 제목을 상대적으로 중요하게 보고, KNN에서는 특정 도서관의 소장 도서만 검색할 수 있도록 libCode 조건을 함께 적용했다.
KNN의 내부 후보값도 실제 API에서 필요한 결과 수보다 조금 여유 있게 가져가도록 구성했다.
이 설정이 모든 검색 서비스에 맞는 정답이라고 생각하지 않는다.
오히려 이 시스템을 운영하면서 알게 된 것은 검색 파라미터보다 먼저 테스트할 검색어와 기대 결과를 정의하는 것이 중요하다는 것이었다.
앞으로는 실제 검색어 세트를 고정한 뒤 Hybrid, KNN, k, num_candidates를 하나씩 변경하면서 결과가 얼마나 달라지는지 직접 비교해볼 예정이다.
그때는 추측이 아니라 실제 검색 결과 순위를 가지고 다시 기록해보려고 한다.
참고
Elasticsearch의 Hybrid Search는 full-text와 vector 기반 검색을 결합해 하나의 검색 경험을 만드는 방식이며, 최신 Elastic 문서에서는 RRF 등의 방법을 활용한 결합 방식도 안내하고 있다. KNN 검색의 num_candidates는 shard별 근접 후보를 확보하는 과정에 사용된다.