- Published on
Rust+Tantivy로 RAG 검색속도 3배 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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 검색은 보통 다음 중 하나입니다.
- 벡터 검색 전 필터링/부스팅: 특정 키워드/필드 매칭을 강제
- 하이브리드: BM25 결과와 벡터 결과를 섞어 후보군 확대
- 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 millisi64- 권한/태그는 카디널리티에 따라
- 낮으면
u64enum(비트마스크도 가능) - 높으면 별도 인덱스 분리 또는 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단계 전략:
- Tantivy:
top_k_lexical을 50~200 수준으로 제한 - 벡터/크로스 인코더 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배”가 자주 나옵니다.
QueryParser제거 + 고정 쿼리 트리(파싱/예측불가 비용 제거)- 필터 필드
FAST화 및 타입 정리(u64/i64) - STORED fetch 최소화(본문 전체를 Tantivy에서 꺼내지 않기)
- 세그먼트 수 관리(merge policy/커밋 전략)
- 테넌트 단위 인덱스 분리(가능한 경우 가장 강력)
특히 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/메모리까지 함께 최적화하려면 아래 글도 같이 보면 전체 병목을 한 번에 잡는 데 도움이 됩니다.