Skip to Content
RAGVector stores

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

BackendUse when
InMemoryVectorStoreDefault. Zero deps. Cosine over a Python list. Up to ~10K chunks before search latency bites.
ChromaVectorStorePersistent on-disk or hosted Chroma. Lazy import.
PostgresVectorStoreProduction durable. pgvector + asyncpg. Lazy import.
FAISSVectorStoreFast 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_search rows (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 has k rows (within pgvector’s hnsw.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 $exists and $not, which Chroma’s where can’t express. A filter that uses them is checked in process over max(k * 8, 50) candidates, so a very tight one may return fewer than k.
  • FAISS filters an over-fetched candidate pool (max(k * 8, 20)), so a very tight filter on a large store may return fewer than k.

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 as None.
  • 1.0: maximum diversity (may sacrifice relevance).
  • 0.3 to 0.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 generated content_tsv column (GIN-indexed) is ranked by ts_rank_cd and 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 quality

For 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.

Last updated on