tantivy로 시작하는 Rust 풀텍스트 검색: 스키마부터 쿼리까지

tantivy로 시작하는 Rust 풀텍스트 검색: 스키마부터 쿼리까지

데이터베이스에 LIKE '%검색어%'를 박아 쓰다 보면 한계가 금방 옵니다. 결과는 느리고, 오타에 약하고, 관련도 점수도 없습니다. 본격적으로 검색다운 검색을 붙이려면 Elasticsearch나 OpenSearch를 띄우는 게 정석이지만, 가벼운 사이드 프로젝트나 임베디드 환경에서 별도 서버까지 두기는 부담스러울 때가 있어요.

tantivy는 Rust로 작성된 풀텍스트 검색 엔진 라이브러리입니다. Apache Lucene에서 영감을 받은 설계라서 한 번 배워두면 검색 엔진의 기본기를 그대로 익힐 수 있고, 별도의 데몬 없이 우리 앱에 라이브러리로 끼워 쓸 수 있다는 게 큰 매력입니다. 이 글에서는 tantivy로 작은 색인을 만들고 검색해보면서 스키마, 색인, 쿼리, 갱신까지 한 번에 익혀보겠습니다.

tantivy를 쓰는 이유

먼저 자리매김부터 짚어볼게요. tantivy는 검색 서버가 아니라 라이브러리예요. 자바의 Lucene이 Elasticsearch의 엔진인 것처럼, tantivy 위에 Quickwit이라는 분산 검색 엔진이 올라가 있는 식이죠. 즉 우리 앱 안에서 직접 색인을 만들고 디스크에 떨궈두면 됩니다. 외부 프로세스나 네트워크 호출이 없어서 지연이 짧고 운영도 단순합니다.

성능 역시 Rust답습니다. 메모리 안전성을 지키면서도 색인과 검색 속도가 빠르고, Tokio 같은 비동기 런타임 없이 동기 API로 시작할 수 있어 진입 장벽이 낮아요. JSON 문서를 그대로 받아 쓰는 serde와의 궁합도 좋아서 실제 서비스에 붙이기 편합니다.

의존성 추가

새 프로젝트를 만들고 Cargo.toml에 tantivy를 더해봅시다.

Cargo.toml
[dependencies]
tantivy = "0.22"
tempfile = "3"

tempfile은 디스크에 임시 색인을 만들기 위해 추가했습니다. 실제 서비스에서는 영구 디렉터리를 쓰지만, 예제에서는 매번 새 디렉터리를 잡아주는 편이 깔끔해요.

스키마 정의

tantivy에서 가장 먼저 정해야 하는 건 스키마입니다. 어떤 필드를 두고, 각각 어떤 식으로 색인할지를 미리 선언해 두는 거예요. 여기서 내린 선택이 이후 검색 품질을 사실상 좌우합니다.

use tantivy::schema::{Schema, STORED, STRING, TEXT};

fn build_schema() -> Schema {
    let mut sb = Schema::builder();

    // 본문처럼 토큰화해서 풀텍스트 검색에 쓸 필드
    sb.add_text_field("title", TEXT | STORED);
    sb.add_text_field("body", TEXT);

    // 토큰화 없이 정확히 일치해야 하는 식별자
    sb.add_text_field("isbn", STRING | STORED);

    sb.build()
}

플래그 두세 개만 알아두면 90%는 끝납니다. TEXT는 단어 단위로 쪼개서 색인하라는 뜻이고 STRING은 통째로 한 토큰으로 다루라는 의미입니다. 그래서 사람 이름이나 ISBN처럼 부분 매칭이 필요 없는 값에는 STRING을 씁니다. STORED를 더하면 검색 결과에서 원본 값을 다시 꺼내볼 수 있어요. 본문처럼 양이 큰 데이터는 검색만 가능하면 되니 STORED 없이 TEXT만 켜서 색인 크기를 줄이는 게 보통입니다.

숫자나 날짜, 불리언, IP 주소, 심지어 JSON까지 받아주는 필드 종류가 꽤 많습니다. 정렬이나 집계에 쓸 숫자 필드라면 FAST 플래그를 더해 컬럼 스토어에 함께 저장해 두는 식이에요.

인덱스 만들기

스키마를 손에 쥐었으면 인덱스를 열 차례입니다. 인메모리와 디스크 두 가지 모드가 있어요.

use tantivy::Index;
use tempfile::TempDir;

fn open_index(schema: &tantivy::schema::Schema) -> tantivy::Result<(TempDir, Index)> {
    let dir = TempDir::new()?;
    let index = Index::create_in_dir(dir.path(), schema.clone())?;
    Ok((dir, index))
}

테스트나 일회성 작업이라면 Index::create_in_ram(schema)만으로도 충분합니다. 실서비스에서는 보통 create_in_dir로 디스크에 색인을 떨궈두고, 다음 실행에서는 Index::open_in_dir로 다시 열어 그대로 검색합니다. 색인은 한 번 만들면 같은 디렉터리에 세그먼트 파일로 누적되니까 앱 재시작에도 데이터가 살아남아요.

문서 색인하기

이제 본격적으로 데이터를 넣어볼게요. tantivy는 IndexWriter라는 전용 라이터를 통해 문서를 쌓고, 마지막에 commit()을 호출해야 검색에 노출됩니다.

use tantivy::{doc, Index, IndexWriter};

fn index_books(index: &Index) -> tantivy::Result<()> {
    let schema = index.schema();
    let title = schema.get_field("title")?;
    let body = schema.get_field("body")?;
    let isbn = schema.get_field("isbn")?;

    // 색인용 버퍼로 50MB 할당
    let mut writer: IndexWriter = index.writer(50_000_000)?;

    writer.add_document(doc!(
        title => "노인과 바다",
        body  => "그는 늙은 어부였고, 84일 동안 물고기 한 마리 잡지 못했다.",
        isbn  => "978-0099908401",
    ))?;

    writer.add_document(doc!(
        title => "위대한 개츠비",
        body  => "그래서 우리는 흐름을 거슬러 노 젓는 배처럼 끊임없이 과거로 떠밀려 간다.",
        isbn  => "978-0743273565",
    ))?;

    writer.commit()?;
    Ok(())
}

writer(50_000_000)의 인자는 색인 작업에 쓸 메모리 예산입니다. 값이 클수록 디스크 플러시 빈도가 줄어 처리량이 좋아지지만, 시스템 메모리와의 균형을 맞춰야 해요. tantivy 내부 가이드라인은 한 라이터당 3MB 이상은 줘야 한다는 정도예요.

doc! 매크로는 필드 핸들과 값을 한 줄로 묶어주는 단축 표기입니다. 풀어서 쓰고 싶다면 TantivyDocument::default()로 빈 문서를 만든 다음 add_text, add_u64 같은 메서드로 차근차근 채워도 됩니다.

use tantivy::TantivyDocument;

let mut doc = TantivyDocument::default();
doc.add_text(title, "노인과 바다");
doc.add_text(body, "그는 늙은 어부였고, 84일 동안 물고기 한 마리 잡지 못했다.");
doc.add_text(isbn, "978-0099908401");

writer.add_document(doc)?;

조건에 따라 일부 필드만 채우거나, 반복문 안에서 값을 누적해 넣는 경우라면 매크로보다 이쪽이 더 자연스럽습니다. JSON 문자열을 가지고 있다면 TantivyDocument::parse_json(&schema, json_str)로 바로 변환할 수도 있어요.

여기서 가장 흔히 빠지는 함정이 commit()을 잊는 거예요. add_document를 백 번 호출해도 커밋이 없으면 검색 결과에는 한 건도 나오지 않습니다. 색인을 트랜잭션처럼 다룬다고 생각하면 편해요. 중간에 잘못된 문서를 넣었다면 rollback()으로 통째로 취소할 수도 있습니다.

검색하기

색인이 만들어졌으면 검색을 붙여볼게요. tantivy는 라이터와 리더가 분리되어 있어서, 검색 측은 IndexReader를 통해 일관된 스냅샷을 봅니다.

use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::Value;
use tantivy::{Index, TantivyDocument};

fn search(index: &Index, query_text: &str) -> tantivy::Result<()> {
    let schema = index.schema();
    let title = schema.get_field("title")?;
    let body = schema.get_field("body")?;

    let reader = index.reader()?;
    let searcher = reader.searcher();

    let parser = QueryParser::for_index(index, vec![title, body]);
    let query = parser.parse_query(query_text)?;

    let top_docs = searcher.search(&query, &TopDocs::with_limit(5))?;

    for (score, doc_address) in top_docs {
        let doc: TantivyDocument = searcher.doc(doc_address)?;
        let title_value = doc
            .get_first(title)
            .and_then(|v| v.as_str())
            .unwrap_or("(제목 없음)");
        println!("{score:.3}  {title_value}");
    }
    Ok(())
}

흐름을 짚어보면 QueryParser가 사람이 쓴 검색어를 내부 쿼리 트리로 바꾸고, Searcher::search가 그 쿼리를 받아 TopDocs 컬렉터에 점수가 높은 순으로 결과를 모읍니다. 이때 출력되는 score는 tantivy가 기본 랭킹 함수로 쓰는 BM25 알고리즘으로 계산한 관련도 점수인데, 단어 빈도와 문서 길이를 함께 따져 정렬하는 검색 엔진의 표준 방식이에요. 컬렉터는 검색 결과를 어떻게 거둘지를 결정하는 추상화인데, 상위 N개를 추리는 TopDocs 외에도 개수만 세는 Count, 패싯을 모으는 FacetCollector 등 종류가 다양해요. 필요에 따라 골라 쓰는 Iterator의 소비자와 비슷한 감각으로 이해하면 됩니다.

쿼리 문법은 Lucene과 거의 같아서 노인 AND 바다, "위대한 개츠비", title:바다 같은 표현이 그대로 통합니다. 따옴표로 감싸면 구문 검색, 콜론으로 필드를 지정하는 식이죠. 사용자 입력을 그대로 받기 부담스럽다면 TermQueryBooleanQuery를 코드로 직접 조립해 안전한 검색을 구성할 수도 있어요.

searcher.doc(...)로 꺼낸 문서는 STORED 플래그가 붙은 필드만 값을 돌려준다는 점을 기억해 두세요. 위 예제에서 bodySTORED 없이 색인했기 때문에 본문은 다시 꺼낼 수 없습니다. 원본을 보여줘야 한다면 본문도 STORED를 켜거나, 별도 데이터베이스에서 ID로 다시 조회하는 식으로 분리하는 게 좋습니다.

갱신과 삭제

tantivy의 문서는 한 번 색인되면 직접 수정할 수 없습니다. 대신 같은 식별자를 가진 문서를 지우고 새로 넣는 패턴을 씁니다. 도서 ISBN처럼 유일성이 보장되는 필드를 미리 STRING으로 색인해 두면 그대로 갱신 키로 쓸 수 있어요.

use tantivy::Term;

fn update_book(index: &Index) -> tantivy::Result<()> {
    let schema = index.schema();
    let title = schema.get_field("title")?;
    let isbn = schema.get_field("isbn")?;
    let mut writer: IndexWriter = index.writer(50_000_000)?;

    // 동일 ISBN 문서를 모두 표시 삭제
    let key = Term::from_field_text(isbn, "978-0099908401");
    writer.delete_term(key);

    // 갱신된 내용으로 다시 추가
    writer.add_document(doc!(
        title => "노인과 바다 (개정판)",
        isbn  => "978-0099908401",
    ))?;

    writer.commit()?;
    Ok(())
}

delete_term은 즉시 파일을 지우지는 않고 묘비 표시(tombstone)만 남깁니다. 이후 백그라운드 머지가 일어날 때 실제 데이터가 정리되죠. 사용자 입장에서는 commit()만 호출하면 곧바로 새로운 문서가 검색되니까 신경 쓸 필요는 거의 없습니다.

만약 문서가 진짜로 외부에서 수정되었는지 빠르게 반영하고 싶다면, 라이터 쪽에서 commit()을 부른 뒤 리더에 reader.reload()를 호출해 최신 스냅샷을 다시 들이게 해주세요. 기본 설정으로도 일정 주기마다 자동 갱신되지만, 즉시성이 필요할 때는 명시적으로 새로 고치는 편이 안전합니다.

한국어 검색은 어떻게 할까

기본 토크나이저는 공백과 구두점으로 단어를 나누기 때문에 영어에는 잘 맞지만, 한국어처럼 형태소를 다뤄야 하는 언어에는 한계가 있습니다. tantivy는 토크나이저를 갈아끼울 수 있는 구조라서 lindera 같은 한국어 형태소 분석기를 붙이거나, n-gram 토크나이저로 부분 일치를 흉내 내는 방식이 자주 쓰입니다.

붙이는 절차는 의외로 간단합니다. 우선 별도 크레이트에서 토크나이저 인스턴스를 만들고, 인덱스의 tokenizers()에 이름을 붙여 등록한 다음, 스키마에서 해당 필드가 그 토크나이저를 쓰도록 지정해 주면 끝이에요. 입문 단계에서는 기본 토크나이저로 영문 데이터로 감을 잡고, 한국어 본격 적용은 다음 주제로 미뤄두는 걸 추천드립니다.

마치며

tantivy는 검색 엔진을 “내 앱의 일부”로 다룰 수 있게 해주는 라이브러리입니다. 별도 서버 없이 색인을 만들고, Rust 타입 시스템의 보호를 받으며 빠른 검색을 붙일 수 있다는 점이 가장 큰 장점이에요. 처음에는 스키마 설계가 낯설게 느껴질 수 있지만, TEXTSTRING, STORED만 구분해서 출발해도 대부분의 시나리오는 충분히 다룰 수 있습니다.

이 글에서 다룬 흐름을 한 줄로 요약하면 “스키마를 짜고, 인덱스를 열어, IndexWriter로 문서를 넣은 다음 커밋하고, QueryParser로 검색한다”입니다. 이 뼈대만 잡혀 있으면 이후에 패싯, 정렬, 형태소 분석기, 분산 색인 같은 주제로 자연스럽게 확장해 나갈 수 있어요. 검색 서버가 부담스러웠던 사이드 프로젝트가 있다면 한 번쯤 tantivy로 옮겨보시길 권합니다.

더 자세한 내용은 tantivy 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord