OpenSearch는 문서를 JSON으로 저장하고, 글 안의 낱말로 비슷한 문서를 찾아 점수 순으로 돌려주는 검색 엔진이다. HTTP로 JSON을 주고받으며 쓰고, 실제 색인과 점수 계산은 안에 든 검색 라이브러리 Lucene이 한다. MySQL 같은 관계형 DB를 대신하기보다는, 원본은 DB에 두고 검색용 사본을 OpenSearch에 두는 식으로 함께 쓴다.
MySQL과의 차이
| MySQL | OpenSearch | |
|---|---|---|
| 잘하는 일 | 정확한 조건으로 꺼내기 | 낱말로 비슷한 것 찾기 |
| 결과 | 조건에 맞는 행 전부 | 점수 순 상위 N개 |
| 담는 통 | 테이블 | 인덱스 |
| 한 건 | 행 | 문서(JSON) |
| 구조 정의 | 스키마 | 매핑(mapping) |
| 쓴 직후 | 커밋하면 바로 조회됨 | 약 1초 뒤 검색됨 |
MySQL은 WHERE id = 3, WHERE status = 'DONE'처럼 맞는지 아닌지가 분명한 조건에 강하다. '배송이 늦었다는 불만과 비슷한 문의'처럼 얼마나 비슷한지를 따져 순위를 매기는 일은 OpenSearch가 맡는다.
역색인
OpenSearch는 문서를 넣을 때 낱말마다 그 낱말이 어느 문서에 있는지 목록을 미리 만들어 둔다. 이 구조를 역색인(inverted index)이라고 한다. 책 뒤의 '찾아보기'와 같다.
환불 → 문서 1, 문서 4
배송 → 문서 2, 문서 4
요청 → 문서 1MySQL의 LIKE '%환불%'은 모든 행을 처음부터 읽어 문자열을 비교하고, 얼마나 관련 있는지 점수도 없다. 역색인은 검색어의 낱말로 목록을 바로 찾고, 그 낱말이 문서에 몇 번, 얼마나 드물게 나오는지로 점수(BM25)를 매긴다. BM25는 BM25, 벡터 검색, RRF에서 다룬다.
역색인을 만들려면 문장을 낱말로 나눠야 한다. 한국어는 '환불을', '환불은'에서 조사를 떼어 '환불'로 맞춰야 하므로 한국어 형태소 분석기가 필요하다. nori 한국어 분석기에 정리했다.
Lucene, Elasticsearch, OpenSearch
- Lucene(1999): Java 검색 라이브러리다. 역색인과 BM25를 실제로 계산하는 부품이지만, Java 코드 안에 넣어서 써야 했다.
- Elasticsearch(2010): Lucene을 HTTP와 JSON으로 쓸 수 있게 감싸고, 데이터를 여러 서버에 나눠 담을 수 있게 했다.
- OpenSearch(2021): Elasticsearch가 라이선스를 바꾸자, AWS가 바꾸기 직전 오픈소스 버전(7.10)을 가져와 이어 만든 프로젝트다.
출발점이 같아서 쓰는 방법이 거의 같고, Elasticsearch 자료도 대부분 그대로 통한다. OpenSearch 3.9.0에는 Lucene 10.5.1이 들어 있다.
'인덱스'의 세 가지 뜻
검색 기능을 만들다 보면 '인덱스'가 세 가지 뜻으로 섞여 쓰인다.
- OpenSearch 인덱스: 문서를 담는 통이다. MySQL의 테이블에 해당한다. 예:
reviews인덱스 - 색인하다(indexing): 문서를 OpenSearch에 넣는 일이다.
- MySQL 인덱스: MySQL 테이블에서 행을 빨리 찾기 위한 B-tree 구조다. 위 둘과는 다른 것이다.
쓴 직후에는 검색되지 않음
OpenSearch는 문서를 받으면 바로 저장하지만, 검색 대상에 넣는 일(refresh)은 기본 1초마다 몰아서 한다. 그래서 넣은 직후에는 검색에 나오지 않는다.
# 문서를 넣고 바로 검색하면 0건
curl -X POST localhost:9200/reviews/_doc/1 -H 'Content-Type: application/json' -d '{"body":"환불 요청"}'
curl localhost:9200/reviews/_search -H 'Content-Type: application/json' -d '{"query":{"match":{"body":"환불"}}}'
# hits.total.value: 0, 2초 뒤에 다시 검색하면 1OpenSearch 3.9.0에서 실행하면 넣은 직후 검색은 0건, 2초 뒤에는 1건이다. id로 직접 가져오는 GET /reviews/_doc/1은 넣은 직후에도 찾는다. 검색은 refresh를 거쳐야 하고, id 조회는 그렇지 않기 때문이다.
바로 검색돼야 하면 넣을 때 ?refresh=true를 붙이거나 POST /reviews/_refresh를 부른다. refresh는 비용이 있는 작업이라, 문서마다 부르기보다 여러 건을 넣은 뒤 한 번 부른다.
색인 동기화 지연
원본을 DB에 두고 검색용 사본을 OpenSearch에 두면, 두 저장소가 잠깐 서로 다른 시간이 생긴다. 이를 색인 동기화 지연(indexing lag)이라고 한다.
- DB에 커밋한 직후에는 OpenSearch에 아직 없다.
- 색인 작업을 주기적으로 몰아서 돌리면, 그 주기만큼 늦어진다.
- 색인할 때 임베딩(문장을 벡터로 바꾸는 외부 API 호출)까지 하면 더 느리고 실패할 수도 있다.
사용자 입장에서는 '방금 저장했는데 검색에 안 나온다'가 되고, 개발자도 '데이터는 있는데 검색이 비어 있다'를 버그로 오해하기 쉽다. 설계상 생기는 지연이므로 숨기지 말고 드러낸다.
- 색인되지 않은 건수를 기록하거나 지표로 남긴다.
- 바로 검색돼야 하는 데이터는 저장 직후 그 자리에서 색인하고, 실패하면 주기 작업이 다시 집어 가게 한다.
- 사용자 화면에는 '반영까지 몇 초 걸릴 수 있음'처럼 알린다.
DB에 '아직 색인하지 않음'을 표시하는 칸(예: indexed_at이 비어 있음)을 두면, 주기 작업은 그 칸이 빈 행만 골라 색인하고 시각을 채우면 된다. 색인이 실패한 행은 칸이 계속 비어 있어 다음 주기에 다시 시도된다.
로컬에서 띄우기
# docker-compose.yml
services:
opensearch:
image: opensearchproject/opensearch:3.9.0 # latest 대신 버전 고정
environment:
discovery.type: single-node # 노드 하나로 뜬다고 알림; 없으면 시작 검사에서 멈춤
DISABLE_SECURITY_PLUGIN: "true" # 로컬 개발용; 비밀번호 없이 HTTP로 접속됨
OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512m # JVM 힙을 512MB로 제한
ports:
- "127.0.0.1:9200:9200" # 내 컴퓨터에서만 접속되게 묶음
volumes:
- opensearch-data:/usr/share/opensearch/data
volumes:
opensearch-data:포트를 "9200:9200"으로 열면 같은 네트워크의 다른 기기에서도 접속된다. 보안 플러그인을 끈 상태라면 누구든 비밀번호 없이 모든 문서를 읽을 수 있으므로, 로컬 개발에서는 127.0.0.1에 묶는다. 배포할 때는 보안 플러그인을 켠다.
공식 문서: OpenSearch, Refresh index