엘라스틱서치에서 주소 검색 하기 (음성인식 버전)
이전에 스코어를 컨트롤하는 방법을 다뤄본적이 있다. 하지만, 실제로 서비스를 만들다보면, 이것만으로는 충분하지 않을떄가 있다. (문서 내의 단어의 빈도를 가지고 검색엔진의 점수를 컨트롤 하는건 생각보다 쉬운인일이 아니다..)
그럴떄 나는 점수를 정규화 하여 스코어를 사용할떄가 있는데, 이 정규화 방법에 대해 알아보자.
차례
- 도입전
- score nomalization
- constant score
- example
1. 도입전
이번 테스트에선 아래와 같은 데이터를 사용한다.
POST _bulk
{ "index" : { "_index" : "poi_example", "_id" : "1" } }
{ "target_poi_name" : "신당동 떡볶이", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "2" } }
{ "target_poi_name" : "우체국", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "3" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "4" } }
{ "target_poi_name" : "떡볶이집", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "5" } }
{ "target_poi_name" : "신당동 떡볶이", "target_dong_name": "중앙동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "6" } }
{ "target_poi_name" : "우체국", "target_dong_name": "중앙동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "7" } }
{ "target_poi_name" : "신당역", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "8" } }
{ "target_poi_name" : "신당초등학교", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "10" } }
{ "target_poi_name" : "엽기 떡볶이", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "11" } }
{ "target_poi_name" : "신당동 태권도", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "12" } }
{ "target_poi_name" : "신당동 태권도", "target_dong_name": "신당동", "like": 0}
target_poi_name 필드에만 nori analyzer를 적용하였고, 나머진 그냥 타입 자동생성을한 필드이다.
위의 document 들중 내가 원하는 장소를 검색했을때, 올바른 결과를 내기 위한 검색 쿼리를 만들어 보도록 하자.
나는 신당동 즉석 떡볶이를 굉장히 좋아하기 때문에, 신당동 떡볶이를 검색한다고 생각해보자,
그럼 굉장히 직관적인 방법으로 아래와 같이 검색을 하면, 내가 원하는 결과를 얻을수 있을것이다.
GET poi_example/_search
{
"query": {
"multi_match": {
"query": "신당동 떡볶이",
"fields": ["target_poi_name", "target_dong_name"]
}
}
}
// response
"hits" : {
"total" : {
"value" : 10,
"relation" : "eq"
},
"max_score" : 10.304594,
"hits" : [
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "1",
"_score" : 10.304594,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "신당동",
"like" : 0
}
},
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "2",
"_score" : 10.304594,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "중앙동",
"like" : 0
}
}
]
}
1차적으론 성공이다. 그런데, 다른동에 대해서도 점수가 같은걸 볼수 있다. bool query를 통해, 각각의 동에대한 점수 반영을 해보도록 하자. (처음 내가 적용했던 방법)
GET poi_example/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"target_poi_name": "신당동 떡볶이"
}
}
],
"should": [
{
"match": {
"target_dong_name": "중앙동"
}
}
]
}
}
}
// response
"hits" : [
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "2",
"_score" : 10.147953,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "중앙동",
"like" : 0
}
}
]
완벽하지 않은가??
근데 내가 매장을 찾는게 아닌, 집에서 먹으려고 집주소를 검색한다 해보자,
그런데 아파트와 같은 명칭을 가진 아파트가 체인점 이름보다 압도적으로 많다. 이때 검색이 우리가 원하는데로 될까?
{ "index" : { "_index" : "poi_example", "_id" : "10" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "신당동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "11" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "서교동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "12" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "합정동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "13" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "서초동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "14" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "중원동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "15" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "중앙동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "16" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "금광동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "17" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "판교동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "18" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "강호동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "19" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "신림동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "20" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "마포동", "like": 0}
{ "index" : { "_index" : "poi_example", "_id" : "21" } }
{ "target_poi_name" : "휴먼시아", "target_dong_name": "공덕동", "like": 0}
GET poi_example/_search
{
"size": 3,
"query": {
"bool": {
"must": [
{
"match": {
"target_poi_name": "신당동 휴먼시아"
}
}
],
"should": [
{
"match": {
"target_dong_name": "신당동"
}
}
]
}
}
}
response
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "6",
"_score" : 4.1653147,
"_source" : {
"target_poi_name" : "신당동 유치원",
"target_dong_name" : "신당동",
"like" : 0
}
},
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "1",
"_score" : 3.9953568,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "신당동",
"like" : 0
}
},
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "2",
"_score" : 3.9953568,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "중앙동",
"like" : 0
}
}
어??.. 결과가 없다. 마지막에 뭉텅이로 추가한 휴먼시아 아파트들이, 신당동 보다 우선순위가 많이 밀려 이런생기게 되었다.
이렇듯, 단순히 compound query 만으로는 풀지 못하는 문제들이 생길수 있다. (엘라스틱 서치에선 if-then-else 구조의 쿼리 검색을 허용하지 않으므로, 실제로는 나는 __pilot query__를 내가 원하는 만큼 실행시켜 해결하였다.)
pilot query란?
실제로 내가 원한는 결과를 얻기 위해, 검색하는 쿼리가 아닌, elasticsearch에선 존재하지 않는 if-then-else 대신하여 쓰기 위해 선행하는 query 정도로 생각하면 된다.
아래에서 사용할 normalize 도 그렇고,
위 케이스에서 지번명이 상호명에 포함되는 케이스를 필터링 하기위해 본 검색전 minum_should_size 를 통해 값을 확인하는등의 부가 검색 query 들을 말한다.
이건 주소 검색 프로젝트를 설명하는 곳에서 다시 다루어 보겠다.
내가 진행한 프로젝트에선, 단순히 단일 검색을 하는게 아닌 context가 유지되면서 계속해서 검색이 유지되어야 하는 기능도 필요했기에 더더욱 query에 대한 튜닝이 필요하였다.
이제 그 튜닝 방법을 간단히 소개 해보도록 하겠다.
2. score nomalization
엘라스틱 서치에서 match, should 등 compound query 를 통해, 나온 점수들은 _explanation 기능이 제공된다 하여도, 일반 사용자들이 이 점수를 정확히 이해하거나, 이점수를 가지고 어떠한 작업을 하는것은 보통 힘들일이 아니다. 0에서부터 수백 수천까지, 계산 되는 점수를 다음 결과나 이전 결과와 합성하여 사용하는것 또한 굉장히 힘든 일이다. 그래서 나는 이러한 이유로, score 를 보통 normalization 하여 사용한다.
나는 점수의 최소값이 0, 최대값을 1로 하는 검색결과를 받아오고 싶다고 한다면, 어떻게 하면될까?
아쉽게도 엘라스틱 서치에서는 maximum value 를 지정하여 사용하는 기능은 아직 제공하지 않고 있다.
2-1. constant score
maximum 점수를 지정하는 방법이 없다 하였는데, constant score 를 사용하면 되지 않을까 생각할 수 있다.
일단 constant score 를 사용하는건 매우 간단하다.
GET poi_example/_search
{
"query": {
"constant_score": {
"filter": {
"match": {
"target_poi_name": "신당동"
}
},
"boost": 1
}
}
}
// response
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : null,
"like" : 0
}
},
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.0,
"_source" : {
"target_poi_name" : "신당동 떡볶이",
"target_dong_name" : "중앙동",
"like" : 0
}
},
{
"_index" : "poi_example",
"_type" : "_doc",
"_id" : "3",
"_score" : 1.0,
"_source" : {
"target_poi_name" : "신당동 동사무소",
"target_dong_name" : null,
"like" : 0
}
},
하지만 보는것 처럼 모든 점수가 1.0 이 되버렸다… 이제 내가 사용하는 방법을 알아보자
2-2 pilot query
위에서 말했듯 pilot query는 실 검색에 필요한 조건과, 파라미터를 얻기위해 검색하는 선행 쿼리이 이다. normalization 에선 이 pilot query 를 max_score 값을 구하기 위해 사용한다.