최근 검색 트렌드는 의미 검색(Semantic Search)인 것 같다. 구글이 키워드 검색을 꽉잡고 있고 개인 맞춤 검색은 시들해진 여파 같다. 의미 검색(Semantic Search)을 어떤 식으로 얘기해도 역 파일 기반 검색엔진을 통해 구현한다면 검색어, 색인어 선별 문제로 국한된다. 문장 또는 검색어에 나타난 그대로를 색인하면 키워드 검색이 된다. 의미 기준에 따라 선별하면 의미 검색이라고 불릴 자격이 부여된다. 사용자 입장에서 보면 검색어가 돌출돼 있는 문서를 머릿속에서 상상하며 검색하면 키워드 검색이다. 반면 문서를 그 자체로 생각하며 검색한다면 의미 검색이 된다. 웹 검색이 대표적인 키워드 검색라면 쇼핑몰 검색이 대표적인 의미 검색 분야다. 웹 문서는 디지털화된 문자열로 쉽게 상상되지만 구매 대상인 옷들을 문자열로 상상하기란 힘들다.

쇼핑몰에서는 잘 조직화된 의미사전을 사용해 검색어를 자동 확장하여 의미 검색을 시도한다. 아래에 GS이숍의 "나이키" 검색결과가 있다. 잘보면 첫 결과에 "나이키"란 단어가 없다. 나이키의 영문표기인 "NIKE"를 자동으로 넣어 검색했다. "NIKE"와 "나이키"는 수작업으로 의미사전에 등록되어 있고 검색엔진에서는 "나이키"를 "나이키 or NIKE"로 확장해서 검색했을 뿐이다. 질의확장 용도의 의미사전은 정교하게 튜닝되어야 하기에 모두 수작업으로 만든다. 잘못된 오확장은 검색결과를 나쁘게 만들기 때문이다. 일일이 등록되는 모든 상품을 검토할 수 있다면 수작업을 뭐라 할 수 없다. 하지만 옥션과 같이 사용자들이 올리는 상품이라면 불가능하다. 이런 경우 자동화된 질의확장방법이 절대적이다.
 

상품 내용 그 자체만을 분석해서 쿼리를 자동 확장하는 잠재 은닉 색인(Latent Semantic Indexing)이란 기법이 있다. 상당히 잘 동작한다고 알려져 있는데 국내에 잘 소개되지 못한 것 같다. LSI가 심한(?) 행렬 계산을 필요로 하고 문서와 질의를 은닉차원으로 전환하기 위해 2차 색인을 참조해야 하는 여러 부담 때문에 LSI를 적용한 엔진을 구경하기 힘든것 같다. SVD(Singular Value Decomposition)이란 어려운 선형대수가 나오기 때문일지도 모르겠다. 색인기법으로 LSI와 같은 고급기법들을 채택한 똑똑한 검색엔진이 나왔으면 한다.

@webJOY




루씬은 단순 라이브러리다. 데몬 형태로 항시 실행되며 검색어를 찾아주는 검색 서버를 만들려면 웹 서버가 필요하다. 검색 서버용 웹 서버는 검색어를 동시 처리해야 하므로 멀티 쓰레딩이 되어야 하고 적어도 HTTP 1.0을 지원해야 한다. 욕심을 내자면 HTTP Keep Alive를 지원하는 HTTP 1.1 스펙을 지원하면 더 좋다. 사용자 쿼리 폭증 시에 검색 서버가 마비되는 현상을 막기 위해 허용 연결 갯수를 제한할 수 있는 기능이 있으면 금상첨화다. 여러 오픈소스 웹 서버가 있었지만 제티(Jetty)를 골랐다. 다른 자바 웹 서버를 사용해도 상관없다.

웹 서버는 해결되었지만 루씬의 검색 API인 IndexSearcher가 멀티쓰레드로 검색하는 경우에 대한 속 시원한 코드나 답변을 좀처럼 찾기 어려웠다. IndexSearcher는 가급적 1개 인스턴스만 가지고 작업을 하도록 루씬 매뉴얼에 권장되어 있어(참조) 멀티쓰레드가 공유해서 나눠쓰도록 코딩했었다. 결과는 엉망되었는데 ab 스트레스 테스트에 거의 80 ~ 90% 검색어를 처리하다 실패했다. 예외 메시지만 보았을때는 루씬 세그먼트 파일 오픈 실패였다. 파일 갯수 제한에 그 원인이 있는 줄 알고 동시 연결 클라이언트 수를 제약해보거나 단일 쓰레드로 테스트해보았지만 오류는 같았다.

해결책을 찾아 이리저리 구글링하다 코지 세키구치(Koji Sekiguchi)란 일본인이 쓴 "Apache Lucene 入門"이란 책의 예제 코드에서 해결점을 찾았다. 코지는 IndexSearcher 인스턴스를 한 개만 만들고 close()하지 않은채 계속 사용하는 DelayCloseIndexSearcher란 클래스를 만들어섰다. 첫 실험이 쓴 실패가 IndexSeacher.close()를 자주 호출하기 때문에 발생한 것 같다. 코지의 코드를 약간 수정해 Jetty + DelayCloseIndexSearcher로 만족할 만한 루씬 멀티 쓰레드 검색 서버를 만들었다.

고속 루씬 검색 서버로 가려면 넘어야 할 산들이 더 있다. 루씬 2.4 스펙에 들어 있지만 실제론 3.0에 구현될 예정이라는 읽기 전용 모드의 IndexReader를 사용해야 할 것 같다(LUCENE-1329). 처리율 향상을 위해서다. 또, 루씬 2.4에 새롭게 추가된 허용 시간내 색인 읽기 기능(LUCENE-997)도 꼭 필요하다. 대용량 콜렉션에서 빈번하게 출현된 색인어를 검색하는 경우에 대용량 IO발생으로 검색 서버의 IO 대역폭을 과점하여 다른 검색이 느려지기 때문이다. Jetty에서 동시 연결 갯수를 제한하는 기능은 아직 못 찾았다. 이 또한 검색어 폭주 현상에서 검색 서버가 원활히 동작하기 위해 꼭 필요한 기능이다.

@webJOY

우워~~ 코지 세키구치가 솔라(Solr) 커미터였군요 ^^
conv2님의 블로그에 코지 세키구치의 예제코드가 있어요. 약간 오타가 있답니다. 찾아보세요.



검색엔진은 정수배열을 많이 사용한다. 역파일 색인의 문서벡터, 위치벡터를 저장할 때가 대표적인 예다. 이런 정수배열을 압축하면 디스크 공간을 덜 사용하고 메모리로 빨리 올릴 수 있어 일석이조다. 두마리 토끼를 다 잡는 정수배열 압축기법은 색인을 작게 하고 검색을 빠르게 하기 때문에 세련된 검색엔진의 필수조건으로 자리잡고 있다.

정수배열 압축은 압축결과의 형태에 따라 가변길이 바이트 압축(Variable Byte Coding), 가변길이 비트 압축 기법이 있다. 정수자체를 압축해도 좋지만 검색엔진의 문서벡터는 정렬되어 있기 때문에 인접한 정수간의 차이를 압축한다. 이런 방식(Gap encoding)을 보통 "색인압축(Index compression)"이라 총칭한다. 바이트보다 비트 색인압축 기법의 압축율은 좋으나 복호화 속도가 떨어져 가변길이 바이트 압축 기법이 색인압축 기법으로 많이 채택된다.

루씬 2.3.2에서 차이압축(Gap encoding)을 하는진 모르겠다. 하지만 가변길이 바이트 압축 기법은 구현되있다(IndexInput::readVint(), IndexOuput::writeVInt()). 더그커팅이 2004년 강의한 자료의 가변길이 압축 슬라이드 안에 있는 예제를 보면 동작원리를 이해할 수 있다. 슬라이드가 아래에 있다. 아래 예는 정수 135를 압축하는 예제다. 135의 이진표현인 10000111에서 하위 7비트를 뺀 후(shift right) 나머지가 있으면 1을 덧붙이고 없으면 0을 붙여 한 바이트를 만들어 저장한다. 이 과정을 반복 수행 한다.

가변길이 바이트 압축 기법은 간단한 알고리즘이지만 색인압축에서는 강력하다. 127보다 작은 정수라면 상위 3바이트를 저장하지 않아 1바이트만 저장공간으로 사용하게 되어 1/4로 줄어든다. 이처럼 작을수록 압축효율이 높아 작은 수가 많은 정수배열을 저장하면 효율이 좋게 된다. 이런 특징때문에 정렬된 문서벡터를 압축할때 차이값을 압축하는 이유다.


문서벡터 압축 방법은 매우 다양하지만 실무에서 쓸만큼 간단하고 효과적인 방법은 단연코 가변길이 바이트 압축기법이다. 압축 전에 비해 75% 공간을 절약할 수 있고, 쿼리 처리 속도도 나빠지지 않으며 또한 적용도 쉬워 나쁜게 없는 기법이다. Managing Gigabyte와 An Introduction to Information Retrieval 책에도 상세히 소개되어 있이니 참고바란다. 자바라면 루씬 코드를 베껴 쓰면 되고, C나 C++이라면 코드가 10줄도 안되니 포팅하면 된다.

@webJOY