검색 품질을 쟀더니 MySQL 이 따라오지 못했다: Lucene, Elasticsearch, OpenSearch 중 앱 안 Lucene 을 고른 이유
목차
오타와 어형 변화가 섞인 검색어에서 nDCG@10이 지금 검색(LIKE) 0.071, MySQL FULLTEXT 0.324, 앱 안 Lucene 0.808, Elasticsearch 0.814, OpenSearch 0.798이었습니다. 측정 전에 적어 둔 기준대로 검색 엔진을 넣었습니다. 세 엔진의 품질 차이는 0.016이라 기준대로 운영 비용으로 골랐고 컨테이너 하나에 약 1GB가 드는 ES·OpenSearch 대신 힙 5MB로 도는 앱 안 Lucene을 기본값으로 했습니다.
결론부터
- 품질 기준에서는 앱 안 Lucene 0.808, Elasticsearch 0.814, OpenSearch 0.798이 모두 MySQL FULLTEXT 0.324보다 높았습니다.
- 세 엔진의 품질 차이는 0.016으로 사전에 정한 범위 안이어서, 별도 프로세스와 약 1GB 메모리가 필요한 엔진 대신 앱 안 Lucene을 골랐습니다.
- 오타 허용은 품질을 올리는 동시에 잘못된 상품을 상위에 올릴 수 있어, 정확한 쿼리와 오타 쿼리를 분리해 평가해야 했습니다.
같은 검색어로 비교하기
검색 엔진을 넣을지 지연으로 먼저 재 봤다에서 지연 축은 답을 냈고 품질 축은 재지 않았다고 적었습니다. 이커머스가 검색 엔진을 들이는 큰 이유는 지연이 아니라 품질(관련도 순위, 오타, 어형 변화)입니다. 이 글은 그 품질 축을 잰 기록입니다. 검색어 경로를 포트 하나 뒤로 옮기고 구현 여섯 개를 같은 API로 비교했습니다.
카탈로그는 H&M 공개 데이터로 만든 상품 105,545개이고 텍스트는 영어입니다. 로컬 단일 장비에서 쟀습니다.
비교 대상과 쿼리셋
| 구현 | 필드 | 순서 |
|---|---|---|
like(지금까지 나가던 것) | 상품명, 브랜드 | 신상품순 |
like-fields | 상품명, 브랜드, 종류, 설명 | 신상품순 |
| MySQL FULLTEXT 자연어 모드 | 상품명, 종류, 설명 | 관련도 |
| Lucene 9.12(앱 안) | 같음 | BM25 |
| Elasticsearch 8.15, OpenSearch 2.19 | 같음 | BM25 |
like-fields는 일부러 넣은 비교 대상입니다. 엔진이 이긴 폭이 “검색하는 필드를 넓힌 효과”에서 왔는지 “매칭 기술”에서 왔는지 가르려면 엔진 없이 필드만 넓힌 경우가 있어야 합니다. 엔진 셋은 필드, 가중치(상품명·종류 2, 설명 1), 오타 허용(3~5자 1글자, 6자 이상 2글자)이 같은 질의를 하게 한 곳에서 정의했습니다. 다르면 엔진을 비교한 것이 아니라 질의 설정을 비교한 것이 됩니다.
쿼리셋은 측정 전에 규칙으로 고정했습니다(seed 7, 545개).
| 묶음 | 만드는 법 | 정답 |
|---|---|---|
| A 종류 그대로 (63) | 상품 종류 이름 소문자 (leggings) | 그 종류의 상품 |
| B 종류 어형 (63) | 단수와 복수를 뒤집기 (blouse→blouses) | 같음 |
| C 종류 오타 (45) | 가운데 인접 두 글자 교환 (bluose) | 같음 |
| D 이름 그대로 (200) | 같은 이름의 상품이 2개 이상인 이름 | 이름이 같은 상품 |
| E 이름 오타 (174) | D에 C 규칙 | 같음 |
정답이 사람 판정이 아니라 속성 일치라는 한계가 있습니다. 대신 누구나 같은 규칙으로 다시 만들 수 있습니다.
판정 기준도 이슈에 먼저 적었습니다.
- 가장 나은 엔진의 오타·어형 점수(B·C·E 평균)가 가장 나은 MySQL 안보다 0.10 이상 높고 동시성 10 p95가 100ms 이하이고 엔진이 죽어도 검색이 200으로 물러서면 엔진을 넣는다.
- 1이 아니고 MySQL 안이 A에서 0.10 이상 나으면 엔진 없이 MySQL 쪽을 바꾼다.
- 엔진 셋 사이는 품질 차이가 0.02 안이면 운영 비용으로 고른다.
필드만 넓혀도 얻는 몫이 컸다
1차 결과입니다.
| 구현 | A | B | C | D | E | 오타·어형 | 동시성 10 p95 |
|---|---|---|---|---|---|---|---|
like | 0.450 | 0.209 | 0.005 | 0.921 | 0.000 | 0.071 | 1,471ms |
like-fields | 0.727 | 0.427 | 0.005 | 0.921 | 0.000 | 0.144 | 848ms |
| FULLTEXT | 0.852 | 0.500 | 0.169 | 0.808 | 0.304 | 0.324 | 205ms |
| Lucene | 0.717 | 0.701 | 0.716 | 0.994 | 0.966 | 0.794 | 37ms |
| Elasticsearch | 0.724 | 0.708 | 0.726 | 0.996 | 0.968 | 0.801 | 42ms |
| OpenSearch | 0.727 | 0.711 | 0.722 | 0.996 | 0.968 | 0.800 | 48ms |
필드를 넓히는 것만으로 종류 쿼리(A)가 0.450에서 0.727이 됐습니다. 상품명이 Cambell chino, OL Freddy 같은 내부 이름이라서 상품명만 보는 검색은 “leggings”로 찾아도 레깅스를 거의 못 찾습니다. 엔진 없이도 얻을 수 있었던 몫입니다.
오타는 다른 문제였습니다. LIKE는 필드를 넓혀도 오타 점수가 0이었습니다. FULLTEXT의 C 0.169와 E 0.304도 오타를 고쳐 찾은 것이 아니라 biikni top의 top처럼 틀리지 않은 다른 단어가 맞아서 나온 점수입니다. 지연도 문제였습니다. LIKE는 결과 개수를 세는 COUNT까지 인덱스를 못 타서 동시성 10에서 p95가 1.5초였습니다.
오타를 허용하니 blazer 가 lazer 에 밀렸다
엔진은 오타·어형에서 크게 이겼지만 정확한 종류 쿼리(A)에서는 FULLTEXT보다 낮았습니다(0.72 대 0.85). 쿼리별로 가장 크게 진 것을 봤더니 blazer가 FULLTEXT 1.00, Lucene과 ES 0.00이었습니다. 상위 10개가 전부 LAZER RAZER라는 이름의 수영복이었습니다.
blazer는 6글자라 두 글자까지 틀려도 맞는 것으로 봅니다. lazer와 razer가 걸렸고 이 두 단어는 카탈로그에 드물어서 BM25의 IDF(드문 단어 가중치)가 높습니다. 드문 비슷한 말이 흔한 정확한 말보다 위로 간 것입니다. ES와 Lucene이 똑같이 틀렸으니 엔진이 아니라 질의 설계의 문제였습니다.
정확 일치 절을 3배 가중치로 먼저 세우고 오타 허용 절은 정확히 맞는 말이 없을 때 받치는 자리로 내렸습니다. 판정 기준은 바꾸지 않고 같은 쿼리셋으로 엔진 셋을 다시 쟀습니다.
| 구현 | A | B | C | D | E | 오타·어형 | 동시성 10 p95 |
|---|---|---|---|---|---|---|---|
| Lucene | 0.771 | 0.755 | 0.733 | 0.994 | 0.937 | 0.808 | 30ms |
| Elasticsearch | 0.782 | 0.766 | 0.737 | 0.996 | 0.940 | 0.814 | 47ms |
| OpenSearch | 0.757 | 0.742 | 0.718 | 0.993 | 0.934 | 0.798 | 53ms |
A·B·C가 올랐고 이름 오타(E)는 0.03 내려갔습니다. 정확 일치 절이 이름의 다른 단어를 정확히 맞힌 상품을 올린 것으로 보이지만 쿼리별로 확인하지는 않았습니다. A는 여전히 FULLTEXT(0.852)보다 낮습니다. 오타 허용 절이 남아 있는 한 정확한 쿼리에도 비슷한 말이 섞이기 때문입니다.
셋 중 무엇을 고르나
기준 1은 충족했습니다. 가장 나은 엔진이 MySQL 최선보다 0.49 높았고 p95는 30~53ms였습니다. ES와 OpenSearch 컨테이너를 멈추자 검색은 200으로 답하며 물러선 횟수 지표가 1 올랐습니다. 엔진 셋의 품질 차이는 0.016이라 기준 3에 따라 운영 비용으로 골랐습니다.
| 앱 안 Lucene | Elasticsearch · OpenSearch | |
|---|---|---|
| 추가 프로세스 | 없음 | 하나(컨테이너 약 1GB) |
| 색인 10만 건 | 1.4초, 힙 5MB | 3.5초, 디스크 11.6MB |
| 여러 인스턴스 | 인스턴스마다 색인 | 공유 |
| 변경 반영 | 다음 갱신까지 | 다시 넣는 즉시 |
10만 건, 5MB에서 프로세스 하나와 1GB를 더 운영할 이유가 없었습니다. 인스턴스마다 1.4초씩 색인을 만드는 비용이 그보다 쌉니다.
ES·OpenSearch를 쓰면 검색 서버 하나가 느는 것으로 끝나지 않습니다. 상품이 바뀔 때마다 검색 서버에 반영하는 파이프라인이 있어야 합니다. 이 저장소의 카탈로그는 앱 밖에서 적재되어 변경 이벤트가 없습니다. 검색 서버가 죽었을 때의 폴백도 따로 운영해야 합니다. 앱 안 Lucene은 10분마다 DB에서 통째로 다시 만드는 것으로 그 둘을 대신합니다.
거꾸로 말하면 옮길 조건도 분명합니다. 상품이 수백만 개로 늘어 색인이 앱 메모리에 부담이 될 때, 가격·재고·신상품이 검색에 즉시 반영돼야 할 때(이미 있는 CDC 경로로 색인 갱신을 이을 수 있습니다), 여러 서비스가 같은 색인을 봐야 할 때입니다. 코드에는 ES·OpenSearch 구현이 남아 있어 설정 한 줄로 바꿀 수 있습니다. 둘 사이에서는 라이선스(OpenSearch는 Apache 2.0)와 관리형 서비스 여부가 갈립니다. 다만 아래에 적은 대로 같은 질의에도 결과가 다르므로 바꿀 때 다시 재야 합니다.
한 가지는 예상과 달랐습니다. 같은 Lucene 위에서 같은 BM25로 같은 질의를 했는데 ES와 OpenSearch의 상위 10개 겹침이 1차 0.911, 2차 0.753이었습니다. 평균 품질은 같아도 목록은 4분의 1 가까이 다릅니다. OpenSearch는 ES 7.10에서 갈라져 나왔으므로 점수 결합이나 퍼지 확장의 기본값이 달라졌을 수 있지만 원인은 확인하지 못했습니다. 나중에 엔진을 바꿀 때 같은 쿼리셋으로 다시 재야 하는 이유입니다.
붙이면서 정한 것
- 엔진은 상품 id와 순서만 줍니다. 가격과 재고는 DB에서 채웁니다. 색인이 낡아도 화면 가격은 낡지 않고 낡을 수 있는 것은 “무엇이 검색에 걸리는가”뿐입니다.
- 정렬을 고르면 후보 안에서 정렬합니다. 가격순을 고르면 관련도 상위 500개를 받아 그 안에서 DB가 정렬합니다. 지금까지 나가던 LIKE는 일치하는 전체 안에서 정렬하던 동작을 그대로 둡니다.
- 색인은 10분마다 인스턴스 안에서 다시 만들어 갈아 끼웁니다. 카탈로그가 앱 밖 파이프라인에서 적재되므로 앱은 변경을 알 수 없습니다. 배치 스케줄러는 워커 인스턴스 하나만 돌리므로 쓰지 않고 인스턴스마다 자기 색인을 갈게 했습니다. 갈아 끼운 옛 색인은 30초 뒤에 닫아 그 순간 돌던 검색이 닫힌 색인을 읽지 않게 했습니다.
- 엔진이 실패하면 필드를 넓힌 LIKE로 물러서고 지표를 올립니다. 결과가 나오니 아무도 엔진이 죽은 줄 모르는 조용한 강등을 막으려는 것입니다.
이 측정이 말하지 않는 것
- 정답이 속성 일치입니다. 사람이 판정한 관련도와 다를 수 있습니다.
- 동의어(
sneakers와trainers)와 한국어 쿼리는 재지 않았습니다. - 새 상품이 검색에 보이기까지 최대 10분이 걸립니다. 즉시 반영이 필요해지면 ES·OpenSearch로 옮기고 색인 갱신을 CDC로 이어야 합니다.
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.