검색 논문을 읽다 보면 같은 dataset과 BM25인데도 결과가 달라질 때가 있다. analyzer, BM25 parameter, index version, query field, cutoff, evaluator 중 하나만 달라도 run은 달라진다.
Pyserini는 이 실험 경로를 Python에서 재현하기 위한 toolkit이다. sparse와 dense retrieval을 실행하고, 공개 test collection의 prebuilt index와 topics, qrels, evaluation command를 같은 project 안에서 연결한다.
이 글은 2026-07-22 기준 최신 공개 release인 Pyserini 2.3.0에 고정했다. source의 pyproject.toml, release note, 사용 문서를 직접 확인했다.
Pyserini가 감싸는 것
Pyserini를 Python으로 다시 구현한 검색 engine이라고 생각했는데 정확하지 않았다.
Python API와 CLI
├─ PyJNIus
│ └─ Anserini
│ └─ Apache Lucene
├─ dense encoder와 ONNX runtime
├─ Faiss 또는 Lucene dense index
└─ topics, qrels, TREC run, evaluation wrapper
전통적인 BM25와 learned sparse retrieval은 주로 Lucene index를 사용한다. dense retrieval은 Faiss뿐 아니라 Lucene의 flat 또는 HNSW index도 사용할 수 있다. Pyserini의 Python layer는 검색 algorithm 자체보다 이 구성 요소를 같은 실험 인터페이스로 연결하는 역할이 크다.
공식 README는 primary design target을 multi-stage ranking의 first-stage retrieval과 재현 가능한 IR research로 설명한다. 현재 REST API와 MCP server도 제공하지만, 그것이 production search의 multi-tenancy, authorization, incremental indexing, monitoring, SLA를 자동으로 해결한다는 뜻은 아니다.
2.3.0의 실행 조건
2.3.0 release note에서 확인한 조합은 다음과 같다.
| 구성 요소 | 확인한 version 또는 조건 |
|---|---|
| Pyserini | 2.3.0 |
| Python | 3.12 이상 |
| Java | 21 |
| Anserini | 2.2.0 |
| Lucene | 10.4.0 |
pyproject.toml은 Python >=3.12, NumPy >=2, PyTorch >=2.9, Transformers >=5,<6, ONNX Runtime >=1.23 등을 요구한다. 단순 BM25 실험만 하더라도 기본 package dependency가 가볍지는 않다.
Faiss는 여러 CPU와 GPU variant 때문에 기본 dependency에 포함되지 않는다. Faiss 기반 dense search를 사용할 때 실행 환경에 맞는 package를 별도로 설치해야 한다. Lucene HNSW를 쓰는 dense path와 혼동하면 안 된다.
재현 환경은 system Python보다 독립 environment에 고정하는 편이 안전하다.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pyserini==2.3.0
python --version
java -version
python -c "import pyserini; print(pyserini.__path__)"
이 글을 작성한 local shell은 Java 21이었지만 기본 Python은 3.9.6이었다. 따라서 Pyserini 2.3.0 검색 command를 local에서 실행했다고 쓰지 않았다. 아래 command는 2.3.0 공식 문서와 source의 argument parser를 대조해 작성했고, metric 계산은 NIST trec_eval을 직접 build해 별도로 검증했다.
가장 작은 BM25 corpus를 만든다
직접 만든 corpus를 indexing하는 가장 단순한 입력은 id와 contents를 가진 JSON document다. JSON 한 개씩, JSON array, JSONL directory를 지원한다. 여기서는 JSONL을 사용한다.
demo/corpus/docs.jsonl 파일을 만든다.
{"id":"D1","contents":"bearing replacement procedure for pump maintenance"}
{"id":"D2","contents":"bearing failure diagnosis and vibration inspection"}
{"id":"D3","contents":"inventory replenishment policy for spare parts"}
index를 만든다.
python -m pyserini.index.lucene \
--collection JsonCollection \
--input demo/corpus \
--index demo/index \
--generator DefaultLuceneDocumentGenerator \
--threads 1 \
--language en \
--storeRaw
--storeRaw는 검색 점수에 필요한 option이 아니라 원문을 다시 꺼내기 위한 option이다. 공식 문서에 따르면 아무 storage option도 주지 않아도 term frequency 기반 bag-of-words 검색은 가능하다.
| index option | 추가되는 기능 |
|---|---|
--storeRaw | 원본 JSON fetch |
--storePositions | phrase와 position 기반 기능 |
--storeDocvectors | relevance feedback에 필요한 document vector |
필요하지 않은 option까지 켜면 index가 커진다. 반대로 RAG에서 검색된 본문을 prompt로 넘겨야 하는데 raw나 별도 document store가 없으면 docid만 얻고 내용을 가져오지 못한다.
Python API로 한 query를 검색한다
local index는 LuceneSearcher에 directory를 넘겨 연다.
import json
from pyserini.search.lucene import LuceneSearcher
searcher = LuceneSearcher("demo/index")
searcher.set_bm25(k1=0.9, b=0.4)
hits = searcher.search("bearing replacement", k=100)
for rank, hit in enumerate(hits, start=1):
raw = json.loads(searcher.doc(hit.docid).raw())
print(rank, hit.docid, hit.score, raw["contents"])
hit.docid는 corpus가 부여한 external string id다. hit.lucene_docid는 Lucene index 내부의 integer id다. 내부 id는 ingestion order와 index instance에 따라 달라질 수 있으므로 application의 stable key로 저장하면 안 된다.
Python API의 search() 기본 k는 10이다. 위 예제는 Recall@100을 계산하려고 100을 명시했다. 평가 cutoff가 100인데 검색 단계에서 10개만 만들면 evaluator가 나머지 90개를 복구해 주지 않는다.
Batch query를 TREC run으로 만든다
demo/topics.tsv에는 query id와 query를 tab으로 구분해 넣는다. file extension도 .tsv여야 parser가 형식을 판별할 수 있다.
q1 bearing replacement
CLI로 top-100 TREC run을 만든다.
python -m pyserini.search.lucene \
--index demo/index \
--topics demo/topics.tsv \
--output demo/run.txt \
--bm25 \
--k1 0.9 \
--b 0.4 \
--hits 100 \
--threads 1 \
--language en
TREC run 한 줄의 구조는 다음과 같다.
<qid> Q0 <docid> <rank> <score> <run-tag>
CLI의 --hits 기본값은 1000이고 Python API의 기본 k는 10이다. 같은 이름의 검색을 실행했다고 기록해도 interface에 따라 candidate 수가 달라질 수 있다. 그래서 command 전체와 생성된 run file을 실험 artifact로 보존해야 한다.
BM25 parameter도 명시했다. LuceneSearcher.set_bm25()의 기본은 k1=0.9, b=0.4다. 다만 CLI는 알려진 prebuilt index alias에 대해 별도 parameter를 자동 적용하기도 한다. 예를 들어 source는 msmarco-v1-passage에 k1=0.82, b=0.68을 설정한다. alias를 path로 바꾸거나 API와 CLI를 섞으면 자동 설정 여부가 달라질 수 있으므로 최종 parameter를 log에 남긴다.
qrels를 만들고 두 지표를 같이 계산한다
평가에는 query별 relevance judgment가 필요하다. demo/qrels.txt를 다음처럼 만든다.
q1 0 D1 2
q1 0 D2 1
q1 0 D3 0
한 줄은 query id, iteration, document id, relevance 순서다. 두 지표를 한 run에서 계산한다.
python -m pyserini.eval.trec_eval \
-q -c \
-m ndcg_cut.10 \
-m recall.100 \
demo/qrels.txt demo/run.txt
- nDCG@10은 qrels의 graded relevance와 top-10 순서를 본다.
- Recall@100은 relevance threshold 이상인 문서가 top-100 후보에 들어왔는지 본다.
-q는 query별 값도 출력한다.-c는 qrels에는 있지만 run에 빠진 query를 0으로 평균에 포함한다.
Pyserini 2.3.0의 evaluator source를 읽으며 찾은 주의점도 있다. CLI는 여러 -m 결과를 모두 출력한다. 반면 trec_eval() 함수를 Python에서 직접 호출해 여러 metric을 요청하면 내부 dictionary가 query id만 key로 사용해 마지막 metric value가 앞 값을 덮는다. source에도 TODO가 남아 있다. programmatic evaluation에서는 metric을 하나씩 호출하거나 stdout을 구조적으로 parsing하기 전에 이 동작을 확인해야 한다.
Prebuilt index는 편하지만 무료 download가 아니다
공개 corpus는 index alias만으로 열 수 있다.
from pyserini.search.lucene import LuceneSearcher
searcher = LuceneSearcher.from_prebuilt_index("msmarco-v1-passage")
hits = searcher.search("what is a lobster roll?", k=10)
없는 index는 자동으로 내려받아 ~/.cache/pyserini/indexes/ 아래에 저장한다. 공식 metadata에서 msmarco-v1-passage 기본 index의 uncompressed size는 2.6GB다. slim은 627MB지만 raw text를 저장하지 않아 fetch할 수 없고, full은 position과 document vector까지 포함해 4.3GB다.
slim → 작은 bag-of-words index, raw fetch 불가
default → term frequency와 raw text
full → position, doc vector, raw text까지 포함
CI나 container에서 alias를 처음 열 때 network와 disk cost가 발생한다. 정확한 index variant, metadata checksum, cache warm-up 여부를 기록하지 않으면 latency와 재현 결과가 흔들린다.
Sparse와 dense는 같은 command의 option 차이만이 아니다
Pyserini가 여러 retrieval family를 한 project에서 제공해도 index와 query representation은 서로 다르다.
| 방식 | 대표 entry point | 주 index |
|---|---|---|
| BM25 | LuceneSearcher | Lucene inverted index |
| learned sparse | LuceneImpactSearcher | Lucene impact index |
| dense exact 또는 ANN | Lucene dense searcher | Lucene flat 또는 HNSW |
| dense | FaissSearcher | Faiss flat, HNSW, PQ 계열 |
| hybrid | HybridSearcher | sparse와 dense 결과 결합 |
BM25 index를 만든 뒤 encoder 이름만 넣는다고 dense index가 되지 않는다. document encoding, vector index 생성, query encoder, similarity와 normalization이 맞아야 한다.
Hybrid도 model 이름 하나가 아니다. sparse와 dense에서 각각 몇 개를 가져오는지, score를 어떻게 정규화하고 가중하는지, 중복 docid를 어떻게 합치는지가 결과를 바꾼다. Pyserini는 실험 도구를 제공하지만 실험 설계를 대신 결정하지는 않는다.
한국어에서는 language option도 model configuration이다
Pyserini의 MIRACL reproduction command는 한국어 BM25에서 index와 search 모두 --language ko를 사용한다.
python -m pyserini.index.lucene \
--collection JsonCollection \
--input corpus-ko \
--index index-ko \
--generator DefaultLuceneDocumentGenerator \
--language ko \
--threads 1 \
--storeRaw
python -m pyserini.search.lucene \
--index index-ko \
--topics topics-ko.tsv \
--output run-ko.txt \
--language ko \
--bm25 --hits 100
Python API에서는 searcher.set_language("ko")를 맞춰야 한다. Pyserini 2.3.0이 의존하는 Anserini 2.2.0 source는 ko를 Lucene KoreanAnalyzer에 연결한다. index analyzer와 query analyzer가 다르면 같은 표면형도 token이 달라질 수 있다.
다만 --language ko를 넣었다는 사실만으로 사내 품번, 화면 ID, 복합 명사, 약어가 원하는 방식으로 보존된다고 보장할 수는 없다. analyzer의 실제 token stream을 확인하고 exact identifier는 별도 field나 registry로 분리해야 한다.
재현 가능한 run에 남길 것
Pyserini를 사용했다는 한 문장만으로는 실험을 재현할 수 없다. 최소한 다음을 함께 저장한다.
Pyserini, Anserini, Lucene, Python, Java version
index alias 또는 build command와 checksum
corpus snapshot과 document generator
language, analyzer, tokenizer
BM25 k1과 b 또는 encoder checkpoint
topics file과 query preprocessing
hits, threads, batch size
qrels version과 relevance threshold
evaluator command와 version
TREC run file
latency 측정 환경과 cache 상태
run file은 특히 중요하다. 최종 metric만 있으면 model 차이인지 evaluator 차이인지 역추적하기 어렵다. 동일한 run에 evaluator만 바꿔 gain과 cutoff 차이를 확인할 수 있어야 한다.
Pyserini를 쓰기 좋은 경우와 아닌 경우
잘 맞는 경우
- 논문의 공개 BM25, sparse, dense baseline을 재현할 때
- prebuilt index와 topics, qrels를 같은 이름으로 연결할 때
- 자체 corpus에서 TREC run을 만들고 여러 retriever를 비교할 때
- Lucene과 Faiss 계열을 공통 evaluation flow에 놓을 때
- first-stage retrieval과 reranking 실험을 분리할 때
별도 검토가 필요한 경우
- online write와 실시간 index refresh가 핵심일 때
- tenant isolation, authorization filter, audit이 필요한 production service일 때
- 한국어 사용자 사전과 복잡한 field mapping을 제품 수준으로 운영할 때
- cluster scaling, failover, observability가 검색 품질만큼 중요할 때
- application DB의 최신 값을 검색 corpus로 대신하려 할 때
Pyserini에서 좋은 offline run을 만들고 production engine에서 같은 analyzer와 scoring을 다시 구현할 수도 있다. 이때 두 system의 결과가 같다고 가정하지 말고 query별 diff와 metric을 다시 계산해야 한다.
내가 기억할 한 문장
Pyserini의 가장 큰 가치는 검색 함수 한 번이 아니라 corpus, index, topics, qrels, run, evaluator를 다시 실행 가능한 실험 단위로 묶는 것이다.
먼저 작은 corpus에서 indexing과 fetch, batch run, nDCG@10, Recall@100을 끝까지 연결한다. 그다음 같은 protocol로 BM25, dense, hybrid를 바꿔야 무엇이 성능을 바꿨는지 설명할 수 있다.
References
- Jimmy Lin et al. Pyserini paper. SIGIR, 2021.
- Castorini. Pyserini 2.3.0 source.
- Castorini. Pyserini 2.3.0 release note.
- Castorini. Installation guide.
- Castorini. Indexing guide.
- Castorini. Search guide.
- Castorini.
LuceneSearchersource. - Castorini. Lucene search CLI source.
- Castorini. Pyserini evaluator source.
- Castorini. Anserini 2.2.0 language analyzer mapping.
공식 문서와 source는 2026-07-22에 다시 확인했다.
댓글