Published on

Rust+Tantivy로 RAG 검색속도 3배 튜닝

Authors

RAG 파이프라인에서 “검색이 느리다”는 말은 보통 벡터 DB만 의심하는데, 실제로는 1차 후보군을 만드는 키워드/필터 검색(lexical) 구간이 병목인 경우가 많습니다. 특히 다음 조건이 겹치면 Tantivy 같은 역색인 엔진이 전체 P95를 결정합니다.

  • 문서가 많고(수백만~수천만) chunk 단위로 쪼개져 인덱스 엔트리가 폭증
  • 테넌트/권한/카테고리 등 필터가 강하게 걸림
  • RAG에서 top-k를 넉넉히 뽑고(예: 200~2000) 이후 rerank/LLM에 넘김
  • 동시 요청이 높아 OS page cache 경쟁이 심함

이 글은 Rust + Tantivy로 RAG 검색을 운영할 때, 검색 속도를 체감적으로 3배까지 끌어올렸던 튜닝 포인트를 “측정 → 병목 분리 → 설계 변경 → 런타임 최적화” 순서로 정리합니다.

관련해서 벡터 검색 품질 튜닝은 별개의 축이므로, HNSW 쪽 체크리스트는 아래 글도 같이 참고하면 전체 RAG 튜닝 그림이 완성됩니다.


목표 정의: “3배”를 어떻게 계측할까

검색 속도 3배는 보통 평균이 아니라 P95/P99에서 의미가 있습니다. RAG는 tail latency가 LLM 응답시간까지 끌어올리기 때문입니다.

권장 지표는 아래처럼 나눕니다.

  • t_parse: 쿼리 파싱/전처리
  • t_filter: 필터 적용(테넌트, 권한, 날짜 등)
  • t_retrieve: top-k 문서 스코어링 및 수집
  • t_fetch: doc store에서 본문/메타 로딩

Tantivy 단독 서비스라면 최소한 다음을 로그로 남기세요.

  • 요청별 top_k, 필터 카디널리티(예: tenant 별 chunk 수)
  • warm/cold(프로세스 재시작 직후인지)
  • 세그먼트 수, 인덱스 크기, OS cache hit 추정(간접적으로 t_fetch 상승으로 관찰)

RAG에서 Tantivy의 역할 재정의: “정확한 top-10”이 목표가 아니다

RAG에서 lexical 검색은 보통 다음 중 하나입니다.

  1. 벡터 검색 전 필터링/부스팅: 특정 키워드/필드 매칭을 강제
  2. 하이브리드: BM25 결과와 벡터 결과를 섞어 후보군 확대
  3. fallback: 임베딩이 약한 도메인(에러 코드, 로그, 약어)에 lexical이 더 강함

이때 Tantivy가 해야 할 일은 “완벽한 랭킹”보다 빠르게 괜찮은 후보를 많이 뽑는 것입니다. 즉, 쿼리와 스키마를

  • 계산이 비싼 스코어링을 최소화하고
  • 필터를 빠르게 적용하며
  • doc fetch를 늦추고(필요한 것만)

이 방향으로 설계해야 속도가 나옵니다.


튜닝 1) 스키마: 필터는 FAST + u64/i64로 고정하라

RAG에서 가장 흔한 실수는 필터 필드를 TEXT로 넣거나, STRING으로 넣어놓고 term query로 두드리는 것입니다. Tantivy에서 필터는 가능하면 fast field 기반으로 처리해야 합니다.

권장 패턴:

  • tenant_id, project_id, doc_id 같은 식별자는 u64로 매핑
  • created_at 같은 시간은 epoch millis i64
  • 권한/태그는 카디널리티에 따라
    • 낮으면 u64 enum(비트마스크도 가능)
    • 높으면 별도 인덱스 분리 또는 facet/term 혼합

아래는 스키마 예시입니다.

use tantivy::schema::*;

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

    // 본문: 검색용
    schema_builder.add_text_field("body", TEXT);

    // chunk 식별자/정렬/필터용
    schema_builder.add_u64_field("tenant_id", FAST | INDEXED | STORED);
    schema_builder.add_u64_field("doc_id", FAST | INDEXED | STORED);
    schema_builder.add_u64_field("chunk_id", FAST | INDEXED | STORED);

    // 시간 필터
    schema_builder.add_i64_field("created_at", FAST | INDEXED);

    // 반환용 최소 메타
    schema_builder.add_text_field("title", STORED);

    schema_builder.build()
}

포인트는 FAST입니다. FAST가 붙으면 columnar 방식으로 빠르게 접근할 수 있어, 필터/정렬/집계에 유리합니다.


튜닝 2) 인덱스 분리: “테넌트 단위 인덱스”가 생각보다 강력하다

다중 테넌트 RAG에서 가장 치명적인 병목은 필터 카디널리티입니다.

  • 단일 거대 인덱스 + tenant_id 필터
  • vs 테넌트(혹은 프로젝트) 단위로 인덱스를 쪼갠 뒤, 아예 검색 대상 세그먼트를 줄이는 방식

후자가 운영 복잡도는 올라가지만, 성능은 극적으로 좋아질 수 있습니다. 특히 한 테넌트의 데이터가 전체의 1~5% 수준이면, “필터로 거르기”보다 “애초에 검색 범위를 줄이기”가 훨씬 빠릅니다.

실무 팁:

  • 테넌트가 매우 많으면 “상위 단위(예: region, org)”로 샤딩하고, 그 안에서 tenant_id 필터
  • 인덱스 파일 핸들 수(ulimit)와 메모리 매핑 수를 고려

이 설계는 벡터 DB에서도 동일하게 중요합니다. 필터 누락/성능 문제를 막는 인덱스 설계 관점은 아래 글이 도움됩니다.


튜닝 3) 쿼리 구조: QueryParser를 버리고 “고정 쿼리”로 가자

자연어 쿼리를 그대로 QueryParser에 넣으면 편하지만, RAG에서는 다음 비용이 숨어 있습니다.

  • 토큰화/연산자 파싱 오버헤드
  • 불필요한 phrase/slop/precedence 처리
  • 예측 불가능한 쿼리 트리(성능 튜닝이 어려움)

대신 고정된 쿼리 템플릿을 구성하세요.

  • 본문은 body에 대해 BooleanQuery로 term들을 Should로 묶고
  • 필터는 Must로 붙이며
  • 필요하면 특정 필드는 boost

예시:

use tantivy::query::{BooleanQuery, Occur, Query, TermQuery, RangeQuery};
use tantivy::schema::{Field, IndexRecordOption, Term};
use tantivy::{DocAddress, Index, Score};

fn build_rag_query(
    body_field: Field,
    tenant_field: Field,
    created_at_field: Field,
    tenant_id: u64,
    terms: &[String],
    created_at_gte: i64,
) -> Box<dyn Query> {
    let mut clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();

    // tenant 필터 (MUST)
    let tenant_term = Term::from_field_u64(tenant_field, tenant_id);
    clauses.push((
        Occur::Must,
        Box::new(TermQuery::new(tenant_term, IndexRecordOption::Basic)),
    ));

    // 시간 필터 (MUST)
    let range = RangeQuery::new_i64_bounds(created_at_field, created_at_gte..);
    clauses.push((Occur::Must, Box::new(range)));

    // 본문 매칭 (SHOULD)
    for t in terms {
        let term = Term::from_field_text(body_field, t);
        clauses.push((
            Occur::Should,
            Box::new(TermQuery::new(term, IndexRecordOption::WithFreqs)),
        ));
    }

    Box::new(BooleanQuery::from(clauses))
}

이 방식의 장점:

  • 쿼리 트리가 고정되어 성능이 예측 가능
  • 쿼리 파싱 비용 제거
  • term 수, IndexRecordOption을 조절해 스코어링 비용을 제어 가능

RAG에서는 BM25의 “정교함”보다 “후보군 생성”이 목적이므로, 상황에 따라 IndexRecordOption::Basic으로 낮추는 것도 효과가 있습니다(정확도는 약간 떨어질 수 있으니 측정 필수).


튜닝 4) top-k 전략: “일단 많이 뽑고 rerank”가 항상 정답은 아니다

RAG에서 흔히 top_k=1000 같은 값을 주고 이후 rerank로 정리합니다. 하지만 Tantivy에서 top-k가 커질수록

  • 힙 유지 비용
  • 스코어 계산/정렬 비용
  • doc fetch 비용(특히 STORED 필드 로딩)

이 함께 증가합니다.

추천하는 2단계 전략:

  1. Tantivy: top_k_lexical을 50~200 수준으로 제한
  2. 벡터/크로스 인코더 rerank: 필요한 만큼만 확장

하이브리드가 필요하다면 “lexical 100 + vector 100”처럼 각 검색기의 top-k를 작게 유지하고 merge 단계에서 dedup/score fusion을 합니다.


튜닝 5) doc fetch 지연: STORED 필드 로딩을 최소화하라

Tantivy에서 검색 자체보다 느린 구간이 t_fetch인 경우가 많습니다. 즉, top-k 문서의 STORED 필드를 읽어오는 비용이 tail latency를 만듭니다.

해결책:

  • 1차 검색에서는 STORED 필드를 거의 읽지 말고 DocAddress만 확보
  • 필요한 필드만 별도 저장(예: title, chunk_preview)
  • 본문 전체는 Tantivy STORED 대신 외부 KV/오브젝트 스토리지에서 가져오되, 캐시 전략을 분리

예시로, Tantivy에서 doc를 가져올 때도 최소 필드만 읽도록 스키마를 분리하는 것이 좋습니다(큰 본문을 STORED로 넣으면 fetch가 급격히 느려질 수 있음).


튜닝 6) 세그먼트 관리: 세그먼트 수가 늘면 검색이 느려진다

Tantivy는 Lucene 계열과 마찬가지로 세그먼트 기반입니다. 세그먼트가 많아지면

  • 검색 시 세그먼트별로 스코어링 수행
  • 파일 핸들/메모리 매핑 증가
  • OS cache locality 악화

즉, 세그먼트 병합 정책이 성능에 직결됩니다.

운영 팁:

  • 인덱싱이 잦은 서비스는 “쓰기 성능”과 “읽기 성능” 사이에서 타협점을 찾기
  • 배치 인덱싱이 가능하면, 일정량 쌓아서 커밋하고 병합을 유도

간단한 예시(주기적 merge 트리거):

use tantivy::Index;
use tantivy::merge_policy::LogMergePolicy;

fn tune_merge_policy(index: &Index) {
    let mut policy = LogMergePolicy::default();

    // 세그먼트가 너무 잘게 쪼개지지 않도록 조정 (워크로드에 맞게 실험 필요)
    policy.set_min_layer_size(10_000);
    policy.set_max_merge_size(5_000_000);

    index.set_merge_policy(Box::new(policy));
}

값은 데이터 크기/업데이트 패턴에 따라 달라서 “정답”은 없고, 핵심은 세그먼트 수를 관찰하고 P95와 상관관계를 잡는 것입니다.


튜닝 7) Reader/Searcher 재사용: 매 요청마다 열지 말 것

Tantivy에서 IndexReader/Searcher 생성은 가볍지 않습니다. 서비스에서는 다음 원칙을 지키는 편이 안전합니다.

  • 프로세스 시작 시 Index::reader_builder()로 reader를 만들고 공유
  • 요청 핸들러에서는 reader.searcher()만 얻어 사용
  • refresh는 ReloadPolicy로 제어
use tantivy::{Index, IndexReader, ReloadPolicy};

fn build_reader(index: &Index) -> tantivy::Result<IndexReader> {
    index
        .reader_builder()
        .reload_policy(ReloadPolicy::OnCommit)
        .try_into()
}

OnCommit은 커밋 시점에만 새 세그먼트를 보게 하므로, 읽기 일관성과 성능이 예측 가능합니다. near-real-time이 필요하면 정책을 바꾸되, refresh 빈도가 높아질수록 tail latency가 흔들릴 수 있습니다.


튜닝 8) 캐시: “쿼리 결과 캐시”보다 “필터 비트셋 캐시”가 효율적

RAG 쿼리는 유사해 보여도 완전히 동일한 문자열이 반복되는 경우는 생각보다 적습니다. 그래서 결과 캐시는 hit rate가 낮습니다.

대신 효과적인 건:

  • tenant_id 같은 강한 필터의 문서 집합을 비트셋 형태로 캐시
  • 권한/카테고리 조합이 제한적이면 조합별 비트셋 캐시

Tantivy 내부 캐시만으로 부족하면 애플리케이션 레벨에서 “필터 키”를 만들고, 해당 필터에 대한 후보 doc id 집합(또는 segment별 bitset)을 캐싱하는 접근이 성능에 도움이 됩니다.

다만 이 영역은 구현 난이도가 올라가므로, 먼저 위의 스키마/세그먼트/쿼리 구조 최적화로 P95를 충분히 내린 후에 시도하는 것을 권합니다.


튜닝 9) 하이브리드(lexical+vector) 결합 시, “교집합 강제”를 조심하라

속도만 보면 교집합이 후보 수를 줄여 유리해 보이지만, RAG 품질이 크게 흔들릴 수 있습니다. 실무적으로는

  • union으로 넓게 뽑고
  • dedup 후
  • rerank에서 정리

가 안전합니다.

또한 필터가 있는 하이브리드에서는 “필터를 어디서 적용하느냐”가 중요합니다.

  • Tantivy에서 필터 적용 후 vector로 넘기면 vector 단계 비용이 줄어듦
  • 반대로 vector에서 필터를 먼저 적용하면, 필터 누락/불일치가 발생할 수 있음(특히 메타 동기화가 느릴 때)

필터 정합성 문제는 검색 품질뿐 아니라 보안(권한) 이슈로도 이어지니, 인덱스 설계 단계에서 미리 방지하는 게 좋습니다.


튜닝 10) 벤치마크 방법: “재현 가능한 데이터셋”과 “cold/warm 분리”

3배 개선을 주장하려면, 최소한 아래 조건을 통제해야 합니다.

  • 동일한 인덱스 스냅샷(세그먼트 수 포함)
  • 동일한 쿼리 셋(실트래픽에서 샘플링)
  • cold start(프로세스 재시작 직후)와 warm(10분 이상 워밍) 분리

간단히는 다음처럼 Rust에서 쿼리 셋을 돌리며 P95를 계산할 수 있습니다.

use std::time::Instant;

fn p95(mut xs: Vec<u128>) -> u128 {
    xs.sort_unstable();
    let idx = (xs.len() as f64 * 0.95).ceil() as usize - 1;
    xs[idx]
}

fn bench<F: Fn()>(name: &str, iters: usize, f: F) {
    let mut samples = Vec::with_capacity(iters);
    for _ in 0..iters {
        let t0 = Instant::now();
        f();
        samples.push(t0.elapsed().as_micros());
    }
    println!("{} p95={}us", name, p95(samples));
}

실서비스에서는 이 벤치가 아니라도, 최소한 “튜닝 전/후 동일 쿼리 셋의 P95 비교”가 가능해야 합니다.


실제로 3배가 나오는 조합(우선순위)

현장에서 효과가 컸던 순서대로 정리하면 아래 조합에서 “3배”가 자주 나옵니다.

  1. QueryParser 제거 + 고정 쿼리 트리(파싱/예측불가 비용 제거)
  2. 필터 필드 FAST화 및 타입 정리(u64/i64)
  3. STORED fetch 최소화(본문 전체를 Tantivy에서 꺼내지 않기)
  4. 세그먼트 수 관리(merge policy/커밋 전략)
  5. 테넌트 단위 인덱스 분리(가능한 경우 가장 강력)

특히 1~3은 코드 변경량 대비 효과가 커서, 먼저 적용하기 좋습니다.


체크리스트: 튜닝 후에도 느리면 여기부터 다시 보기

  • 필터가 FAST 필드로 처리되는가, 아니면 term query로 억지로 거르고 있는가
  • top-k가 과도하게 크지 않은가(lexical 단계에서 1000을 뽑고 있지 않은가)
  • STORED 필드에 큰 본문이 들어가 있지 않은가
  • 세그먼트 수가 비정상적으로 많지 않은가(업데이트가 잦으면 특히)
  • 테넌트/프로젝트 단위로 검색 범위를 줄일 수 없는가

마무리: Tantivy는 “RAG의 후보 생성기”로 최적화하라

Rust+Tantivy 조합은 기본 성능이 좋지만, RAG에서는 워크로드가 일반 검색과 다릅니다. “정확한 랭킹 엔진”이 아니라 빠른 후보 생성기로 역할을 재정의하고,

  • 필터는 FAST
  • 쿼리는 고정 트리
  • fetch는 최소화
  • 세그먼트는 관리

이 4가지를 지키면, P95 기준으로 3배 개선은 충분히 현실적인 목표입니다.

다음 단계로는 하이브리드 결합(lexical+vector)에서 후보군 merge와 rerank 비용까지 포함해 end-to-end로 튜닝해야 합니다. LLM 서빙 쪽 P95/메모리까지 함께 최적화하려면 아래 글도 같이 보면 전체 병목을 한 번에 잡는 데 도움이 됩니다.