Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

Solr에 대해서

Solr는 루신(lucene)기반의 검색 소프트웨어다. 루신이 검색엔진이라면, Solr는 색인과 검색, 분산 검색, 리플리케이션, 로드밸런싱 등 검색서비스를 위한 다양한 기능을 포함한 검색 소프트웨어라고 할 수 있다. 이 바닥에서는 ElasticSearch을 많이 사용하고 있는 것 같다. 스키마를 정의 할 필요가 없어서 로그와 같은 비정형 데이터를 색인하기가 쉽기 때문이다. 지금은 solr도 schemaless를 지원하기 때문에 거의 차이가 없다.

사용 목적

joinc의 검색 서비스를 위해서 사용한다. joinc의 모든 문서들과 코드를 색인한다.

Solr 설치

우분투 리눅스에 설치했다. 먼저 jdk를 설치한다.
# apt-get install openjdk-7-jre
Apache Solr사이트에서 최신 버전을 다운로드 한다. (2016년 1월)현재 최신 버전은 5.4.0이다. 다운로드 한 solr는 /opt/solr 에 풀었다.

서버 실행 및 Core 만들기

모든 명령은 /opt/solr 디렉터리에서 수행한다. solr 서버를 실행한다.
# bin/solr start
서버는 0.0.0.0:8983으로 실행된다.

joinc의 컨텐츠를 색인하기 위해서 wiki라는 이름의 core를 만들기로 했다.
# bin/solr create -c wiki -d basic_configs

Copying configuration to new core instance directory:
/opt/solr/server/solr/wiki

Creating new core 'wiki' using command:
http://localhost:8983/solr/admin/cores?action=CREATE&name=wiki&instanceDir=wiki

{
  "responseHeader":{
    "status":0,
    "QTime":220},
  "core":"wiki"}
웹 브라우저로 8983으로 접근하면, core 정보를 확인 할 수 있다.

 solor-core 정보

색인 하기

문서를 색인하기 위해서는 스키마 설정이 필요하다. Solr도 스키마 없는 색인을 지원한다. Schemaless를 이용하면 복잡한 스키마 설정이 필요 없으며, 스키마의 변경에 신경 쓸 필요가 없이 색인을 할 수 있다는 장점이 있다. 하지만 스키마가 없는 만큼 검색의 품질이 떨어진다는 단점도 있다. 내가 색인하려는 joinc의 컨텐츠는 형식이 고정되어 있다. 따라서 스키마를 만들어서 검색의 품질을 높이기로 했다.

앞서 wiki core를 만들었는데, solr는 core를 만들때 기본 스키마를 복사한다. 기본 스키마는 /opt/solr/server/solr/wiki/conf/schema.xml 이다. 이 스키마를 joinc 컨텐츠에 맞게 수정하기로 했다. joinc 컨텐츠는 대략 아래와 같은 요소들로 구성된다. json 형태로 표현했다.
{
    "id": 12345,
    "path" : "man/12/docker",
    "title" : "제목",
    "author" : "yundream",
    "contents" : "본문 내용 ....",
    "createdate" : "2015-05-01 11:12:00",
    "updatedate" : "2015-05-02 09:53:00"
}
joinc의 wiki 컨텐츠는 database에 저장돼 있다. 데이터베이스에 있는 값을 읽어서 위의 json 형태로 만들어서 색인하면 되겠다. 위에 있는 데이터를 색인하기 위한 schema를 만들었다.
<!-- /opt/solr/server/solr/wiki/conf/schema.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<schema name="example" version="1.5">
 <uniqueKey>id</uniqueKey>
    <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
    <field name="path" type="text_general" indexed="true" stored="true"/>
    <field name="title" type="text_index" indexed="true" stored="true"/>
    <field name="author" type="text_general" indexed="true" stored="true"/>
    <field name="contents" type="text_index" indexed="true" stored="true"/>
    <field name="createdate" type="text_general" indexed="true" stored="true"/>
    <field name="updatedate" type="text_general" indexed="true" stored="true"/>
json을 색인하기 위해서 json에 대응되는 field name을 추가했다. uniqueKey는 색인된 문서를 가리키는 유일한 키다.

각 field는 type이 설정돼 있는데, type으로 색인 방식을 결정할 수 있다. 나는 text_general 과 text_index 두 개의 필드타입을 설정했다.
    <fieldType name="text_index" class="solr.TextField" positionIncrementGap="100">
      <analyzer type="index">
        <tokenizer class="solr.StandardTokenizerFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="2" maxGramSize="10"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.StandardTokenizerFactory"/>
        <filter class="solr.EdgeNGramFilterFactory" minGramSize="2" maxGramSize="10"/>
      </analyzer>
    </fieldType>

    <fieldType name="text_general" class="solr.TextField" positionIncrementGap="100">
      <analyzer type="index">
        <tokenizer class="solr.StandardTokenizerFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" />
        <!-- in this example, we will only use synonyms at query time
        <filter class="solr.SynonymFilterFactory" synonyms="index_synonyms.txt" ignoreCase="true" expand="false"/>
        -->
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
      <analyzer type="query">
        <tokenizer class="solr.StandardTokenizerFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" />
        <filter class="solr.SynonymFilterFactory" synonyms="synonyms.txt" ignoreCase="true" expand="true"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>
색인(index)과 질의(query)에는 tokenizerfilter 두 개의 주요 엘리먼트가 사용된다. 색인을 예로 들면, 먼저 데이터를 tokenizer에 설정한 방식을 이용해서, 단어(term)을 추출하고, 각 term을 filter에 등록된 필터에 통과 시킨다. 나는 text_index필드로 들어오는 데이터들을 StandardtokenizerFactory로 단어들을 추출하고, 추출한 단어들을 EdgeNGramFilterFactory로 필터링 한 결과를 색인했다.

text_index는 색인과 질의(query)를 위해서 solr.StandardTokenizerFactory를 사용했다. 공백문자와 특수 문자들을 토큰으로 사용한다.
<analyzer>
  <tokenizer class="solr.StandardTokenizerFactory"/>
</analyzer>
  • 입력 : "Please, email john.doe@foo.com by 03-09, re: m37-xq."
  • 출력 : "Please", "email", "john.doe", "foo.com", "by", "03", "09", "re", "m37", "xq"
필터링으로 solr.EdgeNgramFilterFactory를 사용했다. Ngram 방식인데, 단어의 앞자리에서 부터 NGram을 적용한다.
<analyzer>
  <filter class="solr.EdgeNGramFilterFactory" minGramSize="2" maxGramSize="10"/>
</analyzer>
  • 입력 : "Please"
  • 출력 : "Pl", "Ple", "Plea", "Pleas", "Please"
예를 들어 Linux Programming은 아래와 같이 색인 될 것이다.
  • 먼저 standardTokenizerFactory가 적용된다. : "Linux", "Programming" 두 개의 단어가 추출된다.
  • 각 단어에 대해서 EdgeNgramFilterFactory가 적용된다. : "Li", "Lin", "Linu", "Linux", "Pr", "Prog", "Progr"....
한글 형태소 분석기를 붙이지 않더라도 괜찮은 검색 결과를 얻을 수 있다.

curl을 이용해서 색인테스트를 했다.
# curl http://localhost:8983/solr/wiki/update -d @test2.json -H 'Content-type:application/json'

Solr 대시보드에서 색인과 질의 결과를 미리테스트해 볼 수 있다.

 solr 대시 보드

데이터 클랜징

웹 문서를 색인 할 때, 골치 아픈 문제가 하나 있다. 주요 컨텐츠 이외의 텍스들을 걸러내는 것이다. 블로그 사이트를 예로 든다면, 실제 색인하고픈 본문 텍스트 외에 광고, 탑메뉴, 사이드메뉴 등등 블로그의 프레임에 해당되는 텍스트이 함께 구성돼 있는데, 이들 쓸데 없는 텍스트들 때문에 검색 결과가 꼬이는 경우가 심심찮게 발생한다. 문제는 이런 텍스트들을 걸러내는게 쉽지 않다는 점.

Joinc 문서들도 마찬가지다. w3m으로 랜더링이 끝난 문서를 읽어오다 보니, 메뉴들도 함께 색인이 되버린다.

이 문제는 URL요청에 파라메터로 ?print=true가 들어가면, 본문만 출력하도록 gowiki 코드를 수정하는 것으로 해결 했다.

Json으로 검색 하기

아래와 같이 Json 향태로 질의 할 수 있다.
{
  "query": "contents:리눅스",
  "limit": 10,
  "offset": 0,
  "fields": ["contents", "title", "path"]
}
query는 질의어다. field:질의어로 구성된다. 물론 AND, OR등의 연산자도 사용 할 수 있다. limit와 offset을 이용해서 페이징을 할 수 있다. contents와 title에서 가져오고 싶다면 "contents:리눅스" AND "title:리눅스"와 같이 사용하면 된다. fields를 이용해서 검색결과로 가져올 필드를 설정 할 수 있다. 실행 결과는 다음과 같다.
# curl http://localhost:8983/solr/wiki/query -d @query.json 
{
  "responseHeader":{
    "status":0,
    "QTime":50,
    "params":{
      "json":"{  \"query\": \"contents:리눅스\",  \"limit\": 10,  \"offset\": 0,  \"fields\": [\"contents\", \"title\", \"path\"]}"}},
  "response":{"numFound":528,"start":0,"docs":[
      {
        "title":"Linux 미니 홈피",
        "path":"Site/Linux",
        "contents":".... 리눅스 시스템 콜 레퍼런스 하위 문서들 제목 저자 변경일...."},
      {
        "title":"None title",
        "path":"Site/Embedded/Documents/LinuxKernelStudyForSystemenginer/chap01.html",
        "contents":"...리눅스 커널 프로그래밍은...."},
      ....
}

하일라이팅

Solr는 검색어에 대한 하일라이팅을 지원한다. 이 기능을 이용하면, 전체 문서에서 검색어를 포함한 문장을 가져올 수 있다. hl계열의 파라메터를 이용해서 하일라이팅 환경을 설정할 수 있다.
  • hl=true : 하일라이팅을 사용한다.
  • hl.snippets=Num : 문서에서 Num개 만큼의 스니핏을 추출한다.
  • hl.fl=field : 이름이 field인 필드에 대해서 하일라이팅을 한다.
  • hl.simple.pre=tag : 하일라이팅된 검색어에 대해서 tag처리를 한다. 기본 태그는 <em>이다.
아래는 사용 예제다.
# curl http://localhost:8983/solr/wiki/query?hl=true&hl.snippets=3\&hl.fl=contents\&hl.simple.pre=<em> -d @query.json
아래는 검색 결과다.
{
  "responseHeader":{
    "status":0,
    "QTime":50,
    "params":{
      "json":"{  \"query\": \"contents:리눅스\",  \"limit\": 10,  \"offset\": 0,  \"fields\": [\"contents\", \"title\", \"path\"]}"}},
  "response":{"numFound":528,"start":0,"docs":[
      {
        "title":"Linux 미니 홈피",
        "path":"Site/Linux",
        "contents":".... 리눅스 시스템 콜 레퍼런스 하위 문서들 제목 저자 변경일...."},
      {
        "title":"None title",
        "path":"Site/Embedded/Documents/LinuxKernelStudyForSystemenginer/chap01.html",
        "contents":"...리눅스 커널 프로그래밍은...."},
      ....
  "highlighting":{
    "1139":{
      "contents":[...<em>리눅스</em> 운영체제와 관련된 각종 팁, 노하우를 다루는 페이지 입니다. • 리눅스",
        " 환경에서 C 프로그래밍하기 <em>리눅스</em> 개발 입문자를 위한 우분투 <em>리눅스</em> Quick Start 가이드... ",
        "ice 2007 사용하기 개발자를 위한 우분투 리눅스 설치 가이드 관련 문서들 <em>리눅스</em> 활용 3. 임베디드 <em>리눅스</em> <em>리눅스</em>"]},
    "1153":{
      "contents":[" Ubuntu Linux Howto 우분투 <em>리눅스</em> 설치부터 활용까지 정리를 해볼 생각",
        "입니다. 1. 우분투 <em>리눅스</em> 간단 소개 <em>리눅스</em> 배포판 <em>리눅스</em> 2. 우분투 <em>리눅스</em>",
        " 설치 virtualbox로 설치. 3. 리눅스 파일 구조 주요 파일 및 디렉토리 4. 계정 및 권한 시스템 게정 권한 5. <em>리눅스</em>..."]},
    }
}

Joinc 문서색인

Joinc의 문서는 Mysql 로 관리하고 있다.

계획

색인을 위한 애플리케이션을 만든다. 이 애플리케이션의 이름은 jindexer로 한다. 위키 문서를 업데이트하면, 업데이트한 문서의 ID를 redis로 보낸다. jindexer는 redis에서 문서 ID를 읽어서 색인을 수행한다.

 색인 프로세스

향후 유저가 늘어날 경우 redis를 기준으로 컴포넌트들을 분리하는 식으로 확장할 수 있다. gowiki는 rpush로 데이터를 밀어 넣고, jindexer는 BRPOP으로 데이터를 읽는다. jindex는 문서 ID로 mysql에 질의해서 문서를 읽어와서 색인한다.

gowiki 수정

문서를 mysql에 저장한 후 redis에 문서 정보를 전송하는 코드를 추가했다.

jindexer 개발

Redis로부터 문서 ID를 읽는다. 이 문서 ID로 문서의 정보를 읽어서 색인한다. 문서는 wiki 문법이 그대로 저장되 있다. 따라서 wiki 문서를 해석해서 HTML 문서로 만들고 HTML 태그를 모두 제거하는 작업을 해줘야 한다. 귀찮아서 w3m을 이용해서 문서를 dump 받기로 했다. 사용할 w3m 명령은 다음과 같다.
# w3m http://play.joinc.co.kr/w/man/12/solr -dump

HTML 랜더링 까지 완전히 끝낸 문자열이 표준출력된다. 이 문자열과 타이틀, 저자와 같은 데이터들을 색인하면 된다. 코드는 대략 아래와 같은 모양이다. 실제 코드는 많이 다를 거다. 스켈레톤 코드 정도로 보자.
package main

import (
    "fmt"
    redis "gopkg.in/redis.v3"
    "os"
    "sync"
) 

func main() {
    client := redis.NewClient(&redis.Options{ 
        Addr:     "localhost:6379",     
        Password: "",
        DB:       0,
    })
    
    _, err := client.Ping().Result()
    if err != nil {
        fmt.Println(err.Error())        
        os.Exit(0)
    }
    
    message := make(chan string)    
    
    var wg sync.WaitGroup
    wg.Add(2)
   
    // redis로 부터 문서 정보를 읽는다. 
    // 문서 정보는 채널로 전송한다.
    go func() {
        defer wg.Done()
        for {
            res := client.BRPop(0, "index") 
            if res.Err() != nil {           
                fmt.Println(res.Err().Error())  
            } else {
                val, err := res.Result()        
                if err != nil {
                    fmt.Println(err.Error())        
                } else {
                    fmt.Println(val[1])             
                    message <- val[1]               
                }
            }
        }
    }()

    // 채널로 문서 정보를 읽어와서 
    // 색인한다.
    go func() {
        defer wg.Done()
        for {
            // 채널에서 데이터를 읽는다.
            data := <-message
            // solr api를 호출 문서를 색인한다. 
            // .... 생략
        }
    }()

    wg.Wait()
}
		

Joinc 검색 서비스

이렇게 해서 Joinc 검색 서비스를 만들어서 붙였다. 결과는 사이트 위에 있는 검색 버튼으로 확인 할 수 있다. jquery 노가다가 귀찮아서 대충 만든게 함정..

참고