Serverless, in-your-own-account hybrid vector database on AWS.
dynavec fuses Amazon DynamoDB (single-digit-millisecond metadata + document store) with Amazon S3 Vectors (billion-scale, AWS-managed approximate-nearest-neighbor search) into one Python client — a drop-in alternative to Pinecone, Qdrant, Milvus, Weaviate, and OpenSearch that runs entirely inside your AWS account and bills only when you use it.
# with pip
pip install dynavec # base: boto3 + numpy only
pip install "dynavec[openai]" # + OpenAI embedder
pip install "dynavec[sentence-transformers]" # + local/offline embedder
pip install "dynavec[all]" # every embedder + framework adapters
# with uv (installs from the same PyPI index)
uv add dynavec
uv add "dynavec[all]"
Type hints are included for type checkers such as mypy and pyright.
Why dynavec
| Goal | How dynavec delivers it |
|---|---|
| Cost-effective | No always-on servers, no managed-service premium. You pay S3 Vectors storage/query + DynamoDB on-demand. Idle cost ≈ storage only. |
| Lowest latency | ANN keys come from S3 Vectors; the actual documents are hydrated from DynamoDB via BatchGetItem in single-digit ms. Warm S3 Vectors queries land ~100 ms. |
| Scale | S3 Vectors is designed to search across billions of vectors with 90%+ recall. |
| Data compliance | Every byte stays in your account, your region, your AZs. dynavec only ever calls AWS with your credentials. No third-party data plane. |
| Secure / elastic | Serverless primitives scale to zero and back automatically; IAM is the only access boundary. |
What dynavec is not pretending to be
S3 Vectors is the ANN engine — AWS manages the index internally, so you don't (and can't) choose HNSW vs SPANN vs SPFresh there. dynavec's algorithmic value is the layers around it that you do control: the two-store hybrid design, metadata pre-filtering, RRF hybrid fusion, MMR diversity reranking, namespace/partition routing, and (on the roadmap) an optional in-process hnswlib hot tier for sub-10-ms hot-partition queries. See ARCHITECTURE.md.
Architecture at a glance
┌──────────────────────── your AWS account ────────────────────────┐
upsert ───▶│ Embedder (BYO key: OpenAI / Gemini / Cohere / Bedrock / local) │
│ │ │
│ ▼ │
│ ┌─────────────┐ vector + small filterable metadata │
│ │ S3 Vectors │◀──────────────────────────────────┐ │
│ │ (ANN index) │ │ │
│ └─────────────┘ full text + rich metadata │ │
│ ┌─────────────┐◀──────────────────────────────────┘ │
│ │ DynamoDB │ │
│ │ (documents) │ │
│ └─────────────┘ │
│ │
search ──▶│ 1) query_vectors → keys+distance 2) BatchGetItem → documents │
│ 3) MMR rerank / RRF hybrid fusion → ranked SearchResults │
└───────────────────────────────────────────────────────────────────┘
Quick start
Three steps to your first semantic search — everything runs inside your own AWS account.
1. Install
pip install dynavec # or: uv add dynavec
pip install "dynavec[openai]" # add an embedder extra so dynavec can embed for you
2. Grant AWS access
dynavec needs an IAM identity with permission for Amazon S3 Vectors + Amazon DynamoDB. Create an IAM user, attach the policy below, and export its keys (or use an IAM role / profile — see Provisioning & IAM).
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_DEFAULT_REGION=us-east-1
Minimum IAM policy (click to expand)
Replace REGION and ACCOUNT_ID. dynamodb:Scan is only needed for the GraphRAG feature; the Create*/Delete* actions are only needed for auto_provision=True.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DynavecS3Vectors",
"Effect": "Allow",
"Action": [
"s3vectors:CreateVectorBucket", "s3vectors:GetVectorBucket",
"s3vectors:ListVectorBuckets", "s3vectors:DeleteVectorBucket",
"s3vectors:CreateIndex", "s3vectors:GetIndex",
"s3vectors:ListIndexes", "s3vectors:DeleteIndex",
"s3vectors:PutVectors", "s3vectors:GetVectors",
"s3vectors:ListVectors", "s3vectors:QueryVectors", "s3vectors:DeleteVectors"
],
"Resource": "*"
},
{
"Sid": "DynavecDynamoDB",
"Effect": "Allow",
"Action": [
"dynamodb:CreateTable", "dynamodb:DescribeTable", "dynamodb:DeleteTable",
"dynamodb:BatchWriteItem", "dynamodb:BatchGetItem",
"dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem",
"dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan"
],
"Resource": [
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec_*",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec_*/index/*"
]
}
]
}
3. Run your first query
from dynavec import Dynavec, DynavecConfig, Document
from dynavec.embeddings import OpenAIEmbedder # or Gemini / Bedrock / SentenceTransformer
cfg = DynavecConfig(
vector_bucket="my-vectors", # S3 vector bucket
index="docs", # vector index
table="dynavec_docs", # DynamoDB table
dimension=1536,
distance_metric="cosine",
region="us-east-1",
auto_provision=True, # create bucket + index + table if missing
)
db = Dynavec(cfg, embedder=OpenAIEmbedder(model="text-embedding-3-small"))
db.upsert(
[
Document(id="a", text="The mitochondria is the powerhouse of the cell.",
metadata={"topic": "biology", "year": 2021}),
Document(id="b", text="Rockets reach orbit at roughly 28,000 km/h.",
metadata={"topic": "space", "year": 2023}),
],
auto_metadata=True, # also attach hash/timestamp/word counts
)
hits = db.search(
"how do cells make energy?",
top_k=3,
filter={"topic": "biology"}, # S3 Vectors metadata pre-filter
rerank="mmr", # diversity-aware reranking
)
for h in hits:
print(h.score, h.id, h.text)
Bring your own vectors (no embedder needed)
db = Dynavec(cfg) # no embedder
db.upsert([Document(id="x", vector=my_1536_dim_vector, metadata={"lang": "en"})])
hits = db.search(vector=my_query_vector, top_k=5)
The metadata switch
- You provide metadata → stored verbatim (full copy in DynamoDB, filterable subset in S3 Vectors).
auto_metadata=True→ dynavec also derivescreated_at,content_hash,word_count,char_count. Your keys always win on conflict.
Control the split with DynavecConfig.filterable_keys (allowlist of keys pushed to S3 Vectors for filtering) — keep it small; S3 Vectors caps filterable metadata size per vector.
Framework integrations
LangChain
from dynavec.integrations.langchain import DynavecVectorStore
store = DynavecVectorStore(db, namespace="kb")
retriever = store.as_retriever(search_kwargs={"k": 4})
FastMCP Server (Claude Desktop, Cursor, AI agents)
Expose dynavec_search and dynavec_graph_search tools to any MCP client over stdio:
# Launch MCP server from environment variables
dynavec mcp
{
"mcpServers": {
"dynavec": {
"command": "uvx",
"args": ["--with", "dynavec[all]", "dynavec", "mcp"],
"env": {
"AWS_ACCESS_KEY_ID": "AKIA...",
"AWS_SECRET_ACCESS_KEY": "...",
"AWS_REGION": "us-east-1",
"OPENAI_API_KEY": "sk-...",
"DYNAVEC_VECTOR_BUCKET": "my-vectors",
"DYNAVEC_INDEX": "docs",
"DYNAVEC_TABLE": "dynavec_docs"
}
}
}
}
Strands Agents
Use the framework-agnostic make_retriever_fn as a native Strands tool:
pip install "dynavec[sentence-transformers]" strands-agents
from strands import Agent, tool
from dynavec.integrations.tools import make_retriever_fn
retrieve = make_retriever_fn(db, namespace="kb", top_k=2)
@tool
def search_knowledge_base(query: str) -> str:
"""Search the dynavec knowledge base for relevant passages."""
return retrieve(query)
agent = Agent(tools=[search_knowledge_base])
agent("Where is dynavec vector data stored?")
See examples/strands_retriever.py for a complete example that provisions a database and indexes sample documents. LlamaIndex integration remains on the roadmap; the core client works in any agent framework today.
Query expansion: Multi-Query and HyDE
Single-query vector search frequently misses relevant documents when queries are short, colloquial, or use different terminology than the corpus. Dynavec provides two first-class query expansion adapters:
MultiQueryRetriever
Expands a user query into diverse reformulations using an LLM, fans out searches concurrently, and merges results via Reciprocal Rank Fusion (RRF):
from dynavec import MultiQueryRetriever
retriever = MultiQueryRetriever(
db.namespace("docs"),
generate_queries=lambda q: my_llm.generate_variations(q, n=3),
top_k=4,
)
hits = retriever.search("car won't start")
HyDERetriever
Hypothetical Document Embeddings (HyDE) asks an LLM to generate an answer passage, embeds it as a document (via embed_documents), and retrieves nearest neighbours. Supports single-passage or multi-passage Centroid averaging (strategy="average") and multi-search fusion (strategy="fuse"):
from dynavec import HyDERetriever
hyde = HyDERetriever(
db.namespace("docs"),
generate_hypothetical=lambda q: my_llm.generate_answer(q),
top_k=4,
strategy="average",
include_original=True,
)
hits = hyde.search("explain dynamo db storage pricing breakdown")
You can also instantiate retrievers directly via db.as_multiquery_retriever(...) or db.namespace("docs").as_hyde_retriever(...).
Choosing an embedding dimension
Embedding dimension trades off recall against storage cost and latency. A larger dimension usually gives higher recall, but the right choice is the smallest dimension that meets your recall target — not the largest. See EMBEDDING_DIMENSIONS.md for a comparison table, the S3 Vectors 4096-dim ceiling, and a step-by-step picking guide.
Namespaces & multi-tenancy
Every write/read takes a namespace. dynavec tags each vector with its namespace and scopes queries to it automatically, so a single index can host many tenants (or many embedding "collections") with clean isolation. DynamoDB keys use escaped "{namespace}#{id}" components for even partition distribution, so # in namespaces, document IDs, and graph entity IDs remains unambiguous.
Provisioning & IAM
auto_provision=True (or db.provision()) creates the S3 vector bucket, the vector index, and the DynamoDB table idempotently. The caller needs s3vectors:* on the bucket/index and dynamodb:* on the table (scope these down in production — see ARCHITECTURE.md). For supported AWS regions and regional configuration, see REGIONS.md.
Inspecting what's provisioned
db.describe() returns an IndexInfo dataclass with the live bucket/index/table
config — dimension, distance metric, non-filterable metadata keys, and the
DynamoDB table's status and item count. Useful for a quick sanity check after
provision(), or for debugging a dimension-mismatch in production.
info = db.describe()
print(info.dimension, info.distance_metric, info.table_status)
Benchmarks
benchmarks/ measures recall@k, latency (p50/p95/p99), and estimated $/month, with a cost model comparing dynavec to Pinecone / Qdrant / Milvus / Weaviate / OpenSearch. See benchmarks/README.md.
Cost by scale
dynavec has no idle floor — you pay storage + per-request, so it stays far below cluster- and OCU-based systems, and tracks serverless Pinecone while keeping your data in-account.

Quality & latency

Comparison (1M × 768d, 1M queries/mo)
| Metric | dynavec | Pinecone | OpenSearch | Qdrant | Weaviate | Milvus/Zilliz |
|---|---|---|---|---|---|---|
| Recall@10 | 0.90 | 0.95 | 0.97 | 0.98 | 0.97 | 0.98 |
| Latency p50 (ms) | 45 | 30 | 15 | 8 | 10 | 7 |
| Latency p95 (ms) | 120 | 70 | 40 | 20 | 25 | 18 |
| Cost ($/mo) | $3 | $9 | $701 | $160 | $175 | $150 |
| Serverless (scale-to-zero) | Yes | Yes | No (OCU floor) | No (nodes) | No (nodes) | No (CU) |
| Data in your AWS account | Yes | No | Yes | Self-host only | Self-host only | Self-host only |
Honesty note: the cost row is computed by the repo's cost model from public list prices (order-of-magnitude; verify before quoting). Recall/latency are representative figures pending a live AWS run — regenerate real numbers with the commands below.
Scaling: every embedding dimension, 100K → 1 billion vectors
Cost across the common embedding dimensions (384 / 768 / 1024 / 1536 / 3072) and the full scale ladder. dynavec stays lowest at every point because its storage is priced like S3, not RAM — while cluster/OCU systems grow linearly with data held in memory.

| Cost by dimension @ 100M vectors | Raw storage footprint | |
|---|---|---|
![]() |
![]() |
1536-dim (e.g. OpenAI text-embedding-3-small) — $/month @ 1M queries/mo:
| Product | 100K | 1M | 10M | 100M | 1B |
|---|---|---|---|---|---|
| dynavec | $3 | $3 | $8 | $50 | $469 |
| Pinecone | $9 | $10 | $27 | $197 | $1,897 |
| OpenSearch | $701 | $701 | $877 | $8,423 | $83,708 |
| Qdrant | $160 | $160 | $960 | $8,640 | $85,920 |
| Weaviate | $175 | $175 | $1,050 | $9,450 | $93,975 |
| Milvus/Zilliz | $150 | $150 | $900 | $8,100 | $80,550 |
| raw float32 size | 1 GB | 6 GB | 57 GB | 572 GB | 5,722 GB |
Full tables for all five dimensions: scaling.md. At 1B × 1536-d that's ~5.7 TB of raw vectors — where dynavec's product quantization and the S3-priced tier matter most.
pip install "dynavec[benchmark]" # or: uv add "dynavec[benchmark]"
# reproduce the charts + table above
python -m benchmarks.report --vectors 1_000_000 --dim 768 --qpm 1_000_000
# measure real recall + latency against your own AWS account
python -m benchmarks.run_benchmark --backend dynavec \
--bucket my-vectors --index bench --table dynavec_bench --n 100000 --dim 768
Observability dashboard
A native, in-your-brand observability dashboard — a Langfuse-style view of real query telemetry (no simulated data). Attach a recorder and every search is captured with latency, cache outcome, result count, and score stats.

▶ Live interactive preview — in the landing-page theme.
The dashboard is a Next.js + TypeScript + Tailwind + Recharts app in dashboard/; the data comes from a tiny Python telemetry API. Two steps:
1. Expose real telemetry — attach a recorder to your client and serve the API:
from dynavec import Dynavec, DynavecConfig, SemanticCache
from dynavec.telemetry import TelemetryRecorder
from dynavec.dashboard import serve
rec = TelemetryRecorder()
db = Dynavec(cfg, embedder=emb, cache=SemanticCache(), telemetry=rec)
# ... your app runs searches; the recorder fills automatically ...
serve(rec, port=8779) # JSON API at http://127.0.0.1:8779
2. Run the dashboard (points at that API; falls back to sample data if unset):
cd dashboard
npm install
NEXT_PUBLIC_DYNAVEC_API=http://127.0.0.1:8779 npm run dev # http://localhost:3000
No AWS? python examples/dashboard_demo.py runs real searches against in-memory
stand-ins and serves the API on :8779 for the dashboard to read.
It shows a query-volume histogram, latency percentiles (p50/p95/p99), cache hit-rate, and a filterable traces table with per-trace drill-down. Contributors welcome: the Evaluation (recall@k, faithfulness), Resource (buckets/indexes/namespaces), and Cost panels are open under the dashboard epic (#122).
Capabilities
| Area | What you get | API |
|---|---|---|
| Distance metrics | Index on cosine/euclidean (S3 Vectors native); client-side rescore in cosine / dot / euclidean / manhattan or a weighted combination, with optional result-set normalization | search(..., rescore="dot", normalize_scores=True) |
| Concurrency | GIL-aware thread pool — real parallelism for I/O-bound AWS calls; parallel batched writes + search_many; tunable botocore connection pool (default 10, raise for high concurrency) |
DynavecConfig(max_workers=8, max_pool_connections=50), db.search_many([...]) |
| Streaming | Results yielded page-by-page as S3 Vectors paginates, so agents start consuming early | for hit in db.search_stream(q): ... |
| Namespace RAG | Per-tenant/collection handles; isolation + even partitioning | kb = db.namespace("kb"); kb.search(...) |
| Product quantization | Compress cached/hot-tier vectors up to 32× (ADC distance) | ProductQuantizer(m=96).fit(X) |
| Knowledge graph / ER | Entities + relations in DynamoDB linked to embeddings; traverse to scope/guide vector search (GraphRAG) | db.graph_add_edge(...), db.graph_search(q, seed_entities=[...]) |
| Query cache | DynamoDB-TTL exact cache, in-process semantic cache (serves near-duplicate queries), or Redis/ElastiCache | Dynavec(..., cache=SemanticCache()) |
| Ingestion / MCP | Pull + chunk + embed from any source; any MCP server's resources (Notion, Confluence, Drive, …) become a corpus | ingest(db, MCPResourceSource(session)) |
| Updates + Lambda | Update text/vector/metadata (merge or replace); transform pipeline incl. in-account AWS Lambda | db.update(id, ...), Dynavec(..., transform=LambdaTransform(...)) |
| IAM / credentials | Access keys, session tokens, named profiles, cross-account assume-role | Dynavec(..., credentials=AWSCredentials(...)) |
| Frameworks | LangChain + LlamaIndex vector stores; a framework-agnostic tool for LangGraph/CrewAI/Strands | dynavec.integrations.* |
| Benchmark report | Comparison table + recall/latency + cost-by-scale (log) charts | python -m benchmarks.report |
SemanticCache can be bounded by both entry count and approximate in-memory
size. Pass max_bytes to account for each cached float32 query vector and its
result object graph, and inspect size_bytes for the current accounted size:
cache = SemanticCache(max_size=2_048, max_bytes=64 * 1024 * 1024)
Pre-populate the cache from a list of common queries at startup with warm_cache() —
it runs each query once (through search, so results land in the cache) and
returns the number of queries processed:
from dynavec import Dynavec, DynavecConfig, SemanticCache, warm_cache
db = Dynavec(DynavecConfig(...), embedder=..., cache=SemanticCache())
warm_cache(db, ["what is vector search", "how do i upsert documents"], top_k=10)
Pass the same filter / rescore / rerank options you use at runtime so the
warmed entries share cache keys with real queries.
Remove graph entities and relations with graph_delete_node() and
graph_delete_edge(). Both are idempotent and return the number of edges removed.
Deleting a node also strips every edge pointing at it (a namespace scan) but
leaves its linked documents and their embeddings untouched:
db.graph_delete_edge("acme", "competes_with", "globex", namespace="kb", bidirectional=True)
db.graph_delete_node("globex", namespace="kb")
Status
v0.5.0 (current) — adds office-document ingestion (Docx/Pptx/Xlsx), a Hugging Face Inference embedder, a DSPy retrieval integration, opt-in structured JSON logging (with secret redaction), ProductQuantizer save/load, dashboard dark mode, and vectorized MMR reranking — on top of the v0.4 in-memory hot tier and the v0.1 hybrid core.
See the full history in CHANGELOG.md, the browsable Release notes page, or the GitHub Releases tab.
Roadmap (v0.6): optional hnswlib/faiss hot-tier backend for very large hot sets, sparse/BM25 hybrid computed from DynamoDB, and OPQ (rotated product quantization).
Publishing (maintainers)
dynavec publishes to PyPI; both pip and uv install from there (there is no separate "uv registry").
Automated (recommended) — a GitHub Release triggers .github/workflows/publish.yml, which builds and uploads via PyPI Trusted Publishing (OIDC) — no API token stored anywhere. One-time setup: on PyPI, add a pending publisher for project dynavec, repo codeforstartups/dynavec, workflow publish.yml, environment pypi. Then:
git tag v0.3.0 && git push origin v0.3.0 # then publish a GitHub Release for the tag
Manual — if you'd rather push from your machine with a token:
uv build # -> dist/*.whl, dist/*.tar.gz
uv publish # uses UV_PUBLISH_TOKEN / prompts
# or: python -m twine upload dist/*
Bump the version in both pyproject.toml and src/dynavec/__init__.py before releasing.
🫂 Community
If you want to get more involved with dynavec, join our WhatsApp community. It's a friendly space to talk about vector search, RAG, AWS costs, production issues, and everything in between — ask questions, share what you're building, or help others out.


Comments