Cookbook
On this page
Practical recipes built only from methods that exist in the source. Each recipe names the exact backend method(s) it relies on so you can cross-reference the API Reference.
Swap backends behind one interface
Because all three classes implement VectorDatabaseBase's five mandatory
methods with the same signatures and return types, you can select a backend at runtime and
write the rest of your code once:
def make_db(backend: str, embedder):
if backend == "faiss":
from vector_database import FAISSVectorDatabase
return FAISSVectorDatabase(embedder=embedder, index_type="flat")
if backend == "chroma":
from vector_database import ChromaVectorDatabase
return ChromaVectorDatabase(embedder=embedder, persist_directory="./chroma_db")
if backend == "pinecone":
from vector_database import PineconeVectorDatabase
return PineconeVectorDatabase(embedder=embedder, index_name="my-index")
raise ValueError(backend)
db = make_db("faiss", embedder)
db.add(chunks)
results = db.search("query text", top_k=5)
db.remove_by_doc_id("doc-1")
print(db.get_stats(), len(db))
Only rely on the five unified methods (add, search, remove_by_doc_id, get_stats,
__len__) plus clear() if this code path must stay backend-agnostic — everything else
(search_by_doc_id, delete_by_ids, namespaces, get_by_ids, etc.) is backend-specific.
Re-indexing a document (delete-then-add)
def reindex_document(db, doc_id: str, new_chunks: list) -> None:
db.remove_by_doc_id(doc_id) # unified across all three backends
db.add(new_chunks)
For FAISS, remember remove_by_doc_id triggers a full index rebuild
(see FAISS → Delete operations) — for large indices with
frequent per-document updates, batching many documents' worth of removals before one add()
call is cheaper than interleaving them one at a time.
Filtering results by document
Each backend spells filtering differently — there is no unified kwarg name (a deliberate observation in the base class's own docstring):
# FAISS
faiss_db.search(query, top_k=5, doc_id_filter=["doc-1", "doc-2"])
# Chroma
chroma_db.search(query, top_k=5, filters={"category": "tech"})
# Pinecone
pinecone_db.search(query, top_k=5, filter_dict={"category": "tech"})
If you need one call site to support all three, branch on type(db) or maintain a small
per-backend adapter function rather than assuming a shared kwarg.
Threshold-based filtering with cross-backend caution
results = db.search(query, top_k=20, score_threshold=0.75)
Works identically across all three backends mechanically, but the meaning of the score differs:
- FAISS: raw similarity from the chosen metric (cosine similarity after normalization, inner product, or negative-isn't-applied L2 distance depending on config).
- Chroma: converted from Chroma's native distance via
_distance_to_score—1 - distancefor cosine,-distancefor L2, raw value for inner product. - Pinecone: Pinecone's own native match score, unmodified.
Don't reuse the same score_threshold value across backends or across a metric change on
the same backend — recalibrate per (backend, metric) combination.
Async usage in an async application
async def search_many(db, queries: list[str]):
import asyncio
return await asyncio.gather(*(db.asearch(q, top_k=5) for q in queries))
Every backend's asearch/aadd run the synchronous method in a thread pool
(asyncio.to_thread) — this parallelizes I/O-bound waiting (network calls to Pinecone, disk
I/O for Chroma's persistent client) but does not make FAISS's CPU-bound search faster; expect
thread-pool-bound speedups only, not true concurrency for CPU-heavy FAISS workloads.
Batch operations with centralized error handling
Chroma and Pinecone both expose a batch_operation() context manager for logging/error
handling around a group of calls (it does not defer writes or reduce network calls — see
each backend's page):
with db.batch_operation():
db.add(first_chunk_batch)
db.add(second_chunk_batch)
For Pinecone, batch_operation(namespace=...) additionally scopes the instance's default
namespace for the duration of the block, restoring the original afterward — useful for
temporarily targeting a different namespace without mutating db.namespace permanently
elsewhere in your code.
Persisting and restoring a FAISS database
db.save("./my_database")
# later, in a new process:
from vector_database import FAISSVectorDatabase
db = FAISSVectorDatabase.load("./my_database", embedder=embedder)
Only FAISSVectorDatabase implements real persistence in this codebase.
ChromaVectorDatabase persists automatically to persist_directory if you passed one (no
explicit save() call needed — its inherited save() is the base class's no-op-with-warning
default). PineconeVectorDatabase is always remotely persistent; its save()/load() are
also the inherited no-op/NotImplementedError defaults from the base class.
Multi-tenancy
- Chroma: pass
tenant_idat construction. Everyadd()injectstenant_idinto metadata; everysearch()/delete_by_filter()ANDs your filter with{"tenant_id": tenant_id}automatically. - Pinecone: pass
namespaceat construction, or override per-call via each method'snamespace=parameter. Namespaces are a first-class Pinecone concept, isolated at the index level rather than via a metadata filter. - FAISS: no built-in multi-tenancy. You'd need one
FAISSVectorDatabaseinstance per tenant, or implement your own metadata-based filtering on top ofdoc_id_filter.
Building a minimal RAG retrieval step
def retrieve_context(db, question: str, top_k: int = 5, min_score: float = 0.5) -> str:
results = db.search(question, top_k=top_k, score_threshold=min_score)
return "\n\n".join(chunk.text for chunk, _score in results)
This works against any of the three backends unmodified, since search()'s return type
(List[Tuple[DocumentChunk, float]]) is unified.