3333import os
3434import sys
3535import time
36+ import uuid
3637from functools import partial
3738from 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+
266373def 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
0 commit comments