Files
2026-07-13 13:05:14 +08:00

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
]