Persistent vector search¶
aaa.data.InMemoryIndex is fine for tests and small corpora; anything bigger, or anything that must survive a restart, needs a vector store.
AAA splits the job in two so each part stays small:
- a
VectorStoreholds vectors with a payload (text and metadata) and finds the nearest ones. It never embeds text. - an
EmbeddingIndex(store, embedder)adds the text side and implements theIndexprotocol, soRAGAgent,search_toolandNativeMemoryrun on a persistent store without changing.
"""Vector search: documents with metadata, filtered search, a per-tenant wall. Offline (swap in PgVectorStore / QdrantStore)."""
import asyncio
from aaa.data.index import Doc
from aaa.vector import EmbeddingIndex, F, InMemoryVectorStore, Record, TenantVectorStore
VOCAB = ["refund", "shipping", "invoice", "password", "returns", "login"]
class WordEmbedder: # stands in for LLMEmbedder(client): counts vocabulary words
async def embed(self, texts):
return [[float(t.lower().split().count(w)) + 0.01 for w in VOCAB] for t in texts]
async def main() -> None:
# In production: PgVectorStore("postgresql://...", dimension=1536) or QdrantStore(url="http://localhost:6333", dimension=1536)
store = InMemoryVectorStore(dimension=len(VOCAB))
index = EmbeddingIndex(store, WordEmbedder())
await index.upsert(
[
Doc(id="a", text="refund policy for returns", meta={"lang": "en", "year": 2023}),
Doc(id="b", text="shipping and returns", meta={"lang": "en", "year": 2021}),
Doc(id="c", text="reset your password at login", meta={"lang": "en", "year": 2024}),
Doc(id="d", text="refund policy for returns", meta={"lang": "fr", "year": 2024}),
]
)
hits = await index.search("refund returns", k=4)
print(
"all:",
[h.doc.id for h in hits],
f"(scores are {hits[0].metric}, higher is better: {hits[0].higher_is_better})",
)
recent = await index.search(
"refund returns", k=4, filter=(F("lang") == "en") & (F("year") >= 2023)
)
print("english, 2023 or later:", [h.doc.id for h in recent])
acme, globex = TenantVectorStore(store, "acme"), TenantVectorStore(store, "globex")
await acme.upsert([Record("1", [1.0, 0, 0, 0, 0, 0], "acme invoice terms", {"lang": "en"})])
await globex.upsert([Record("1", [1.0, 0, 0, 0, 0, 0], "globex invoice terms", {"lang": "en"})])
wide_open = (F("lang") == "en") | (F("lang") != "en") # a filter that matches every row...
print(
"acme sees:", [m.text for m in await acme.query([1.0, 0, 0, 0, 0, 0], 10, filter=wide_open)]
) # ...still only its own
asyncio.run(main())
all: ['a', 'd', 'b', 'c'] (scores are cosine, higher is better: True)
english, 2023 or later: ['a', 'c']
acme sees: ['acme invoice terms']
Stores¶
| Store | Install | Notes |
|---|---|---|
InMemoryVectorStore |
none | exact, brute force; the reference the shared tests compare the others to |
PgVectorStore(dsn, dimension=, metric=, table=) |
pip install "autonomous-agent-architecture[pgvector]" + CREATE EXTENSION vector |
one table, HNSW index, jsonb metadata, filters become SQL |
QdrantStore(url=… \| location=":memory:", dimension=) |
pip install "autonomous-agent-architecture[qdrant]" |
server, cloud, or in-process |
SqliteVecStore("vectors.db", dimension=) |
[sqlitevec] |
one file; brute force and exact; every filter runs in SQL before the top-k cut; needs a Python whose sqlite3 can load extensions |
LanceDBStore("./lance", dimension=) |
[lancedb] |
embedded directory or object storage; metadata filters are exact but evaluated client-side over growing candidate batches (see below) |
PineconeStore(index_name=… \| index_host=…, dimension=) |
[pinecone] |
Pinecone's own namespaces; metadata flat with a 40 KB limit; writes wait for visibility (consistent=True); no delete_where (serverless cannot) |
ChromaStore(path=… \| host=…, dimension=) |
[chroma] |
embedded or server; one collection per namespace; flat metadata (nested objects flattened for filtering, exact original kept) |
Anything else can implement VectorStore (about six methods).
from aaa.vector import EmbeddingIndex
from aaa.vector.pgvector import PgVectorStore
from aaa.llm import LLMEmbedder
store = PgVectorStore("postgresql://user:pw@host/db", dimension=1536)
await store.setup() # creates the extension, table and indexes once
index = EmbeddingIndex(store, LLMEmbedder(client))
Filters¶
F("year") >= 2020, (F("lang") == "en") & F("tag").is_in(["a", "b"]), ~F("archived").exists(), dotted paths for nested metadata, or a dict:
{"lang": "en", "year": {"$gte": 2020}}. The meaning is identical in every store (and tested against all of them): == needs the field present, equal and
of the same type (1 does not match "1"); != also matches records without the field; ranges compare numbers with numbers and strings with strings.
Filters are applied before the top-k cut, so a rare match is found even when thousands of unrelated records are closer to the query. A store that cannot
express a filter raises UnsupportedFilter instead of fetching more and filtering afterwards (which silently returns fewer than k): Qdrant, for example, cannot do
ranges over strings. The old Index style filter, a Python function over the Doc, still works through EmbeddingIndex, which over-fetches and keeps asking
until it has k survivors (bounded by max_overfetch); prefer a Filter on a large index.
What each store cannot do natively¶
Refused with UnsupportedFilter (never approximated):
| Store | Cannot express |
|---|---|
| Qdrant | ranges over strings |
| Chroma | ranges over strings, equality with null, exists, negated ranges |
| Pinecone | ranges over strings, equality with null, negated ranges; also no delete-by-filter |
| LanceDB | nothing is refused, but non-namespace filters are not pushed down: a very selective filter on a large namespace scans a lot. Choose pgvector or Qdrant if you need that at scale |
| pgvector, sqlite-vec, in-memory | everything in the filter language |
Hybrid search: meaning plus exact words¶
Similarity finds what a query means; it blurs exact strings (an error code, a name, a part number). Hybrid search also ranks by keyword relevance and fuses the two
rankings by reciprocal rank (score = sum(1 / (60 + rank))), so ZX4411 finds the record that contains it even when its embedding is far from the question's.
index = EmbeddingIndex(store, embedder, hybrid="auto") # hybrid wherever the store provides it
hits = await index.search("pump error ZX4411", k=5, hybrid=True) # or per call; True raises HybridUnavailable if the store cannot
| Store | Keyword leg |
|---|---|
InMemoryVectorStore |
BM25 (the reference) |
PgVectorStore |
PostgreSQL full-text: a stored generated tsv column + GIN index (added automatically, also to tables from 0.2); text_search_config="english" picks the language |
SqliteVecStore |
SQLite FTS5 (BM25), kept in step by triggers; rows written before the index existed are indexed on first open; needs an SQLite built with FTS5 (else HybridUnavailable, dense search unaffected) |
TenantVectorStore |
passes through to its store, inside the tenant wall |
Qdrant, LanceDB, Chroma and Pinecone do not provide it here (their keyword search needs sparse vectors or an extra index that this release does not manage):
hybrid="auto" falls back to dense search on them, hybrid=True raises. Any word of the query may match (OR semantics), query text is never interpreted as search syntax,
filters and namespaces apply to both legs, and fused scores are RRF values: higher is better, and not comparable with similarity scores (Hit.metric == "rrf").
Scores¶
Match.score is a similarity for cosine and dot (higher is better) and a distance for euclidean (lower is better); Hit carries metric and
higher_is_better. Scores from different stores, metrics or embedding models are not comparable. Qdrant keeps cosine vectors normalised to unit length.
Several tenants in one store¶
Two independent walls: every call is pinned to the namespace acme (a hard partition in the store), and every record carries meta["tenant"] while every query and delete
is ANDed with tenant == "acme" outside the caller's own filter, so an or in that filter cannot reach another tenant's rows. Addressing another namespace, or upserting a record that claims
another tenant, raises. acme.erase() removes everything of that tenant (tenant offboarding, erasure requests). The tests include filter-escape attempts against every store.
Reranking¶
RerankingIndex(index, LLMReranker(client), fetch_k=30) fetches generously and lets a model reorder the best k; an unreadable reply keeps the original order. CohereReranker(api_key) calls Cohere's rerank endpoint instead (plain HTTP; built from Cohere's published request/response, mock-tested, not run live). Voyage and Jina rerankers are not included: their response formats could not be confirmed from the documentation.
Things to know¶
- Dimension. The first upsert fixes it (pgvector and Qdrant are told up front); a later vector of another size raises
DimensionMismatch, for writes and for queries. A different embedding model with the same dimension cannot be detected: re-index when you change models. - pgvector at scale. One HNSW index serves the table, and namespace/filter conditions apply to its candidates, so a very selective filter on a huge table can return fewer than
krows unless you raiseef_search; for hard partitions at scale use a table per tenant (table=) or partition the table yourself. Table names are validated and quoted; values are always parameters. - Qdrant keeps every namespace in one collection (an indexed payload field), which is Qdrant's recommended multi-tenant layout; the in-process mode ignores payload indexes.
- Verified. pgvector runs the shared contract suite against a real PostgreSQL with the extension, Qdrant against its real client in
:memory:mode (not a server), sqlite-vec, LanceDB and Chroma against the real libraries (sqlite-vec 0.1.9, LanceDB 0.40, Chroma 1.5), and Pinecone against Pinecone Local, the official emulator, with both SDK 10 and SDK 7 (not a live Pinecone account; SDK 10's index-creation call is rejected by the emulator, so the tests create the index directly): filters, namespaces, tenant escape attempts, dimension checks, hostile values. Not run: Qdrant server/cloud, very large indexes, concurrent writers.