---
title: "Pyserini로 indexing부터 nDCG와 Recall 평가까지 한 흐름으로 묶기"
slug: "pyserini-reproducible-retrieval"
category: "RAG"
topic: "rag"
subtopic: "retrieval"
tags: ["Pyserini","Anserini","Lucene","BM25","Dense Retrieval","Reproducibility","trec_eval"]
status: "published"
created: "2026-07-21"
updated: "2026-07-22"
summary: "Pyserini 2.3.0의 구조와 설치 조건을 소스에서 확인하고 작은 corpus의 indexing, BM25 검색, TREC run 생성, nDCG와 Recall 평가를 연결한다."
kind: "Deep Dive"
evidence: "Pyserini 2.3.0 공식 문서·소스·release note 확인, NIST trec_eval 교차 검증"
---

검색 논문을 읽다 보면 같은 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이라고 생각했는데 정확하지 않았다.

```text
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에 고정하는 편이 안전하다.

```bash
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` 파일을 만든다.

```json
{"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를 만든다.

```bash
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를 넘겨 연다.

```python
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가 형식을 판별할 수 있다.

```text
q1	bearing replacement
```

CLI로 top-100 TREC run을 만든다.

```bash
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 한 줄의 구조는 다음과 같다.

```text
<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`를 다음처럼 만든다.

```text
q1 0 D1 2
q1 0 D2 1
q1 0 D3 0
```

한 줄은 query id, iteration, document id, relevance 순서다. 두 지표를 한 run에서 계산한다.

```bash
python -m pyserini.eval.trec_eval \
  -q -c \
  -m ndcg_cut.10 \
  -m recall.100 \
  demo/qrels.txt demo/run.txt
```

- [nDCG@10](/posts/ndcg-at-10)은 qrels의 graded relevance와 top-10 순서를 본다.
- [Recall@100](/posts/recall-at-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만으로 열 수 있다.

```python
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다.

```text
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`를 사용한다.

```bash
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를 사용했다는 한 문장만으로는 실험을 재현할 수 없다. 최소한 다음을 함께 저장한다.

```text
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](/posts/ndcg-at-10), [Recall@100](/posts/recall-at-100)을 끝까지 연결한다. 그다음 같은 protocol로 BM25, dense, hybrid를 바꿔야 무엇이 성능을 바꿨는지 설명할 수 있다.

## References

1. Jimmy Lin et al. [Pyserini paper](https://doi.org/10.1145/3404835.3463238). SIGIR, 2021.
2. Castorini. [Pyserini 2.3.0 source](https://github.com/castorini/pyserini/tree/pyserini-2.3.0).
3. Castorini. [Pyserini 2.3.0 release note](https://github.com/castorini/pyserini/blob/8f6964c95d01980b1700381998c2448d9e6232de/docs/release-notes/release-notes-v2.3.0.md).
4. Castorini. [Installation guide](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/docs/installation.md).
5. Castorini. [Indexing guide](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/docs/usage-index.md).
6. Castorini. [Search guide](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/docs/usage-search.md).
7. Castorini. [`LuceneSearcher` source](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/pyserini/search/lucene/_searcher.py).
8. Castorini. [Lucene search CLI source](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/pyserini/search/lucene/__main__.py).
9. Castorini. [Pyserini evaluator source](https://github.com/castorini/pyserini/blob/pyserini-2.3.0/pyserini/eval/trec_eval.py).
10. Castorini. [Anserini 2.2.0 language analyzer mapping](https://github.com/castorini/anserini/blob/anserini-2.2.0/src/main/java/io/anserini/analysis/AnalyzerMap.java).

공식 문서와 source는 2026-07-22에 다시 확인했다.
