Skip to content

Commit 25307d9

Browse files
feat(scripts): bootstrap_corpus.py — idempotent Qdrant ingestion (Phase 2.3)
CLI that walks --pdf-dir, ingests each PDF via src.ingestion.pipeline. ingest_paper, and writes chunks + bge-m3 embeddings to a configured Qdrant collection. Skips re-ingestion if the collection already has points (override with --force) so re-running is a safe noop. Designed as the foundation for Phase 4's Container Apps init job; runs locally against `docker compose up qdrant ollama` for dev parity. There's no /ingest HTTP endpoint yet — deferred until live paper upload becomes a demo requirement. QdrantVectorStore gains a `count()` method that returns 0 for missing collections (instead of raising) so the idempotency check is one call. 3 unit tests cover empty, missing, and populated paths against the in-memory Qdrant client. Caveats documented in the script docstring: - BM25 + chunks_by_id are in-process state and don't survive this script's process exit. The FastAPI app would need a separate persistence path to use the populated Qdrant collection end-to-end. Phase 2.x follow-up; eval scripts rebuild fresh per run.
1 parent 5014319 commit 25307d9

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

scripts/bootstrap_corpus.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Bootstrap the corpus into a configured Qdrant (Phase 2.3).
2+
3+
Runs once per deploy / fresh local setup: walks --pdf-dir, ingests each PDF
4+
through src.ingestion.pipeline.ingest_paper, and writes the resulting chunks
5+
+ embeddings to the named Qdrant collection. Idempotent — refuses to re-ingest
6+
if the collection already has points (override with --force).
7+
8+
Designed to be the foundation for Phase 4's Container Apps init job; the same
9+
script runs locally against `docker compose up qdrant ollama` for development
10+
parity. There's no /ingest HTTP endpoint yet — that's deferred until live
11+
paper upload becomes a demo requirement.
12+
13+
Caveats (recorded so future-me doesn't have to re-derive them):
14+
15+
* BM25 lives in process memory (`src/rag/bm25.py`) — this script populates
16+
Qdrant but its BM25 dies with the process. The FastAPI app would need to
17+
rebuild BM25 from scratch at startup (or persist it separately). That's
18+
a Phase 2.x follow-up; for now eval scripts rebuild BM25 per run.
19+
* Same for `chunks_by_id` — the PipelineRetriever needs a chunk dict to
20+
resolve text after Qdrant returns chunk-ids. This script doesn't
21+
persist that either; eval / retrieval workflows materialize chunks
22+
fresh per run.
23+
24+
Usage:
25+
26+
.venv/Scripts/python.exe -m scripts.bootstrap_corpus \\
27+
--pdf-dir data/papers \\
28+
--qdrant http://localhost:6333 \\
29+
--ollama http://localhost:11434 \\
30+
--collection rag_corpus
31+
32+
Pass `--force` to re-ingest into a non-empty collection (drops + recreates).
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import argparse
38+
import asyncio
39+
from pathlib import Path
40+
41+
from src.embeddings.ollama_bge import OllamaBgeEmbedder
42+
from src.ingestion.pipeline import ingest_paper
43+
from src.observability.logging import configure_logging, get_logger
44+
from src.rag.bm25 import Bm25Index
45+
from src.rag.vectorstore import QdrantVectorStore
46+
from src.types import Paper
47+
48+
49+
async def _main(
50+
*,
51+
pdf_dir: Path,
52+
qdrant_url: str,
53+
ollama_url: str,
54+
collection: str,
55+
force: bool,
56+
) -> None:
57+
log = get_logger("scripts.bootstrap_corpus")
58+
pdf_paths = sorted(pdf_dir.glob("*.pdf"))
59+
if not pdf_paths:
60+
raise SystemExit(f"No .pdf files found in {pdf_dir}")
61+
print(f"Found {len(pdf_paths)} PDFs in {pdf_dir}")
62+
63+
embedder = OllamaBgeEmbedder(base_url=ollama_url)
64+
vectorstore = QdrantVectorStore(url=qdrant_url, collection_name=collection, dim=embedder.dim)
65+
66+
existing = await vectorstore.count()
67+
if existing > 0 and not force:
68+
print(
69+
f"Collection {collection!r} already has {existing} points — "
70+
f"skipping ingestion (pass --force to re-ingest)."
71+
)
72+
log.info("bootstrap.skip", collection=collection, existing_points=existing, force=force)
73+
return
74+
75+
if existing > 0 and force:
76+
print(f"--force: dropping existing {existing} points in {collection!r}")
77+
78+
await vectorstore.ensure_collection()
79+
bm25 = Bm25Index() # in-process, throwaway — see module docstring caveats
80+
81+
total_chunks = 0
82+
for pdf_path in pdf_paths:
83+
paper = Paper(paper_id=pdf_path.stem, title=pdf_path.stem, pdf_path=pdf_path)
84+
ingested = await ingest_paper(
85+
paper=paper,
86+
embedder=embedder,
87+
vectorstore=vectorstore,
88+
bm25=bm25,
89+
contextualizer_llm=None,
90+
contextualizer_model=None,
91+
contextualizer_concurrency=4,
92+
extract_figures_enabled=False,
93+
extract_tables_enabled=False,
94+
vlm_captioner=None,
95+
)
96+
total_chunks += ingested.chunk_count
97+
print(f" {pdf_path.name}: {ingested.chunk_count} chunks")
98+
99+
final = await vectorstore.count()
100+
print(
101+
f"\nIngested {total_chunks} chunks across {len(pdf_paths)} papers into "
102+
f"{collection!r} (collection now contains {final} points)"
103+
)
104+
log.info(
105+
"bootstrap.done",
106+
collection=collection,
107+
papers=len(pdf_paths),
108+
chunks_ingested=total_chunks,
109+
collection_size=final,
110+
)
111+
112+
113+
if __name__ == "__main__":
114+
parser = argparse.ArgumentParser(
115+
description="Idempotently ingest every PDF in --pdf-dir into a Qdrant collection."
116+
)
117+
parser.add_argument("--pdf-dir", type=Path, default=Path("data/papers"))
118+
parser.add_argument("--qdrant", default="http://localhost:6333")
119+
parser.add_argument("--ollama", default="http://localhost:11434")
120+
parser.add_argument("--collection", default="rag_corpus")
121+
parser.add_argument(
122+
"--force",
123+
action="store_true",
124+
help="Re-ingest even if the collection already contains points.",
125+
)
126+
args = parser.parse_args()
127+
128+
configure_logging(level="INFO", env="local", log_file=None)
129+
asyncio.run(
130+
_main(
131+
pdf_dir=args.pdf_dir,
132+
qdrant_url=args.qdrant,
133+
ollama_url=args.ollama,
134+
collection=args.collection,
135+
force=args.force,
136+
)
137+
)

src/rag/vectorstore.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ async def upsert_chunks(self, chunks: list[Chunk], vectors: list[list[float]]) -
6464
]
6565
await self._client.upsert(collection_name=self._collection, points=points)
6666

67+
async def count(self) -> int:
68+
"""Return the number of points in the collection; 0 if it doesn't exist.
69+
70+
Used by `scripts/bootstrap_corpus.py` for idempotent re-runs — a populated
71+
collection means ingestion already happened, skip unless --force.
72+
"""
73+
existing = await self._client.get_collections()
74+
if not any(c.name == self._collection for c in existing.collections):
75+
return 0
76+
result = await self._client.count(collection_name=self._collection, exact=True)
77+
return int(result.count)
78+
6779
async def search(self, vector: list[float], top_k: int) -> list[VectorMatch]:
6880
response = await self._client.query_points(
6981
collection_name=self._collection,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""QdrantVectorStore.count — used by scripts/bootstrap_corpus.py for idempotency.
2+
3+
Uses the in-memory Qdrant client (`url=':memory:'`) so the test runs without
4+
docker. Exercises the empty-collection, missing-collection, and populated
5+
paths.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
12+
from src.rag.vectorstore import QdrantVectorStore
13+
from src.types import Chunk
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_count_returns_zero_when_collection_missing() -> None:
18+
"""Newly-constructed store has no collection yet — count is 0, not an error."""
19+
store = QdrantVectorStore(url=":memory:", collection_name="not_yet_created", dim=4)
20+
assert await store.count() == 0
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_count_returns_zero_when_collection_empty() -> None:
25+
"""Created but empty collection counts as 0."""
26+
store = QdrantVectorStore(url=":memory:", collection_name="empty", dim=4)
27+
await store.ensure_collection()
28+
assert await store.count() == 0
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_count_reflects_upserted_chunks() -> None:
33+
store = QdrantVectorStore(url=":memory:", collection_name="populated", dim=4)
34+
await store.ensure_collection()
35+
chunks = [
36+
Chunk(chunk_id=f"p1::p1::c{i}", paper_id="p1", page_numbers=[1], text=f"chunk {i}")
37+
for i in range(3)
38+
]
39+
vectors = [[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8], [0.9, 0.0, 0.1, 0.2]]
40+
await store.upsert_chunks(chunks, vectors)
41+
assert await store.count() == 3

0 commit comments

Comments
 (0)