162 lines
5.9 KiB
Python
162 lines
5.9 KiB
Python
"""Pluggable local vector-store backends for the DROID frame index.
|
|
|
|
`ingest.py` and `search.py` talk to a vector store only through the small
|
|
`VectorStore` interface here, so the same code path works whether you pick
|
|
LanceDB (`--backend lance`) or Qdrant (`--backend qdrant`). Both run fully
|
|
locally on disk — no server to stand up — so the example stays one-command.
|
|
Adding another store (Pinecone, pgvector, Milvus, …) is a third subclass.
|
|
|
|
The index is written from the columnar `(segment_id, camera, timestamp_ms,
|
|
vector)` Arrow table that `ingest.py` assembles. Searches return hits carrying
|
|
those three metadata fields plus a `similarity` in `[-1, 1]` (cosine),
|
|
normalized here so callers never have to know which backend's distance/score
|
|
convention is in play.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
import pyarrow as pa
|
|
|
|
BACKENDS = ("lance", "qdrant")
|
|
|
|
# Per-backend default on-disk location, used when `--db-path` is omitted.
|
|
DEFAULT_PATHS = {
|
|
"lance": "./droid_lancedb",
|
|
"qdrant": "./droid_qdrant",
|
|
}
|
|
|
|
|
|
class VectorStore(ABC):
|
|
"""A local on-disk vector index over `(segment_id, camera, timestamp_ms, vector)` rows."""
|
|
|
|
@abstractmethod
|
|
def write(self, table: pa.Table) -> None:
|
|
"""(Over)write the index from *table*, replacing any existing table/collection.
|
|
|
|
*table* has columns `segment_id` (string), `camera` (string),
|
|
`timestamp_ms` (int64), and `vector` (fixed-size list of float32).
|
|
"""
|
|
|
|
@abstractmethod
|
|
def search(self, vector: list[float], top_k: int) -> list[dict[str, Any]]:
|
|
"""Cosine nearest-neighbor search.
|
|
|
|
Returns up to *top_k* hit dicts, each with `segment_id`, `camera`,
|
|
`timestamp_ms`, and a cosine `similarity` (higher is closer).
|
|
"""
|
|
|
|
|
|
def open_store(backend: str, path: str, table: str) -> VectorStore:
|
|
if backend == "lance":
|
|
return LanceStore(path, table)
|
|
if backend == "qdrant":
|
|
return QdrantStore(path, table)
|
|
raise ValueError(f"Unknown backend '{backend}'; expected one of {BACKENDS}.")
|
|
|
|
|
|
class LanceStore(VectorStore):
|
|
"""LanceDB backend. Returns cosine *distance*, which we flip to similarity."""
|
|
|
|
def __init__(self, path: str, table: str) -> None:
|
|
self._path = path
|
|
self._table = table
|
|
|
|
def write(self, table: pa.Table) -> None:
|
|
import lancedb
|
|
|
|
dim = table.schema.field("vector").type.list_size
|
|
|
|
db = lancedb.connect(self._path)
|
|
tbl = db.create_table(self._table, data=table, mode="overwrite")
|
|
print(f"Wrote {table.num_rows} rows ({dim}-dim) to LanceDB table '{self._table}' in {self._path}")
|
|
|
|
# An ANN index needs enough rows to train; small demo tables fall back to
|
|
# brute-force search, which is exact and plenty fast at this scale.
|
|
try:
|
|
tbl.create_index(metric="cosine", vector_column_name="vector")
|
|
print("Built ANN index (cosine).")
|
|
except Exception as exc:
|
|
print(f"Skipped ANN index ({exc}); brute-force cosine search will be used.")
|
|
|
|
def search(self, vector: list[float], top_k: int) -> list[dict[str, Any]]:
|
|
import lancedb
|
|
|
|
tbl = lancedb.connect(self._path).open_table(self._table)
|
|
hits = tbl.search(vector).metric("cosine").limit(top_k).to_list()
|
|
return [
|
|
{
|
|
"segment_id": h["segment_id"],
|
|
"camera": h["camera"],
|
|
"timestamp_ms": h["timestamp_ms"],
|
|
"similarity": 1.0 - float(h["_distance"]), # cosine distance -> similarity
|
|
}
|
|
for h in hits
|
|
]
|
|
|
|
|
|
class QdrantStore(VectorStore):
|
|
"""Qdrant backend in local (embedded) mode. Returns cosine *score* directly."""
|
|
|
|
def __init__(self, path: str, collection: str) -> None:
|
|
self._path = path
|
|
self._collection = collection
|
|
|
|
def write(self, table: pa.Table) -> None:
|
|
from qdrant_client import QdrantClient, models
|
|
|
|
dim = table.schema.field("vector").type.list_size
|
|
segment_ids = table.column("segment_id").to_pylist()
|
|
cameras = table.column("camera").to_pylist()
|
|
timestamps_ms = table.column("timestamp_ms").to_pylist()
|
|
vectors = table.column("vector").to_pylist()
|
|
|
|
client = QdrantClient(path=self._path)
|
|
|
|
# Mirror Lance's overwrite semantics: drop any prior collection first.
|
|
if client.collection_exists(self._collection):
|
|
client.delete_collection(self._collection)
|
|
client.create_collection(
|
|
collection_name=self._collection,
|
|
vectors_config=models.VectorParams(size=dim, distance=models.Distance.COSINE),
|
|
)
|
|
|
|
points = [
|
|
models.PointStruct(
|
|
id=i,
|
|
vector=vectors[i],
|
|
payload={
|
|
"segment_id": segment_ids[i],
|
|
"camera": cameras[i],
|
|
"timestamp_ms": timestamps_ms[i],
|
|
},
|
|
)
|
|
for i in range(table.num_rows)
|
|
]
|
|
client.upsert(collection_name=self._collection, points=points)
|
|
print(f"Wrote {table.num_rows} rows ({dim}-dim) to Qdrant collection '{self._collection}' in {self._path}")
|
|
|
|
def search(self, vector: list[float], top_k: int) -> list[dict[str, Any]]:
|
|
from qdrant_client import QdrantClient
|
|
|
|
client = QdrantClient(path=self._path)
|
|
result = client.query_points(
|
|
collection_name=self._collection,
|
|
query=vector,
|
|
limit=top_k,
|
|
with_payload=True,
|
|
)
|
|
return [
|
|
{
|
|
"segment_id": payload["segment_id"],
|
|
"camera": payload["camera"],
|
|
"timestamp_ms": payload["timestamp_ms"],
|
|
"similarity": float(point.score), # Qdrant cosine score is already a similarity
|
|
}
|
|
for point in result.points
|
|
if (payload := point.payload) is not None
|
|
]
|