LangChain Vector Store Comparison: Choosing the Right Backend
Why your vector store choice quietly decides your whole architecture
Every LangChain tutorial teaches retrieval-augmented generation the same way: chunk your documents, embed them, stick the vectors "somewhere," and query that somewhere with cosine similarity. The tutorials spend ten minutes on chunking strategy and thirty seconds on the vector store, usually a one-liner like Chroma.from_documents(docs, embeddings). Then people ship that to production and wonder why search gets slow at 200k documents, why deployments crash on ephemeral storage, or why their AWS bill includes a managed vector database they never meant to pay for.
The vector store is not a detail. It is the component that decides whether your RAG pipeline survives contact with real traffic, how much operational surface you own, and how much you pay per month once your document count stops being a demo number. LangChain's VectorStore interface makes every backend look interchangeable at the API level — same .similarity_search(), same .add_documents() — which is exactly why people don't think hard about which one they pick. Underneath that shared interface, Chroma, FAISS, Pinecone, Weaviate, and pgvector make wildly different trade-offs on persistence, scaling, filtering, and cost.
This article walks through five backends you'll actually encounter in production LangChain apps, with working code for each, and gives you a decision framework instead of a "just use X" answer — because the honest answer is "it depends on your traffic, your data volume, and who's paying the infrastructure bill."
How LangChain's VectorStore abstraction actually works
Before comparing backends, it helps to know what LangChain is standardizing. Every vector store integration implements a common base class with roughly this surface:
from langchain_core.vectorstores import VectorStore
class VectorStore:
def add_documents(self, documents: list, **kwargs) -> list[str]:
...
def similarity_search(self, query: str, k: int = 4, **kwargs) -> list:
...
def similarity_search_with_score(self, query: str, k: int = 4, **kwargs) -> list:
...
def as_retriever(self, **kwargs):
...That as_retriever() method is the piece that matters most in practice, because it's what plugs into chains and agents:
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
collection_name="course_notes",
embedding_function=embeddings,
persist_directory="./chroma_db",
)
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 5, "fetch_k": 20},
)Notice search_type="mmr" — Maximal Marginal Relevance. This is a retrieval strategy, not a database feature, and it's supported across most LangChain vector store integrations because it's implemented at the LangChain layer, re-ranking whatever candidates the backend returns. That's the pattern to internalize: some capabilities (MMR, self-query retrievers, multi-vector retrievers) live in LangChain itself and work the same regardless of backend. Others — metadata filtering syntax, hybrid search, sharding, replication — are backend-specific and leak through the abstraction the moment you need them.
Chroma: the default that's good for more than you'd think
Chroma is what most LangChain tutorials reach for, and it deserves more credit than "the toy option." It runs embedded (in-process, SQLite-backed) or as a standalone server, supports metadata filtering, and persists to disk without any external service.
from langchain_chroma import Chroma
from langchain_community.document_loaders import DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
loader = DirectoryLoader("./docs", glob="**/*.md")
raw_docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=120)
chunks = splitter.split_documents(raw_docs)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db",
collection_name="docs_v1",
)
results = vectorstore.similarity_search(
"how do I configure retries in a chain",
k=4,
filter={"source": "retry_guide.md"},
)Chroma's metadata filter dict ({"source": "retry_guide.md"}) is straightforward and, as of recent versions, supports operators like $and, $or, $gte for numeric fields. That's usually enough for a single-tenant app.
Where Chroma struggles: concurrent writes at scale, and multi-node deployments. The embedded mode is single-process by design — fine for a course-notes chatbot, risky for a multi-tenant SaaS product hitting the same collection from twenty API workers. Chroma's server mode helps, but it's still not built for the write-heavy, horizontally-scaled case. Use Chroma when your corpus is under a few hundred thousand chunks, you want zero external infrastructure, and you're running a single app instance or a small cluster with a shared volume.
FAISS: fastest option when you control the deployment
FAISS (Facebook AI Similarity Search) is a library, not a database. There's no server, no persistence layer built in beyond what you wire up yourself, and no metadata filtering unless you build it. What you get in exchange is raw speed — FAISS's approximate nearest neighbor indexes (IVF, HNSW) are some of the fastest available, and everything runs in-process with no network hop.
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = FAISS.from_documents(chunks, embeddings)
vectorstore.save_local("./faiss_index")
loaded = FAISS.load_local(
"./faiss_index",
embeddings,
allow_dangerous_deserialization=True,
)
docs_and_scores = loaded.similarity_search_with_score(
"explain prompt caching",
k=4,
)
for doc, score in docs_and_scores:
print(f"{score:.4f} — {doc.page_content[:80]}")That allow_dangerous_deserialization=True flag isn't decoration — FAISS indexes are pickled, and loading a pickle you didn't generate is a real attack surface. Only load FAISS indexes from sources you control.
FAISS makes sense for batch pipelines, notebooks, evaluation harnesses, and read-heavy apps where you rebuild the index on a schedule (nightly ingestion job, for example) rather than needing live upserts. It's a poor fit for anything with per-user data isolation or frequent incremental updates, because there's no native multi-tenancy and no built-in metadata query language — you'd be filtering results in Python after retrieval, which defeats a chunk of the performance benefit.
Pinecone: the managed option when ops isn't your job
Pinecone trades control for zero operational burden. You don't manage servers, you don't tune index parameters, and it scales to tens of millions of vectors without you touching infrastructure.
from pinecone import Pinecone, ServerlessSpec
from langchain_pinecone import PineconeVectorStore
from langchain_openai import OpenAIEmbeddings
import os
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
index_name = "course-search"
if index_name not in [i.name for i in pc.list_indexes()]:
pc.create_index(
name=index_name,
dimension=1536,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = PineconeVectorStore.from_documents(
documents=chunks,
embedding=embeddings,
index_name=index_name,
)
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 6,
"filter": {"module": {"$eq": "langchain-agents"}},
}
)Pinecone's namespace feature is the underrated piece here — it gives you cheap multi-tenancy without separate indexes:
vectorstore = PineconeVectorStore(
index_name=index_name,
embedding=embeddings,
namespace=f"tenant-{tenant_id}",
)Each namespace is logically isolated within the same index, so a per-customer RAG app doesn't need a new index per customer. The cost model is the honest trade-off: you pay per pod or per read/write unit depending on plan, and at meaningful scale that bill grows faster than a self-hosted Postgres instance would. Pinecone is the right call when your team doesn't want to own database operations, when you need multi-region availability out of the box, or when your vector count is heading into the tens of millions and you don't want to be the one tuning HNSW parameters at 2 a.m.
Weaviate: when you need hybrid search and structured filtering
Weaviate distinguishes itself with native hybrid search — combining vector similarity with BM25 keyword search in a single query, weighted by an alpha parameter. If your users search with exact terms (product SKUs, error codes, API method names) as often as they search with natural language, pure vector search under-performs, and hybrid search is the fix.
import weaviate
from langchain_weaviate.vectorstores import WeaviateVectorStore
from langchain_openai import OpenAIEmbeddings
client = weaviate.connect_to_local()
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = WeaviateVectorStore.from_documents(
documents=chunks,
embedding=embeddings,
client=client,
index_name="CourseDocs",
)
results = vectorstore.similarity_search(
"RecursiveCharacterTextSplitter chunk_overlap default",
k=5,
)
client.close()For the hybrid query itself, you typically drop to the native client since LangChain's wrapper doesn't expose every Weaviate-specific parameter:
from weaviate.classes.query import MetadataQuery
collection = client.collections.get("CourseDocs")
response = collection.query.hybrid(
query="chunk_overlap default value",
alpha=0.5,
limit=5,
return_metadata=MetadataQuery(score=True),
)
for obj in response.objects:
print(obj.properties["text"][:100], obj.metadata.score)Weaviate also has a real schema system — classes with typed properties, cross-references between objects, and GraphQL-style structured filters that go beyond flat metadata dicts. That's valuable when your documents have genuine relational structure (a course has modules, modules have lessons, lessons have chunks) and you want to filter or traverse that structure at query time, not just tag chunks with flat key-value pairs.
The cost of that power is operational: Weaviate needs a server, whether self-hosted via Docker or Weaviate Cloud. It's a heavier commitment than Chroma for a small project, and it's worth it specifically when hybrid search or relational schema is a real requirement, not a nice-to-have.
pgvector: when your data already lives in Postgres
pgvector turns Postgres into a vector store via an extension, which means your embeddings live next to your relational data — same database, same transactions, same backups, same access controls your team already understands.
from langchain_postgres import PGVector
from langchain_openai import OpenAIEmbeddings
connection_string = "postgresql+psycopg://user:pass@localhost:5432/coursedb"
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = PGVector(
embeddings=embeddings,
collection_name="lesson_chunks",
connection=connection_string,
use_jsonb=True,
)
vectorstore.add_documents(chunks)
results = vectorstore.similarity_search(
"vector index HNSW vs IVFFlat",
k=4,
filter={"course_id": "langchain-tutorial-2026"},
)Because it's real SQL underneath, you can join vector search results against relational tables in ways no dedicated vector database allows directly:
SELECT c.lesson_title, c.content, e.embedding <=> '[0.012, -0.034, ...]' AS distance
FROM lesson_chunks c
JOIN enrollments en ON en.course_id = c.course_id
WHERE en.user_id = $1
ORDER BY distance
LIMIT 5;That query filters vector search by a user's actual enrollments in the same statement — no separate metadata sync, no second system to keep consistent. For an app like a course platform where "what can this user see" is a relational fact, that matters more than raw ANN search speed.
The trade-off is index tuning becomes your job. pgvector supports both IVFFlat and HNSW indexes, and HNSW generally gives better recall/speed trade-offs for most workloads:
CREATE INDEX ON lesson_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);Get those parameters wrong and query latency degrades in ways that are less forgiving than a purpose-built vector database, which tunes this for you. pgvector is the right choice when you already run Postgres, when relational joins against your vectors are a real requirement rather than a hypothetical, and when your scale is in the low millions of vectors rather than hundreds of millions.
Metadata filtering: the feature that differs most across backends
This is where the "just swap the vector store" fantasy breaks down fastest. Every backend implements filtering differently, and migrating between them means rewriting your filter logic, not just changing an import.
# Chroma style
vectorstore.similarity_search(query, filter={"category": "tutorial", "level": {"$gte": 2}})
# Pinecone style
vectorstore.similarity_search(query, filter={"category": {"$eq": "tutorial"}, "level": {"$gte": 2}})
# pgvector style — plain dict maps to JSONB containment/comparison under the hood
vectorstore.similarity_search(query, filter={"category": "tutorial"})
# Weaviate — typically expressed via native Filter objects, not a LangChain-level dict
from weaviate.classes.query import Filter
collection.query.near_text(
query=query,
filters=Filter.by_property("category").equal("tutorial"),
)If you expect to switch vector stores later — a common plan for teams that start on Chroma and expect to "graduate" to Pinecone — write a thin filter-building function in your own codebase rather than hardcoding backend-specific filter dicts throughout your retrieval logic. It's a small amount of discipline that saves a full rewrite later.
Benchmarking retrieval quality before you commit to a backend
Most comparisons stop at feature checklists and skip the part that actually matters: does this backend return the right chunks for your specific documents and your specific queries? A vector database that's blazing fast but retrieves the wrong passage is worse than a slow one that gets it right, because a slow-but-correct retrieval just adds latency, while a fast-but-wrong retrieval sends your LLM confidently hallucinating on top of irrelevant context.
Build a small evaluation harness before you pick a backend, not after you've migrated twice. It doesn't need to be elaborate — a set of representative queries with the chunk IDs you'd expect to see in the top results is enough to start:
eval_set = [
{
"query": "how do I set a custom retry policy on an LLM call",
"expected_sources": ["retry_guide.md", "llm_config.md"],
},
{
"query": "difference between RunnableSequence and RunnableParallel",
"expected_sources": ["lcel_composition.md"],
},
]
def recall_at_k(vectorstore, eval_set, k=4):
hits = 0
for case in eval_set:
results = vectorstore.similarity_search(case["query"], k=k)
retrieved_sources = {doc.metadata.get("source") for doc in results}
if retrieved_sources & set(case["expected_sources"]):
hits += 1
return hits / len(eval_set)
score = recall_at_k(vectorstore, eval_set, k=4)
print(f"recall@4: {score:.2%}")Run the same eval set against every backend you're considering, with the same chunking and the same embedding model, and you get an apples-to-apples recall number instead of a vibe. This also catches a subtle trap: two backends using the same embedding model can still return different top-k results because of how they implement approximate nearest neighbor search. HNSW and IVF indexes trade exact recall for speed differently, and the default parameters Chroma ships with are not the same trade-off Pinecone's serverless index makes under the hood. If your eval set shows a meaningful recall gap between backends at the same k, that's a real signal, not noise — chase it down before committing.
It's also worth testing under load, not just correctness. Spin up a script that fires concurrent queries and measures p50/p95 latency:
import time
import concurrent.futures
def timed_query(vectorstore, query):
start = time.perf_counter()
vectorstore.similarity_search(query, k=4)
return time.perf_counter() - start
queries = [case["query"] for case in eval_set] * 20
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
latencies = list(executor.map(lambda q: timed_query(vectorstore, q), queries))
latencies.sort()
p50 = latencies[len(latencies) // 2]
p95 = latencies[int(len(latencies) * 0.95)]
print(f"p50: {p50*1000:.1f}ms p95: {p95*1000:.1f}ms")Embedded backends like Chroma and FAISS look great in a single-threaded notebook and can behave very differently once ten API workers hit them concurrently, because you're now contending for the same process's CPU and I/O rather than distributing load across a server cluster.
Migrating between backends without starting over
Teams rarely pick the right backend on the first try, and that's fine as long as migration isn't a full rewrite. Because LangChain document objects are backend-agnostic — just page_content plus a metadata dict — the actual document and chunking pipeline doesn't need to change when you swap the store underneath it. What changes is the initialization call and, as covered above, your filter syntax.
A practical pattern is to keep your ingestion pipeline producing a stable, serialized intermediate representation rather than writing straight into a specific vector store:
import json
def documents_to_jsonl(chunks, path):
with open(path, "w") as f:
for doc in chunks:
f.write(json.dumps({
"content": doc.page_content,
"metadata": doc.metadata,
}) + "\n")
def jsonl_to_documents(path):
from langchain_core.documents import Document
docs = []
with open(path) as f:
for line in f:
row = json.loads(line)
docs.append(Document(page_content=row["content"], metadata=row["metadata"]))
return docsWith chunks persisted independently of any vector store, migrating from Chroma to Pinecone, or from FAISS to pgvector, is a re-embedding and re-indexing job, not a re-chunking-and-re-parsing-every-source-document job. Given that re-parsing PDFs and re-chunking is usually the slowest and most fragile part of the pipeline, decoupling it from your vector store choice is one of the highest-leverage things you can do early, even if you're confident about your current backend.
One more migration trap worth naming: re-embedding cost. If you have 500k chunks and you're paying per token for embeddings, switching embedding models as part of a vector store migration can be the expensive part, not the database swap itself. Keep the embedding model and the vector store as two separate decisions where possible, so you're not forced to eat both costs at once.
A decision framework instead of a recommendation
Rather than naming a single winner, here's how to actually decide:
- Prototyping or a course project with under 100k chunks — Chroma. Zero infrastructure, persists to disk, good enough filtering.
- Batch pipelines, nightly-rebuilt indexes, no live updates needed — FAISS. Fastest raw retrieval, no server to run.
- Production SaaS, don't want to own database ops, need to scale unpredictably — Pinecone. Pay for convenience, get multi-region and namespace-based multi-tenancy for free.
- Users search with exact keywords as often as natural language, or your documents have real relational structure — Weaviate. Native hybrid search and schema are worth the operational cost.
- You already run Postgres and need vector search to respect relational access rules (multi-tenant apps, enrollment-gated content) — pgvector. One database, one backup strategy, one place to reason about access control.
Also weigh three questions that cut across all five: How many vectors will you actually have in twelve months, not today? Do you need metadata filters that combine multiple conditions, or just a single tag? And who on your team owns uptime for this component — because "no server to manage" and "someone else's server to manage" are very different guarantees when retrieval breaks in production at 3 a.m.
Common mistakes that show up regardless of backend
A few problems recur across every vector store choice, and they're worth fixing before you argue about which database to use:
- Embedding dimension mismatches after switching embedding models — if you move from
text-embedding-3-small(1536 dims) to a different model, you must rebuild the index, not just re-embed new documents into the old one. - Chunk size chosen for the embedding model's context window instead of for retrieval quality — smaller, semantically coherent chunks usually retrieve better than chunks sized purely to "fit."
- No re-ranking step after retrieval — raw cosine similarity from any backend returns "close enough," not "most relevant"; a cross-encoder re-ranker or LangChain's MMR retriever meaningfully improves what actually reaches your LLM's context window.
- Testing retrieval quality only by eyeballing results in a notebook, with no held-out evaluation set of query/expected-chunk pairs to catch regressions when you change chunking or embedding models.
None of these are backend-specific, which is the point: the vector store matters, but it's one variable among several, and it's usually not the first thing worth optimizing.
Wrapping up
There's no universally correct vector store for LangChain — there's a correct one for your traffic pattern, your team's operational appetite, and your data's actual shape. Chroma and FAISS get you moving fast with no infrastructure tax. Pinecone removes operational burden at a real dollar cost. Weaviate earns its keep when hybrid search or schema matters. pgvector earns its keep when your vectors need to respect relational rules you already enforce in Postgres. Pick based on those constraints, not on whichever backend had the flashiest launch post this month.
If you want to go beyond backend selection and actually build production RAG pipelines — chunking strategy, retrieval evaluation, agents that call retrievers as tools, and deploying the whole thing — that's exactly what we cover hands-on in LangChain Tutorial 2026 on TeachYou.ai, with working code for every pattern in this article and the ones we didn't have room for.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.