문서 한 건에 보험계약대출이율이 정확히 들어 있었다. match는 이 문서를 찾았고 match_phraseslop=1에서는 찾았다. 같은 문자열을 넣은 match_phrase, slop=0만 0건을 반환했다.

검색 점수나 리콜의 문제가 아니었다. 분석기가 만든 토큰 그래프를 Elasticsearch가 phrase query로 바꾸는 과정에서 위치 정보 하나가 사라지고 있었다.

최소 재현

Elasticsearch 8.15.3과 Lucene 9.11.1에서 한 문서만 넣은 인덱스를 만들었다. 분석기는 다음과 같이 구성했다.

  • nori_tokenizer, decompound_mode=mixed
  • nori_readingform
  • lowercase
  • nori_part_of_speech

mixed 모드는 복합어 원형과 분해된 토큰을 함께 만든다. 품사 필터는 조사 를 제거한다. 분석 결과에는 그래프와 빈 위치가 동시에 남는다.

토큰위치설명
보험계약0복합어 원형, positionLength=2
보험0분해 경로
계약1분해 경로
대출2
3품사 필터가 제거
4

같은 원문으로 세 쿼리를 실행한 결과는 다음과 같았다.

쿼리결과
match1건
match_phrase, slop=00건
match_phrase, slop=11건

그래프를 없애거나 품사 필터를 제거하면 slop=0에서도 다시 검색됐다. 두 조건이 함께 있을 때 phrase query의 위치 관계가 달라졌다.

토큰 스트림과 query tree 비교

Lucene의 QueryBuilder.createPhraseQuery에 같은 토큰 스트림을 직접 넣으면 빈 위치가 보존됐다.

text:"보험계약 대출 ? 율" text:"보험 계약 대출 ? 율"

?가 조사 가 빠진 위치다. 분해 경로의 위치는 보험@0 계약@1 대출@2 율@4로 색인 위치와 일치한다.

Elasticsearch에서 slop=0 쿼리를 rewrite하면 다른 구조가 나왔다.

spanNear([
  spanOr([
    text:보험계약,
    spanNear([text:보험, text:계약], 0, true)
  ]),
  text:대출,
  text:율
], 0, true)

그래프의 두 경로는 남아 있지만 대출 사이의 한 칸이 없다. 쿼리는 두 토큰이 바로 붙어 있어야 한다고 해석했고, 색인에서는 그 사이에 제거된 조사의 위치가 남아 있어 매치하지 않았다.

slop=1은 해결책이 아니었다. phrase 전체의 허용 범위를 넓히는 동시에 Elasticsearch 내부 실행 경로를 finite-strings 방식으로 바꾼다. 이 경로는 빈 위치를 보존했기 때문에 결과가 나온 것이다.

두 곳에서 위치 정보가 사라졌다

MatchQueryParser의 graph phrase 처리에는 별개의 결함이 두 개 있었다.

첫 번째는 createSpanQuery의 gap 위치였다. 다음 토큰에서 발견한 gap을 직전 clause 뒤가 아니라 그 clause 앞에 추가했다. SpanGap이 한 clause 일찍 들어가면서 토큰 스트림과 다른 위치 관계를 만들었다.

두 번째는 analyzeGraphPhrase의 바깥 조립 과정이었다. 토큰 그래프를 articulation point 기준으로 나눈 뒤 각 구간을 합칠 때, 구간 사이의 PositionIncrementAttribute를 읽지 않았다. side path 밖에서 생긴 gap은 최종 SpanNearQuery에서 완전히 사라졌다.

한쪽만 고치면 다른 형태의 그래프에서 같은 문제가 남는다. 수정은 두 경로 모두에서 position increment를 보존하도록 구성했다.

  • pending clause를 먼저 추가한 뒤 올바른 위치에 SpanGap을 넣었다.
  • 그래프 바깥 구간도 SpanNearQuery.Builder로 조립해 position increment를 전달했다.
  • 추가된 gap clause를 clause 수에 포함해 최종 query tree의 집계와 맞췄다.

회귀 테스트

회귀 테스트는 특정 한국어 문자열 하나에 묶이지 않게 경계를 나눴다.

  • graph side path 앞·안·뒤에 gap이 있는 경우
  • graph segment 주변에 gap이 여러 개 있는 경우
  • synonym_graph 뒤에서 stop filter가 토큰을 제거하는 경우
  • nori mixed decompounding과 품사 필터를 함께 사용한 end-to-end 재현
  • stop filter가 synonym filter 앞에 있어 query construction으로 복구할 수 없는 범위

마지막 항목은 수정 범위를 명확히 하기 위한 대조군이다. 분석기 순서 때문에 synonym rule 자체가 매치하지 못한 경우는 query builder가 고칠 수 없다.

수정은 Elasticsearch #152931로 병합됐다. MatchQueryParser 수정과 함께 unit test 197줄, nori YAML end-to-end test 46줄이 추가됐다. 재현 데이터와 query tree는 공개 case study에서 확인할 수 있다.

검색 결과만 보면 놓치는 문제

이 문제는 검색 결과 한 번이나 집계 지표 하나로 찾기 어렵다. bag-of-words 검색은 위치를 사용하지 않고, 그래프가 없는 phrase test는 문제의 코드 경로에 들어가지 않는다. 전체 nDCG에서도 다른 후보 문서가 검색되면 exact phrase 경로의 누락이 가려질 수 있다.

phrase 검색의 정확성을 확인할 때는 네 가지 산출물을 함께 봐야 한다.

원문
→ 분석된 토큰 그래프
→ 컴파일된 query tree
→ 매치한 문서

분석기가 올바른 토큰을 만들었다는 사실만으로 검색 의미가 보존되지는 않는다. 랭킹에 들어가기 전 query construction 단계에서 위치 관계가 달라질 수 있다.