Skip to content

Commit 1a9ba75

Browse files
Anush008andylizf
andauthored
feat: Vector search with Qdrant (#111)
Co-authored-by: Andy Lee <andylizf@outlook.com>
1 parent 05a8f1f commit 1a9ba75

9 files changed

Lines changed: 978 additions & 157 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,59 @@ pixelrag embed --shard-dir ./tiles --output-dir ./embeddings --gpu-ids 0,1
249249
pixelrag build-index --embeddings-dir ./embeddings --output-dir ./index
250250
```
251251

252+
### Qdrant backend
253+
254+
[Qdrant](https://qdrant.tech) is an open-source vector search engine for high-performance and massive scale. [FAISS](https://ai.meta.com/tools/faiss/) remains the default for local indexes. Use Qdrant for configurable quantization, disk-backed vectors, payload filtering, and one collection shared by multiple PixelRAG servers.
255+
256+
Quantization compresses vectors to reduce memory use and speed up search, with a recall tradeoff that depends on the method and settings.
257+
258+
To configure quantization, pass any Qdrant `quantization_config` object in a JSON file.
259+
See Qdrant's [quantization guide](https://qdrant.tech/documentation/manage-data/quantization/#setting-up-quantization-in-qdrant) for supported methods and parameters.
260+
261+
```json
262+
{
263+
"scalar": {
264+
"type": "int8",
265+
"quantile": 0.99,
266+
"always_ram": true
267+
}
268+
}
269+
```
270+
271+
```bash
272+
pip install 'pixelrag[serve,qdrant]' # or 'pixelrag[index,qdrant]'
273+
274+
# Build against a Qdrant server.
275+
# Start one locally with: docker run -p 6333:6333 qdrant/qdrant
276+
pixelrag build-index --embeddings-dir ./embeddings --output-dir ./index \
277+
--backend qdrant --qdrant-url http://localhost:6333 --collection pixelrag \
278+
--qdrant-quantization-config ./quantization.json
279+
280+
# Add documents to an existing collection.
281+
pixelrag build-index --embeddings-dir ./more --output-dir ./index \
282+
--backend qdrant --qdrant-url http://localhost:6333 --collection pixelrag --append
283+
284+
# Replace an existing collection and its configuration.
285+
pixelrag build-index --embeddings-dir ./embeddings --output-dir ./index \
286+
--backend qdrant --qdrant-url http://localhost:6333 --collection pixelrag --recreate
287+
288+
# Serve the collection. PixelRAG reads the backend from summary.json.
289+
pixelrag serve --index-dir ./index --qdrant-url http://localhost:6333 \
290+
--qdrant-client-config ./qdrant-client.json --port 30001
291+
```
292+
293+
Configure the orchestrator in `pixelrag.yaml`:
294+
295+
```yaml
296+
index:
297+
backend: qdrant
298+
qdrant_url: http://localhost:6333
299+
collection: pixelrag
300+
client_config: ./qdrant-client.json
301+
quantization_config: ./quantization.json
302+
# Set append: true or recreate: true when the collection already exists.
303+
```
304+
252305
### Training
253306

254307
Fine-tuning lives in `train/` — a **separate uv project** (`wiki-screenshot-training`) with its own

embed/src/pixelrag_embed/index.py

Lines changed: 172 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import os
3434
import sys
3535
import time
36+
import uuid
3637
from functools import partial
3738
from pathlib import Path
3839

@@ -243,7 +244,7 @@ def build_ivf(
243244

244245
# Summary
245246
summary = {
246-
"backend": "ivf",
247+
"backend": "faiss",
247248
"total_vectors": n,
248249
"dimension": dim,
249250
"nlist": nlist,
@@ -263,6 +264,112 @@ def build_ivf(
263264
print(f"Summary: {summary_path}")
264265

265266

267+
def build_qdrant(
268+
embeddings_dir: str,
269+
output_dir: str,
270+
url: str | None = None,
271+
collection: str = "pixelrag",
272+
api_key: str | None = None,
273+
client_config: dict | None = None,
274+
metric: str = "ip",
275+
quantization_config: dict | None = None,
276+
append: bool = False,
277+
recreate: bool = False,
278+
parallel: int = 1,
279+
batch: int = 1000,
280+
):
281+
from pydantic import TypeAdapter
282+
from qdrant_client import QdrantClient, models
283+
284+
client_options = dict(client_config or {})
285+
if url:
286+
client_options["url"] = url
287+
if api_key:
288+
client_options["api_key"] = api_key
289+
if not any(key in client_options for key in ("url", "host", "location", "path")):
290+
raise SystemExit(
291+
"Qdrant requires --qdrant-url or an endpoint in --qdrant-client-config"
292+
)
293+
294+
client = QdrantClient(**client_options)
295+
exists = client.collection_exists(collection)
296+
if exists and not (append or recreate):
297+
raise ValueError(
298+
f"collection {collection!r} already exists. Use --append or --recreate"
299+
)
300+
301+
os.makedirs(output_dir, exist_ok=True)
302+
merged = _merge_all_shards(_load_shards(embeddings_dir))
303+
vectors = np.ascontiguousarray(merged["embeddings"], dtype=np.float32)
304+
dim = merged["dim"]
305+
306+
distance = models.Distance.COSINE if metric == "ip" else models.Distance.EUCLID
307+
quantization = (
308+
TypeAdapter(models.QuantizationConfig).validate_python(quantization_config)
309+
if quantization_config
310+
else None
311+
)
312+
if recreate or not exists:
313+
if exists:
314+
client.delete_collection(collection)
315+
client.create_collection(
316+
collection,
317+
vectors_config=models.VectorParams(
318+
size=dim, distance=distance, on_disk=True
319+
),
320+
quantization_config=quantization,
321+
)
322+
323+
# min_tile_height is the only payload filter used during search.
324+
client.create_payload_index(
325+
collection, "tile_height", field_schema=models.PayloadSchemaType.INTEGER
326+
)
327+
328+
fields = {
329+
"article_id": merged["article_ids"],
330+
"tile_index": merged["tile_indices"],
331+
"chunk_index": merged["chunk_indices"],
332+
"y_offset": merged["y_offsets"],
333+
"tile_height": merged["tile_heights"],
334+
}
335+
336+
# Qdrant only allows UUIDs and +ve integers as point IDs.
337+
# Ref: https://qdrant.tech/documentation/manage-data/points/#point-ids
338+
ids = (
339+
str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{article_id}:{tile_index}:{chunk_index}"))
340+
for article_id, tile_index, chunk_index in zip(
341+
fields["article_id"], fields["tile_index"], fields["chunk_index"]
342+
)
343+
)
344+
payloads = (
345+
{name: int(values[i]) for name, values in fields.items()}
346+
for i in range(len(vectors))
347+
)
348+
349+
client.upload_collection(
350+
collection_name=collection,
351+
vectors=vectors,
352+
payload=payloads,
353+
ids=ids,
354+
parallel=parallel,
355+
batch_size=batch,
356+
wait=True,
357+
)
358+
359+
total = client.count(collection_name=collection, exact=True).count
360+
summary = {
361+
"backend": "qdrant",
362+
"total_vectors": total,
363+
"dimension": dim,
364+
"metric": metric,
365+
"collection": collection,
366+
}
367+
summary_path = os.path.join(output_dir, "summary.json")
368+
with open(summary_path, "w") as f:
369+
json.dump(summary, f, indent=2)
370+
print(f"Uploaded {total:,} points to '{collection}'")
371+
372+
266373
def test_search(index_dir: str, nprobe: int = 128, k: int = 10):
267374
"""Test search on a built IVF index."""
268375
import faiss
@@ -306,7 +413,7 @@ def main():
306413
sub = parser.add_subparsers(dest="command", required=True)
307414

308415
# build
309-
p_build = sub.add_parser("build", help="Build IVF index (default)")
416+
p_build = sub.add_parser("build", help="Build a vector index")
310417
p_build.add_argument("--embeddings-dir", default="./data/embeddings")
311418
p_build.add_argument("--output-dir", default="./output/search_index")
312419
p_build.add_argument(
@@ -336,6 +443,38 @@ def main():
336443
default=-1,
337444
help="GPU for K-means training (-1 = CPU only)",
338445
)
446+
p_build.add_argument(
447+
"--backend",
448+
choices=["faiss", "qdrant"],
449+
default="faiss",
450+
help="Index backend (default: faiss)",
451+
)
452+
p_build.add_argument(
453+
"--qdrant-url", default=None, help="Qdrant server/Cloud URL (qdrant backend)"
454+
)
455+
p_build.add_argument("--qdrant-api-key", default=os.environ.get("QDRANT_API_KEY"))
456+
p_build.add_argument(
457+
"--qdrant-client-config",
458+
help="Path to a JSON object of QdrantClient constructor arguments",
459+
)
460+
p_build.add_argument(
461+
"--collection", default="pixelrag", help="Qdrant collection name"
462+
)
463+
p_build.add_argument(
464+
"--qdrant-quantization-config",
465+
help="Qdrant quantization_config JSON for a new or recreated collection",
466+
)
467+
qdrant_mode = p_build.add_mutually_exclusive_group()
468+
qdrant_mode.add_argument(
469+
"--append",
470+
action="store_true",
471+
help="Upsert into an existing Qdrant collection",
472+
)
473+
qdrant_mode.add_argument(
474+
"--recreate",
475+
action="store_true",
476+
help="Delete and recreate an existing Qdrant collection",
477+
)
339478

340479
# test
341480
p_test = sub.add_parser("test", help="Test search on built index")
@@ -346,15 +485,37 @@ def main():
346485
args = parser.parse_args()
347486

348487
if args.command == "build":
349-
build_ivf(
350-
args.embeddings_dir,
351-
args.output_dir,
352-
nlist=args.nlist,
353-
nprobe=args.nprobe,
354-
train_sample=args.train_sample,
355-
metric=args.metric,
356-
gpu_id=args.gpu_id,
357-
)
488+
if args.backend == "qdrant":
489+
client_config = None
490+
if args.qdrant_client_config:
491+
with open(args.qdrant_client_config) as f:
492+
client_config = json.load(f)
493+
quantization_config = None
494+
if args.qdrant_quantization_config:
495+
with open(args.qdrant_quantization_config) as f:
496+
quantization_config = json.load(f)
497+
build_qdrant(
498+
args.embeddings_dir,
499+
args.output_dir,
500+
url=args.qdrant_url,
501+
collection=args.collection,
502+
api_key=args.qdrant_api_key,
503+
client_config=client_config,
504+
metric=args.metric,
505+
quantization_config=quantization_config,
506+
append=args.append,
507+
recreate=args.recreate,
508+
)
509+
else:
510+
build_ivf(
511+
args.embeddings_dir,
512+
args.output_dir,
513+
nlist=args.nlist,
514+
nprobe=args.nprobe,
515+
train_sample=args.train_sample,
516+
metric=args.metric,
517+
gpu_id=args.gpu_id,
518+
)
358519
elif args.command == "test":
359520
test_search(args.index_dir, nprobe=args.nprobe, k=args.k)
360521

index/src/pixelrag_index/pipelines.py

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -404,35 +404,54 @@ def _repl(m: re.Match) -> str:
404404
cmd += ["--backend", embed_cfg["backend"]]
405405
subprocess.run(cmd, check=True)
406406

407-
# Stage 4: Build FAISS index
408-
# Auto-adjust nlist based on vector count (IVF needs nlist <= n_vectors)
409-
import numpy as np
407+
# Stage 4: Build the configured search index.
408+
index_cfg = config.get("index", {})
409+
backend = index_cfg.get("backend", "faiss")
410+
cmd = [
411+
sys.executable,
412+
"-m",
413+
"pixelrag_embed.index",
414+
"build",
415+
"--embeddings-dir",
416+
str(embeddings_dir),
417+
"--output-dir",
418+
str(output),
419+
"--backend",
420+
backend,
421+
]
422+
if backend == "qdrant":
423+
logger.info("Stage 4/4: Building Qdrant collection...")
424+
if index_cfg.get("qdrant_url"):
425+
cmd += ["--qdrant-url", index_cfg["qdrant_url"]]
426+
if index_cfg.get("collection"):
427+
cmd += ["--collection", index_cfg["collection"]]
428+
if index_cfg.get("client_config"):
429+
cmd += ["--qdrant-client-config", index_cfg["client_config"]]
430+
if index_cfg.get("quantization_config"):
431+
cmd += [
432+
"--qdrant-quantization-config",
433+
index_cfg["quantization_config"],
434+
]
435+
if index_cfg.get("append"):
436+
cmd += ["--append"]
437+
if index_cfg.get("recreate"):
438+
cmd += ["--recreate"]
439+
else:
440+
# Auto-adjust nlist based on vector count (IVF needs nlist <= n_vectors)
441+
import numpy as np
410442

411-
npz_files = sorted(embeddings_dir.glob("shard_*.npz"))
412-
total_vectors = sum(
413-
np.load(f, mmap_mode="r")["embeddings"].shape[0] for f in npz_files
414-
)
415-
nlist = min(4096, max(1, total_vectors // 40))
416-
logger.info(
417-
"Stage 4/4: Building FAISS index (%d vectors, nlist=%d)...",
418-
total_vectors,
419-
nlist,
420-
)
421-
subprocess.run(
422-
[
423-
sys.executable,
424-
"-m",
425-
"pixelrag_embed.index",
426-
"build",
427-
"--embeddings-dir",
428-
str(embeddings_dir),
429-
"--output-dir",
430-
str(output),
431-
"--nlist",
432-
str(nlist),
433-
],
434-
check=True,
435-
)
443+
npz_files = sorted(embeddings_dir.glob("shard_*.npz"))
444+
total_vectors = sum(
445+
np.load(f, mmap_mode="r")["embeddings"].shape[0] for f in npz_files
446+
)
447+
nlist = min(4096, max(1, total_vectors // 40))
448+
logger.info(
449+
"Stage 4/4: Building FAISS index (%d vectors, nlist=%d)...",
450+
total_vectors,
451+
nlist,
452+
)
453+
cmd += ["--nlist", str(nlist)]
454+
subprocess.run(cmd, check=True)
436455

437456
logger.info("Index built at %s", output)
438457
return output

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ serve = [
4848
"pydantic>=2.0.0",
4949
]
5050
index = ["pixelrag[embed]", "markdown>=3.4"]
51-
all = ["pixelrag[embed,serve,index]"]
51+
qdrant = ["qdrant-client>=1.18.0"]
52+
all = ["pixelrag[embed,serve,index,qdrant]"]
5253
gpu = ["faiss-gpu-cu12>=1.13.2; sys_platform == 'linux'"]
5354
playwright = ["playwright>=1.40.0"]
5455
pdf = ["pdf2image>=1.16.0"]

0 commit comments

Comments
 (0)