Vector stores
A vector store holds your document chunks as embeddings and returns
the chunks most similar to a query. It is the search index behind a
RAG retriever tool. All four backends share one async interface,
modeled on LangChain’s VectorStore but async-first and typed
against the framework’s Chunk / Document.
Backends
| Backend | Use when |
|---|---|
InMemoryVectorStore | Default. Zero deps. Cosine over a Python list. Up to ~10K chunks before search latency bites. |
ChromaVectorStore | Persistent on-disk or hosted Chroma. Lazy import. |
PostgresVectorStore | Production durable. pgvector + asyncpg. Lazy import. |
FAISSVectorStore | Fast in-memory ANN over large corpora. Lazy import. |
Every store takes the embedder as its first argument; it is required.
from loomflow.memory.embedder import OpenAIEmbedder
from loomflow.vectorstore import ChromaVectorStore, FAISSVectorStore, InMemoryVectorStore, PostgresVectorStore
# In-memory
store = InMemoryVectorStore(embedder=OpenAIEmbedder())
# Chroma: persistent on disk
store = ChromaVectorStore(OpenAIEmbedder(), persist_directory="./chroma-db")
# Postgres: production durable
store = PostgresVectorStore(
OpenAIEmbedder(),
dsn="postgres://user:pw@host/db",
table="my_chunks",
)
# FAISS: fast ANN
store = FAISSVectorStore(embedder=OpenAIEmbedder())ChromaVectorStore also accepts client= for a Chroma client you
configured yourself (for example a hosted chromadb.HttpClient).
PostgresVectorStore opens its connection pool lazily and creates its
table and indexes on the first add(). Every store also has
from_chunks(...) / from_texts(...) factories that build a new
store and add content in one call.
The protocol
Every backend supports:
async def add(chunks: list[Chunk], ids: list[str] | None = None) -> list[str]: ... # returns ids
async def delete(ids: list[str]) -> None: ...
async def search(
query: str,
*,
k: int = 4,
filter: Mapping[str, Any] | None = None,
diversity: float | None = None, # MMR, [0, 1]
) -> list[SearchResult]: ...
async def search_by_vector(
vector: list[float],
*,
k: int = 4,
filter: Mapping[str, Any] | None = None,
diversity: float | None = None,
) -> list[SearchResult]: ...
async def count() -> int: ...
async def get_by_ids(ids: list[str]) -> list[Chunk]: ...A SearchResult carries the matched chunk, its store id, and the
similarity score: cosine in [-1, 1], higher is better, comparable
across all four backends.
Filters (Mongo-style)
# equality
results = await store.search("how to ship", k=5, filter={"source": "release.md"})
# range
results = await store.search("...", filter={"page": {"$gte": 5}})
# membership
results = await store.search("...", filter={"tag": {"$in": ["draft", "final"]}})
# composition
results = await store.search("...", filter={
"$and": [{"source": "spec.md"}, {"section": "auth"}],
})Operators: $eq (default), $ne, $gt, $gte, $lt, $lte,
$in, $nin, $exists, $and, $or, $not. They mean the same on
every backend. A missing key counts as “not equal”, so
{"tag": {"$ne": "draft"}}, {"tag": {"$nin": ["draft"]}} and
{"$not": {"tag": "draft"}} all match chunks that have no tag.
Whether a filtered search can come back with fewer than k results
when k chunks match depends on the backend:
- InMemory checks the filter against every chunk, so it never does.
- Postgres filters inside the query. pgvector’s HNSW scan returns
at most
hnsw.ef_searchrows (40 by default), so loomflow raises that to the number of rows the search needs (up to pgvector’s limit of 1000) and, on pgvector 0.8+, turns on iterative index scans, which keep going until the filter haskrows (within pgvector’shnsw.max_scan_tuples). On older pgvector the scan stops and filters what it found, so a rare filter value can come back short. - Chroma filters inside its query, except
$existsand$not, which Chroma’swherecan’t express. A filter that uses them is checked in process overmax(k * 8, 50)candidates, so a very tight one may return fewer thank. - FAISS filters an over-fetched candidate pool (
max(k * 8, 20)), so a very tight filter on a large store may return fewer thank.
Diversity (MMR)
Pass diversity in [0, 1] to rerank with Maximal Marginal Relevance:
results = await store.search("...", k=8, diversity=0.4)None(default): plain top-k by similarity.0.0: same asNone.1.0: maximum diversity (may sacrifice relevance).0.3to0.5: what most users want when they want diversity.
The diversity scale runs 0..1 (not LangChain’s inverted
lambda_mult): a higher number means more diverse results.
Hybrid search (in-memory + Postgres)
search_hybrid() combines lexical scores with vector similarity via
Reciprocal Rank Fusion, with the same signature and alpha semantics
on both stores that support it:
from loomflow.memory.embedder import OpenAIEmbedder
from loomflow.vectorstore import InMemoryVectorStore
store = InMemoryVectorStore(embedder=OpenAIEmbedder()) # or a PostgresVectorStore
results = await store.search_hybrid("recurring billing error", k=8)
# alpha in [0, 1]: 0 = pure lexical, 1 = pure vector, 0.5 = even
results = await store.search_hybrid("XKCD-42", k=8, alpha=0.3)Useful when the query has acronyms, IDs or proper nouns that embeddings miss but lexical matching catches.
InMemoryVectorStore: BM25 over the whole corpus, fused with cosine ranking.PostgresVectorStore: real Postgres full-text search. A generatedcontent_tsvcolumn (GIN-indexed) is ranked byts_rank_cdand fused with the HNSW cosine ranking. Because the lexical leg scans the whole corpus by index, an exact-term match surfaces even when it isn’t vector-close to the query. Metadata filters apply to both legs.
Postgres hybrid search needs the content_tsv column. New tables get
it automatically. If a table predates it, search_hybrid raises a
ConfigError asking you to run await store.init_schema(dimension),
which adds the column in place (idempotent; Postgres back-fills it). A
non-English corpus can pass PostgresVectorStore(..., fts_language="german")
(or "simple" for no stemming); the language is baked into the column
when it is first created.
Chroma and FAISS don’t have a hybrid method; use search() there.
Persistence (in-memory only)
await store.save("./store.json") and
await InMemoryVectorStore.load("./store.json", embedder=...)
round-trip an InMemoryVectorStore through JSON. That’s useful for
tests and small reproducible demos. For production persistence, use
Chroma or Postgres (FAISS indexes live in memory too).
Embedders
Vector stores need an embedder that satisfies the Embedder protocol:
from loomflow import HashEmbedder
from loomflow.memory.embedder import CohereEmbedder, OpenAIEmbedder, VoyageEmbedder
# Production:
embedder = OpenAIEmbedder("text-embedding-3-small")
embedder = OpenAIEmbedder("text-embedding-3-large")
embedder = VoyageEmbedder("voyage-3")
embedder = CohereEmbedder("embed-english-v3.0")
# Zero-key dev / tests:
embedder = HashEmbedder() # deterministic, no network, low qualityFor custom embedders see Custom embedder recipe.
Use the same embedder for add() and search(). Both code paths
embed text with the store’s embedder; mismatched embedders between
writes and reads silently produce near-zero similarity. When you
reopen a persisted store, pass the same embedder you indexed with.