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:

Python · 17 lines
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)

Python · 3 lines
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):

Python · 8 lines
# 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

Python
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 - distance for cosine, -distance for 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

Python · 3 lines
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):

Python · 3 lines
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

Python · 5 lines
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_id at construction. Every add() injects tenant_id into metadata; every search()/delete_by_filter() ANDs your filter with {"tenant_id": tenant_id} automatically.
  • Pinecone: pass namespace at construction, or override per-call via each method's namespace= 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 FAISSVectorDatabase instance per tenant, or implement your own metadata-based filtering on top of doc_id_filter.

Building a minimal RAG retrieval step

Python · 3 lines
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.

Related pages