diff --git a/CHANGELOG.md b/CHANGELOG.md index e924037..1d96bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Concurrency and scale: a native async client, a lexical/hybrid retrieval path, per-document TTL, a new framework connector, and a graph data-loss fix. ### Added + +- **MCP write tools** (#350) - Added opt-in `dynavec_remember`, `dynavec_get`, and `dynavec_forget` tools, enabled with `dynavec mcp --enable-write`. - **Native async client** (#270) — `AsyncDynavec` offers `async`/`await` `upsert` / `search` / `get` / `delete` built on non-blocking I/O, so agent and web workloads can fan out concurrent calls without thread pools. diff --git a/README.md b/README.md index ac70b0d..83ae628 100644 --- a/README.md +++ b/README.md @@ -1,643 +1,1331 @@ -# dynavec -[![PyPI version](https://img.shields.io/pypi/v/dynavec?style=flat-square&color=e8623b&label=release)](https://pypi.org/project/dynavec/) -[![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-3776ab?style=flat-square&logo=python&logoColor=white)](https://pypi.org/project/dynavec/) -[![CI](https://img.shields.io/github/actions/workflow/status/codeforstartups/dynavec/ci.yml?branch=development&style=flat-square&label=CI)](https://github.com/codeforstartups/dynavec/actions/workflows/ci.yml) -[![License](https://img.shields.io/pypi/l/dynavec?style=flat-square&color=green)](https://github.com/codeforstartups/dynavec/blob/development/LICENSE) -**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**. -```bash -# 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) +**# dynavec** + + + +[![PyPI version]\(https\://img.shields.io/pypi/v/dynavec?style=flat-square&color=e8623b&label=release)]\(https\://pypi.org/project/dynavec/) + +[![Python versions]\(https\://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-3776ab?style=flat-square&logo=python&logoColor=white)]\(https\://pypi.org/project/dynavec/) + +[![CI]\(https\://img.shields.io/github/actions/workflow/status/codeforstartups/dynavec/ci.yml?branch=development&style=flat-square&label=CI)]\(https\://github.com/codeforstartups/dynavec/actions/workflows/ci.yml) + +[![License]\(https\://img.shields.io/pypi/l/dynavec?style=flat-square&color=green)]\(https\://github.com/codeforstartups/dynavec/blob/development/LICENSE) + + + +**\*\*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\*\***. + + + +\`\`\`bash + +\# 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. +\`\`\` + + + +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.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** + + + +\`\`\`bash + +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]\(#provisioning--iam)). + + + +\`\`\`bash + +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\`. + + + +\`\`\`json + +{ + +  "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", + +        "dynamodb:DescribeTimeToLive", "dynamodb:UpdateTimeToLive" + +      ], + +      "Resource": [ + +        "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec\_\*", + +        "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec\_\*/index/\*" + +      ] + +    } + +  ] + +} + +\`\`\` + + + +\
+ + + +**### 3. Run your first query** + + + +\`\`\`python + +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) + +\`\`\` + + + +\`search()\` returns a list of hits by default. Pass \`explain=True\` to receive an + +\`ExplainedSearchResult\` containing those hits plus timing and candidate-count + +details; the type annotations distinguish the two return shapes. + + + +**### Bring your own vectors (no embedder needed)** + + + +\`\`\`python + +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 derives \`created_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. + + + +**### Per-document TTL & automatic expiry** + + + +Expire ephemeral documents, session memories, or cache entries automatically via DynamoDB's native Time To Live (TTL): + + + +\`\`\`python + +\# Pass ttl_seconds on Document or upsert() + +db.upsert([ + +    Document(id="session-1", text="Temporary session context", ttl_seconds=3600), + +    Document(id="cached-doc", text="Ephemeral cache data", ttl_seconds=86400), + +]) + + + +\# Or set a default TTL across an entire batch + +ns = db.namespace("sessions") + +ns.upsert([Document(id="a", text="..."), Document(id="b", text="...")], ttl_seconds=1800) + +\`\`\` + + + +Dynavec automatically computes the Unix epoch timestamp, stores it in the DynamoDB \`ttl\` attribute, and enables TTL on the table during provisioning (\`auto_provision=True\` or \`provision_all\`). + + + +\--- + + + +**## Framework integrations** + + + +**### LangChain** + + + +\`\`\`python + +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: + + + +\`\`\`bash + +\# Launch MCP server from environment variables + +dynavec mcp + +\`\`\` + + + +\`\`\`json + +{ + +  "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" + +      } + +    } + +  } + +} + +\`\`\` + + + +**### Opt-in memory tools** + + + +Dynavec includes three additional MCP tools for managing stored documents: + + + +- **\`dynavec_remember\`** — store a document, optionally with an ID, metadata, namespace, and TTL. +- **\`dynavec_get\`** — retrieve documents by ID. +- **\`dynavec_forget\`** — delete documents by ID. + + + +These memory tools are **disabled by default**. Enable them explicitly when starting the MCP server: + + + +\`\`\`bash + +dynavec mcp --enable-write + +\`\`\` + + + +For SSE transport, include the same flag alongside the transport options: + + + +\`\`\`bash + +dynavec mcp --transport sse --port 8000 --enable-write + +\`\`\` + + + +Only enable write access when you trust the MCP clients connected to the server. The flag exposes document storage and deletion tools to those clients. + + + +**### Strands Agents** + + + +Use the framework-agnostic \`make_retriever_fn\` as a native Strands tool: + + + +\`\`\`bash + +pip install "dynavec[sentence-transformers]" strands-agents + +\`\`\` + + + +\`\`\`python + +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\`]\(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): + + + +\`\`\`python + +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"\`): + + + +\`\`\`python + +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]\(docs/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]\(ARCHITECTURE.md)). For supported AWS regions and regional configuration, see [REGIONS.md]\(docs/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. + + + +\`\`\`python + +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]\(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. + + + +![Monthly cost by scale]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_by_scale.png) + + + +**### Quality & latency** + + + +![Recall and latency]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/quality_latency.png) + + + +**### 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 scale and dimension]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_matrix_by_dim.png) + + + +\| | Cost by dimension @ 100M vectors | Raw storage footprint | ---- +\|---|---|---| -## Why dynavec +\| | ![Cost by dimension]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_by_dimension.png) | ![Storage footprint]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/storage_footprint.png) | -| 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.md). +**\*\*1536-dim (e.g. OpenAI \`text-embedding-3-small\`) — $/month @ 1M queries/mo:\*\*** ---- -## 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 │ - └───────────────────────────────────────────────────────────────────┘ -``` +\| Product | 100K | 1M | 10M | 100M | 1B | ---- +\|---|---|---|---|---|---| -## Quick start +\| **\*\*dynavec\*\*** | **\*\*$3\*\*** | **\*\*$3\*\*** | **\*\*$8\*\*** | **\*\*$50\*\*** | **\*\*$469\*\*** | -Three steps to your first semantic search — everything runs inside **your own AWS account**. +\| Pinecone | $9 | $10 | $27 | $197 | $1,897 | -### 1. Install +\| OpenSearch | $701 | $701 | $877 | $8,423 | $83,708 | -```bash -pip install dynavec # or: uv add dynavec -pip install "dynavec[openai]" # add an embedder extra so dynavec can embed for you -``` +\| Qdrant | $160 | $160 | $960 | $8,640 | $85,920 | -### 2. Grant AWS access +\| Weaviate | $175 | $175 | $1,050 | $9,450 | $93,975 | -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](#provisioning--iam)). +\| Milvus/Zilliz | $150 | $150 | $900 | $8,100 | $80,550 | -```bash -export AWS_ACCESS_KEY_ID=... -export AWS_SECRET_ACCESS_KEY=... -export AWS_DEFAULT_REGION=us-east-1 -``` +\| *\_raw float32 size\_* | 1 GB | 6 GB | 57 GB | 572 GB | 5,722 GB | -
-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`. -```json -{ - "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", - "dynamodb:DescribeTimeToLive", "dynamodb:UpdateTimeToLive" - ], - "Resource": [ - "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec_*", - "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/dynavec_*/index/*" - ] - } - ] -} -``` +Full tables for all five dimensions: [scaling.md]\(https\://github.com/codeforstartups/dynavec/blob/development/docs/assets/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. -
-### 3. Run your first query -```python -from dynavec import Dynavec, DynavecConfig, Document -from dynavec.embeddings import OpenAIEmbedder # or Gemini / Bedrock / SentenceTransformer +\`\`\`bash -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 -) +pip install "dynavec[benchmark]"          # or: uv add "dynavec[benchmark]" -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) -``` +\# reproduce the charts + table above -`search()` returns a list of hits by default. Pass `explain=True` to receive an -`ExplainedSearchResult` containing those hits plus timing and candidate-count -details; the type annotations distinguish the two return shapes. +python -m benchmarks.report --vectors 1_000_000 --dim 768 --qpm 1_000_000 -### Bring your own vectors (no embedder needed) -```python -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 +\# measure real recall + latency against your own AWS account -- **You provide metadata** → stored verbatim (full copy in DynamoDB, filterable subset in S3 Vectors). -- **`auto_metadata=True`** → dynavec also derives `created_at`, `content_hash`, `word_count`, `char_count`. Your keys always win on conflict. +python -m benchmarks.run_benchmark --backend dynavec \\ -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. +    --bucket my-vectors --index bench --table dynavec_bench --n 100000 --dim 768 -### Per-document TTL & automatic expiry +\`\`\` -Expire ephemeral documents, session memories, or cache entries automatically via DynamoDB's native Time To Live (TTL): -```python -# Pass ttl_seconds on Document or upsert() -db.upsert([ - Document(id="session-1", text="Temporary session context", ttl_seconds=3600), - Document(id="cached-doc", text="Ephemeral cache data", ttl_seconds=86400), -]) -# Or set a default TTL across an entire batch -ns = db.namespace("sessions") -ns.upsert([Document(id="a", text="..."), Document(id="b", text="...")], ttl_seconds=1800) -``` +\--- -Dynavec automatically computes the Unix epoch timestamp, stores it in the DynamoDB `ttl` attribute, and enables TTL on the table during provisioning (`auto_provision=True` or `provision_all`). ---- -## Framework integrations +**## Observability dashboard** -### LangChain -```python -from dynavec.integrations.langchain import DynavecVectorStore -store = DynavecVectorStore(db, namespace="kb") -retriever = store.as_retriever(search_kwargs={"k": 4}) -``` +A native, in-your-brand **\*\*observability dashboard\*\*** — a Langfuse-style view of -### FastMCP Server (Claude Desktop, Cursor, AI agents) +**\*\*real\*\*** query telemetry (no simulated data). Attach a recorder and every search -Expose `dynavec_search` and `dynavec_graph_search` tools to any MCP client over stdio: +is captured with latency, cache outcome, result count, and score stats. -```bash -# Launch MCP server from environment variables -dynavec mcp -``` -```json -{ - "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 +![dynavec observability dashboard]\(https\://raw\.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/dashboard.png) -Use the framework-agnostic `make_retriever_fn` as a native Strands tool: -```bash -pip install "dynavec[sentence-transformers]" strands-agents -``` -```python -from strands import Agent, tool +**\*\*▶ [Live interactive preview]\(**https\://codeforstartups.github.io/dynavec/dashboard/**)\*\*** — in the landing-page theme. -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) +The dashboard is a **\*\*Next.js + TypeScript + Tailwind + Recharts\*\*** app in [\`dashboard/\`]\(dashboard); the data comes from a tiny Python telemetry API. Two steps: -agent = Agent(tools=[search_knowledge_base]) -agent("Where is dynavec vector data stored?") -``` -See [`examples/strands_retriever.py`](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. ---- +**\*\*1. Expose real telemetry\*\*** — attach a recorder to your client and serve the API: -## 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): +\`\`\`python -```python -from dynavec import MultiQueryRetriever +from dynavec import Dynavec, DynavecConfig, SemanticCache -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") -``` +from dynavec.telemetry import TelemetryRecorder -### 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.dashboard import serve -```python -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(...)`. +rec = TelemetryRecorder() ---- +db = Dynavec(cfg, embedder=emb, cache=SemanticCache(), telemetry=rec) -## Choosing an embedding dimension +\# ... your app runs searches; the recorder fills automatically ... -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](docs/EMBEDDING_DIMENSIONS.md) for a comparison table, the S3 Vectors 4096-dim ceiling, and a step-by-step picking guide. +serve(rec, port=8779)          # JSON API at http\://127.0.0.1:8779 ---- +\`\`\` -## 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. ---- +**\*\*2. Run the dashboard\*\*** (points at that API; falls back to sample data if unset): -## 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](ARCHITECTURE.md)). For supported AWS regions and regional configuration, see [REGIONS.md](docs/REGIONS.md). -### Inspecting what's provisioned +\`\`\`bash -`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. +cd dashboard -```python -info = db.describe() -print(info.dimension, info.distance_metric, info.table_status) -``` +npm install ---- +NEXT_PUBLIC_DYNAVEC_API=http\://127.0.0.1:8779 npm run dev   # http\://localhost:3000 -## 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](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. +No AWS? \`python examples/dashboard_demo.py\` runs real searches against in-memory -![Monthly cost by scale](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_by_scale.png) +stand-ins and serves the API on \`:8779\` for the dashboard to read. -### Quality & latency -![Recall and latency](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/quality_latency.png) -### Comparison (1M × 768d, 1M queries/mo) +It shows a query-volume histogram, latency percentiles (p50/p95/p99), cache -| 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 | +hit-rate, and a filterable **\*\*traces\*\*** table with per-trace drill-down. -> **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. +**\*\*Contributors welcome:\*\*** the Evaluation (recall\@k, faithfulness), Resource -### Scaling: every embedding dimension, 100K → 1 billion vectors +(buckets/indexes/namespaces), and Cost panels are open under the -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. +[dashboard epic (#122)]\(https\://github.com/codeforstartups/dynavec/issues/122). -![Cost by scale and dimension](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_matrix_by_dim.png) -| | Cost by dimension @ 100M vectors | Raw storage footprint | -|---|---|---| -| | ![Cost by dimension](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/cost_by_dimension.png) | ![Storage footprint](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/storage_footprint.png) | -**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](https://github.com/codeforstartups/dynavec/blob/development/docs/assets/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. -```bash -pip install "dynavec[benchmark]" # or: uv add "dynavec[benchmark]" +**## Capabilities** -# 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 -``` ---- +\| Area | What you get | API | -## 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. +\| **\*\*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)\` | -![dynavec observability dashboard](https://raw.githubusercontent.com/codeforstartups/dynavec/development/docs/assets/dashboard.png) +\| **\*\*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([...])\` | -**▶ [Live interactive preview](https://codeforstartups.github.io/dynavec/dashboard/)** — in the landing-page theme. +\| **\*\*Streaming\*\*** | Results yielded page-by-page as S3 Vectors paginates, so agents start consuming early | \`for hit in db.search_stream(q): ...\` | -The dashboard is a **Next.js + TypeScript + Tailwind + Recharts** app in [`dashboard/`](dashboard); the data comes from a tiny Python telemetry API. Two steps: +\| **\*\*Namespace RAG\*\*** | Per-tenant/collection handles; isolation + even partitioning | \`kb = db.namespace("kb"); kb.search(...)\` | -**1. Expose real telemetry** — attach a recorder to your client and serve the API: +\| **\*\*Product quantization\*\*** | Compress cached/hot-tier vectors up to 32× (ADC distance) | \`ProductQuantizer(m=96).fit(X)\` | -```python -from dynavec import Dynavec, DynavecConfig, SemanticCache -from dynavec.telemetry import TelemetryRecorder -from dynavec.dashboard import serve +\| **\*\*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=[...])\` | -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 -``` +\| **\*\*Query cache\*\*** | DynamoDB-TTL exact cache, in-process **\*\*semantic\*\*** cache (serves near-duplicate queries), or Redis/**\*\*ElastiCache\*\***; writes evict the affected namespace's entries | \`Dynavec(..., cache=SemanticCache())\` | -**2. Run the dashboard** (points at that API; falls back to sample data if unset): +\| **\*\*Ingestion / MCP\*\*** | Pull + chunk + embed from any source; **\*\*any MCP server's resources\*\*** (Notion, Confluence, Drive, …) become a corpus | \`ingest(db, MCPResourceSource(session))\` | -```bash -cd dashboard -npm install -NEXT_PUBLIC_DYNAVEC_API=http://127.0.0.1:8779 npm run dev # http://localhost:3000 -``` +\| **\*\*Updates + Lambda\*\*** | Update text/vector/metadata (merge or replace) with **\*\*optimistic concurrency\*\*** (versioned conditional writes; \`ConflictError\` instead of lost updates); transform pipeline incl. **\*\*in-account AWS Lambda\*\*** | \`db.update(id, ..., expected_version=v)\`, \`Dynavec(..., transform=LambdaTransform(...))\` | -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. +\| **\*\*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: + + + +\`\`\`python + +cache = SemanticCache(max_size=2_048, max_bytes=64 \* 1024 \* 1024) + +\`\`\` + + + +\`upsert\`/\`update\`/\`delete\` evict the written namespace's cached queries, so -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)](https://github.com/codeforstartups/dynavec/issues/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**; writes evict the affected namespace's entries | `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) with **optimistic concurrency** (versioned conditional writes; `ConflictError` instead of lost updates); transform pipeline incl. **in-account AWS Lambda** | `db.update(id, ..., expected_version=v)`, `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: - -```python -cache = SemanticCache(max_size=2_048, max_bytes=64 * 1024 * 1024) -``` - -`upsert`/`update`/`delete` evict the written namespace's cached queries, so writes are never hidden behind stale entries. Set -`DynavecConfig(cache_invalidate_on_write=False)` to opt out. -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 +\`DynavecConfig(cache_invalidate_on_write=False)\` to opt out. + + + +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: -```python + + +\`\`\`python + 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 +\`\`\` + + + +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. + + +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: -```python + + +\`\`\`python + db.graph_delete_edge("acme", "competes_with", "globex", namespace="kb", bidirectional=True) + db.graph_delete_node("globex", namespace="kb") -``` + +\`\`\` + + Load graph records with grouped DynamoDB updates: -```python + + +\`\`\`python + result = db.graph_import( - namespace="kb", - nodes=[{"id": "acme", "ntype": "company", "props": {"country": "US"}}], - edges=[{"src": "acme", "relation": "supplies", "dst": "globex", - "weight": 2.5, "props": {"created_at": "2026-10-07T12:00:00Z"}}], - batch_size=100, + +    namespace="kb", + +    nodes=[{"id": "acme", "ntype": "company", "props": {"country": "US"}}], + +    edges=[{"src": "acme", "relation": "supplies", "dst": "globex", + +            "weight": 2.5, "props": {"created_at": "2026-10-07T12:00:00Z"}}], + +    batch_size=100, + ) -print(result.nodes_processed, result.edges_appended) # 2, 1 + +print(result.nodes_processed, result.edges_appended)  # 2, 1 + + db.graph_import_file(namespace="kb", json_file="graph.json") + db.graph_import_file(namespace="kb", nodes_csv="nodes.csv", edges_csv="edges.csv") -# Either CSV file can also be supplied alone. -``` -Both client methods default to namespace `"default"`. The storage APIs are -`GraphStore.import_graph(ns, *, nodes=None, edges=None, batch_size=100)` and -`GraphStore.import_graph_file(ns, *, json_file=None, nodes_csv=None, edges_csv=None, -batch_size=100)`. They return `GraphImportResult`: `nodes_processed` counts unique +\# Either CSV file can also be supplied alone. + +\`\`\` + + + +Both client methods default to namespace \`"default"\`. The storage APIs are + +\`GraphStore.import_graph(ns, \*, nodes=None, edges=None, batch_size=100)\` and + +\`GraphStore.import_graph_file(ns, \*, json_file=None, nodes_csv=None, edges_csv=None, + +batch_size=100)\`. They return \`GraphImportResult\`: \`nodes_processed\` counts unique + IDs, including inferred edge endpoints and already-existing nodes; -`edges_appended` counts input edge records. Empty record collections return zeros. - -- **Python:** `nodes` and `edges` are sequences of dictionaries. Nodes require a - non-empty string `id`; optional `ntype` is a string or `None`, and `props` is a - dictionary or `None`. Edges require non-empty strings `src`, `relation`, and - `dst`; optional `weight` defaults to `1.0`, and omitted/`None` `props` defaults - to `{}`. Identifiers are stored unchanged, including spaces. -- **JSON:** a UTF-8 object containing optional `nodes` and `edges` arrays using - those same fields. Missing arrays mean empty collections; `null` arrays are - invalid. For example: `{"nodes": [{"id": "acme"}], "edges": [{"src": "acme", - "relation": "supplies", "dst": "globex"}]}`. -- **CSV:** separate UTF-8 files. Node headers: required `id`, optional `ntype,props`. - Edge headers: required `src,relation,dst`, optional `weight,props`. A `props` - cell must contain a JSON object; use standard CSV quoting, e.g. - `acme,company,"{""country"":""US""}"`. Blank optional cells mean omitted values. - Supply JSON or CSV files in one call; combining formats is invalid. - -Existing edges and document links are preserved. Omitted/`None` node attributes -preserve existing values; explicit node `props` replaces the entire dictionary, -so `{}` clears it. For repeated node IDs, later supplied attributes win, while + +\`edges_appended\` counts input edge records. Empty record collections return zeros. + + + +\- **\*\*Python:\*\*** \`nodes\` and \`edges\` are sequences of dictionaries. Nodes require a + +  non-empty string \`id\`; optional \`ntype\` is a string or \`None\`, and \`props\` is a + +  dictionary or \`None\`. Edges require non-empty strings \`src\`, \`relation\`, and + +  \`dst\`; optional \`weight\` defaults to \`1.0\`, and omitted/\`None\` \`props\` defaults + +  to \`{}\`. Identifiers are stored unchanged, including spaces. + +\- **\*\*JSON:\*\*** a UTF-8 object containing optional \`nodes\` and \`edges\` arrays using + +  those same fields. Missing arrays mean empty collections; \`null\` arrays are + +  invalid. For example: \`{"nodes": [{"id": "acme"}], "edges": [{"src": "acme", + +  "relation": "supplies", "dst": "globex"}]}\`. + +\- **\*\*CSV:\*\*** separate UTF-8 files. Node headers: required \`id\`, optional \`ntype,props\`. + +  Edge headers: required \`src,relation,dst\`, optional \`weight,props\`. A \`props\` + +  cell must contain a JSON object; use standard CSV quoting, e.g. + +  \`acme,company,"{""country"":""US""}"\`. Blank optional cells mean omitted values. + +  Supply JSON or CSV files in one call; combining formats is invalid. + + + +Existing edges and document links are preserved. Omitted/\`None\` node attributes + +preserve existing values; explicit node \`props\` replaces the entire dictionary, + +so \`{}\` clears it. For repeated node IDs, later supplied attributes win, while + omitted values preserve earlier ones. Missing edge endpoints are created without + overwriting existing endpoint metadata. Duplicate edges remain duplicates, and -repeating an import appends them again: imports are **not idempotent**. + +repeating an import appends them again: imports are **\*\*not idempotent\*\***. + + Finite numeric weights may be negative or greater than one; booleans, NaN, + infinity, and numbers outside DynamoDB's supported precision/range are rejected. -Nested floats in properties are copied as `Decimal` values for DynamoDB, caller + +Nested floats in properties are copied as \`Decimal\` values for DynamoDB, caller + inputs are not mutated, and timestamp strings remain caller-provided strings. + Unsupported DynamoDB values are rejected before writing. + + Every supplied record/file is loaded and validated before any database writes, + including when both CSV files are supplied. Errors identify a record index or + filename/CSV row. Complete prevalidation uses memory proportional to the import. + Each unique node is prepared once, then edges are grouped by source and appended -in input order within that source. `batch_size` must be a positive integer + +in input order within that source. \`batch_size\` must be a positive integer + (excluding booleans) and limits edges per append request. Three edges from one -source to three destinations use five `UpdateItem` requests with the default -batch size, versus nine via three `add_edge()` calls. This is grouping, not -compression or a single AWS request; `BatchWriteItem` is not used. -The existing **400 KB DynamoDB item limit** still applies to each node's complete +source to three destinations use five \`UpdateItem\` requests with the default + +batch size, versus nine via three \`add_edge()\` calls. This is grouping, not + +compression or a single AWS request; \`BatchWriteItem\` is not used. + + + +The existing **\*\*400 KB DynamoDB item limit\*\*** still applies to each node's complete + adjacency list, even with small chunks. AWS failures can leave partial writes; + there is no whole-import rollback or exactly-once guarantee. Safe node preparation + uses the existing retry policy, but the importer does not retry edge appends or + restart completed chunks. SDK retries can still make ambiguous append failures + unsafe to replay; inspect the stored graph before deciding how to recover. -## Ecosystem + + +**## Ecosystem** + + dynavec started as a vector database, and it's growing into a full agent stack that runs inside your own cloud account. The plan has four parts: -| Project | Tagline | What it covers | Status | -|---------|---------|----------------|--------| -| dynavec | Remember | The vector database: S3 Vectors for search, DynamoDB for documents, plus caching, GraphRAG and hybrid retrieval | Available | -| dynaflow | Build & run | An orchestration engine for agents, with a visual builder | Planned ([#273](https://github.com/codeforstartups/dynavec/issues/273)) | -| dynalogs | Observe | Traces, logs, run history and metrics, kept in your own account | Early version: the [observability dashboard](#observability-dashboard) ([#274](https://github.com/codeforstartups/dynavec/issues/274)) | -| dynaevals | Measure & improve | Retrieval and RAG metrics, test datasets and CI checks | Retrieval metrics available ([#275](https://github.com/codeforstartups/dynavec/issues/275)) | -The roadmap is tracked in [#276](https://github.com/codeforstartups/dynavec/issues/276). It's a proposed order rather than a fixed schedule, and help is welcome on any of the linked issues. See [CONTRIBUTING.md](CONTRIBUTING.md) to get started. -## Status +\| Project | Tagline | What it covers | Status | + +\|---------|---------|----------------|--------| + +\| dynavec | Remember | The vector database: S3 Vectors for search, DynamoDB for documents, plus caching, GraphRAG and hybrid retrieval | Available | + +\| dynaflow | Build & run | An orchestration engine for agents, with a visual builder | Planned ([#273]\(https\://github.com/codeforstartups/dynavec/issues/273)) | + +\| dynalogs | Observe | Traces, logs, run history and metrics, kept in your own account | Early version: the [observability dashboard]\(#observability-dashboard) ([#274]\(https\://github.com/codeforstartups/dynavec/issues/274)) | -**v0.7.0 (current)** — adds a **native async client** (`AsyncDynavec`), a **BM25 lexical retriever + dense hybrid fusion** (`hybrid_search`), **per-document TTL** with automatic provisioning, a **Semantic Kernel** connector, concurrent **`search_many()`**, **optimistic concurrency** on `update()`, **weighted graph edges**, **client-side rate limiting**, and parallelized `put_vectors` ingest — plus a graph metadata-preservation fix — on top of the v0.6 feature set and the v0.1 hybrid core. +\| dynaevals | Measure & improve | Retrieval and RAG metrics, test datasets and CI checks | Retrieval metrics available ([#275]\(https\://github.com/codeforstartups/dynavec/issues/275)) | -See the full history in **[CHANGELOG.md](CHANGELOG.md)**, the browsable **[Release notes](https://codeforstartups.github.io/dynavec/docs/release-notes.html)** page, or the **[GitHub Releases](https://github.com/codeforstartups/dynavec/releases)** tab. -**Roadmap (v0.7):** native async client (`AsyncDynavec` on aioboto3), optional `hnswlib`/`faiss` hot-tier backend, and sparse/BM25 hybrid computed from DynamoDB. -## Publishing (maintainers) +The roadmap is tracked in [#276]\(https\://github.com/codeforstartups/dynavec/issues/276). It's a proposed order rather than a fixed schedule, and help is welcome on any of the linked issues. See [CONTRIBUTING.md]\(CONTRIBUTING.md) to get started. -`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`](.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: -```bash -git tag v0.3.0 && git push origin v0.3.0 # then publish a GitHub Release for the tag -``` +**## Status** -**Manual** — if you'd rather push from your machine with a token: -```bash -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. +**\*\*v0.7.0 (current)\*\*** — adds a **\*\*native async client\*\*** (\`AsyncDynavec\`), a **\*\*BM25 lexical retriever + dense hybrid fusion\*\*** (\`hybrid_search\`), **\*\*per-document TTL\*\*** with automatic provisioning, a **\*\*Semantic Kernel\*\*** connector, concurrent **\*\*\`search_many()\`\*\***, **\*\*optimistic concurrency\*\*** on \`update()\`, **\*\*weighted graph edges\*\***, **\*\*client-side rate limiting\*\***, and parallelized \`put_vectors\` ingest — plus a graph metadata-preservation fix — on top of the v0.6 feature set and the v0.1 hybrid core. -## 🫂 Community -If you want to get more involved with dynavec, join our [WhatsApp community](https://chat.whatsapp.com/D73Mf1aDyIZHHMCgd32XHg). 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. -## Contributors +See the full history in **\*\*[CHANGELOG.md]\(**CHANGELOG.md**)\*\***, the browsable **\*\*[Release notes]\(**https\://codeforstartups.github.io/dynavec/docs/release-notes.html**)\*\*** page, or the **\*\*[GitHub Releases]\(**https\://github.com/codeforstartups/dynavec/releases**)\*\*** tab. + + + +**\*\*Roadmap (v0.7):\*\*** native async client (\`AsyncDynavec\` on aioboto3), optional \`hnswlib\`/\`faiss\` hot-tier backend, and sparse/BM25 hybrid computed from DynamoDB. + + + +**## 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\`]\(.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: + + + +\`\`\`bash + +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: + + + +\`\`\`bash + +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]\(https\://chat.whatsapp.com/D73Mf1aDyIZHHMCgd32XHg). 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. + + + +**## Contributors** + + + +\`\`\` -``` +----------------------------------------------------------------------------+ -| +----------------------------------------------------------------+ | -| | Developers: Those who built with `dynavec`. | | -| | (You have `import dynavec` somewhere in your project) | | -| | +----------------------------------------------------+ | | -| | | Contributors: Those who make `dynavec` better. | | | -| | | (You make a PR to this repo) | | | -| | +----------------------------------------------------+ | | -| +----------------------------------------------------------------+ | + +\|     +----------------------------------------------------------------+     | + +\|     | Developers: Those who built with \`dynavec\`.                    |     | + +\|     | (You have \`import dynavec\` somewhere in your project)          |     | + +\|     |     +----------------------------------------------------+     |     | + +\|     |     | Contributors: Those who make \`dynavec\` better.     |     |     | + +\|     |     | (You make a PR to this repo)                       |     |     | + +\|     |     +----------------------------------------------------+     |     | + +\|     +----------------------------------------------------------------+     | + +----------------------------------------------------------------------------+ -``` -We welcome contributions from the community! Whether it's bug fixes, feature additions, or documentation improvements, your input is valuable. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide, and browse [good first issues](https://github.com/codeforstartups/dynavec/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) to get started. +\`\`\` + + + +We welcome contributions from the community! Whether it's bug fixes, feature additions, or documentation improvements, your input is valuable. See [CONTRIBUTING.md]\(CONTRIBUTING.md) for the full guide, and browse [good first issues]\(https\://github.com/codeforstartups/dynavec/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) to get started. + + + +1\. Fork the repository + +2\. Create your feature branch (\`git checkout -b feature/AmazingFeature\`) + +3\. Commit your changes (\`git commit -m 'Add some AmazingFeature'\`) + +4\. Push to the branch (\`git push origin feature/AmazingFeature\`) + +5\. Open a Pull Request + + + +**## License** -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request -## License Apache-2.0 diff --git a/benchmarks/benchmark_opq.py b/benchmarks/benchmark_opq.py index 573da69..8617e9f 100644 --- a/benchmarks/benchmark_opq.py +++ b/benchmarks/benchmark_opq.py @@ -1,4 +1,3 @@ - """Benchmark PQ vs OPQ + PQ.""" from __future__ import annotations @@ -63,9 +62,7 @@ def recall_at_k( truth_set = set(truth[:k]) prediction_set = set(prediction[:k]) - recalls.append( - len(truth_set & prediction_set) / k - ) + recalls.append(len(truth_set & prediction_set) / k) return float(np.mean(recalls)) @@ -115,9 +112,7 @@ def benchmark_quantizer( k=10, ) - reconstruction_error = quantizer.reconstruction_error( - database - ) + reconstruction_error = quantizer.reconstruction_error(database) print(f"Fit time : {fit_time:.4f}s") print(f"Encode time : {encode_time:.4f}s") @@ -147,13 +142,9 @@ def main() -> None: m = 8 nbits = 8 - database = rng.normal( - size=(num_database, dimension) - ).astype(np.float32) + database = rng.normal(size=(num_database, dimension)).astype(np.float32) - queries = rng.normal( - size=(num_queries, dimension) - ).astype(np.float32) + queries = rng.normal(size=(num_queries, dimension)).astype(np.float32) print("=== PQ vs OPQ + PQ Benchmark ===") print(f"Database : {num_database} vectors") @@ -223,25 +214,13 @@ def main() -> None: print("\n=== Comparison ===") - recall_improvement = ( - opq_results["recall_10"] - - pq_results["recall_10"] - ) + recall_improvement = opq_results["recall_10"] - pq_results["recall_10"] - error_improvement = ( - pq_results["reconstruction_error"] - - opq_results["reconstruction_error"] - ) + error_improvement = pq_results["reconstruction_error"] - opq_results["reconstruction_error"] - print( - f"Recall@10 improvement : " - f"{recall_improvement:+.4f}" - ) + print(f"Recall@10 improvement : {recall_improvement:+.4f}") - print( - f"Reconstruction improvement: " - f"{error_improvement:+.6f}" - ) + print(f"Reconstruction improvement: {error_improvement:+.6f}") if __name__ == "__main__": diff --git a/benchmarks/benchmark_quantization.py b/benchmarks/benchmark_quantization.py index f3340ff..684d9cd 100644 --- a/benchmarks/benchmark_quantization.py +++ b/benchmarks/benchmark_quantization.py @@ -14,9 +14,7 @@ def generate_dataset(n_vectors=10_000, dim=32, seed=0): centers = rng.normal(size=(8, dim)).astype(np.float32) assign = rng.integers(0, 8, size=n_vectors) - x = centers[assign] + 0.05 * rng.normal( - size=(n_vectors, dim) - ).astype(np.float32) + x = centers[assign] + 0.05 * rng.normal(size=(n_vectors, dim)).astype(np.float32) return x.astype(np.float32) @@ -94,15 +92,11 @@ def benchmark(): scalar_codes, ) - scalar_reconstruction_error = scalar.reconstruction_error( - vectors - ) + scalar_reconstruction_error = scalar.reconstruction_error(vectors) scalar_bytes_per_vector = scalar.code_size_bytes scalar_total_bytes = scalar_codes.nbytes - scalar_compression = ( - raw_bytes_per_vector / scalar_bytes_per_vector - ) + scalar_compression = raw_bytes_per_vector / scalar_bytes_per_vector # --------------------------------------------------------- # Product Quantizer @@ -130,15 +124,11 @@ def benchmark(): pq_codes, ) - pq_reconstruction_error = pq.reconstruction_error( - vectors - ) + pq_reconstruction_error = pq.reconstruction_error(vectors) pq_bytes_per_vector = pq.code_size_bytes pq_total_bytes = pq_codes.nbytes - pq_compression = ( - raw_bytes_per_vector / pq_bytes_per_vector - ) + pq_compression = raw_bytes_per_vector / pq_bytes_per_vector # --------------------------------------------------------- # Results @@ -148,10 +138,7 @@ def benchmark(): print(f" Fit time: {scalar_fit_time:.4f} sec") print(f" Encode time: {scalar_encode_time:.4f} sec") print(f" Decode time: {scalar_decode_time:.4f} sec") - print( - f" Reconstruction error: " - f"{scalar_reconstruction_error:.8f}" - ) + print(f" Reconstruction error: {scalar_reconstruction_error:.8f}") print(f" Bytes/vector: {scalar_bytes_per_vector}") print(f" Total encoded size: {scalar_total_bytes / 1024:.2f} KB") print(f" Compression: {scalar_compression:.2f}x") @@ -161,32 +148,20 @@ def benchmark(): print(f" Fit time: {pq_fit_time:.4f} sec") print(f" Encode time: {pq_encode_time:.4f} sec") print(f" Decode time: {pq_decode_time:.4f} sec") - print( - f" Reconstruction error: " - f"{pq_reconstruction_error:.8f}" - ) + print(f" Reconstruction error: {pq_reconstruction_error:.8f}") print(f" Bytes/vector: {pq_bytes_per_vector}") print(f" Total encoded size: {pq_total_bytes / 1024:.2f} KB") print(f" Compression: {pq_compression:.2f}x") print() print("Comparison") - print( - f" Scalar/PQ compression: " - f"{scalar_compression / pq_compression:.2f}x" - ) + print(f" Scalar/PQ compression: {scalar_compression / pq_compression:.2f}x") if scalar_encode_time > 0: - print( - f" PQ encode / Scalar encode: " - f"{pq_encode_time / scalar_encode_time:.2f}x" - ) + print(f" PQ encode / Scalar encode: {pq_encode_time / scalar_encode_time:.2f}x") if scalar_decode_time > 0: - print( - f" PQ decode / Scalar decode: " - f"{pq_decode_time / scalar_decode_time:.2f}x" - ) + print(f" PQ decode / Scalar decode: {pq_decode_time / scalar_decode_time:.2f}x") print() print("=" * 80) diff --git a/benchmarks/cost_model.py b/benchmarks/cost_model.py index c839c01..ff758dd 100644 --- a/benchmarks/cost_model.py +++ b/benchmarks/cost_model.py @@ -19,12 +19,12 @@ @dataclass class Workload: - vectors: int # number of stored vectors - dim: int # embedding dimension + vectors: int # number of stored vectors + dim: int # embedding dimension queries_per_month: int writes_per_month: int = 0 - metadata_bytes: int = 500 # avg full metadata + text per doc (DynamoDB) - filterable_bytes: int = 120 # avg filterable metadata per vector (S3 Vectors) + metadata_bytes: int = 500 # avg full metadata + text per doc (DynamoDB) + filterable_bytes: int = 120 # avg filterable metadata per vector (S3 Vectors) @property def vector_bytes(self) -> int: @@ -147,12 +147,15 @@ def main() -> None: p.add_argument("--wpm", type=int, default=100_000, help="writes per month") args = p.parse_args() - w = Workload(vectors=args.vectors, dim=args.dim, - queries_per_month=args.qpm, writes_per_month=args.wpm) + w = Workload( + vectors=args.vectors, dim=args.dim, queries_per_month=args.qpm, writes_per_month=args.wpm + ) - print(f"\nWorkload: {w.vectors:,} vectors × {w.dim}d " - f"({w.raw_vector_gb:.1f} GB raw), {w.queries_per_month:,} queries/mo, " - f"{w.writes_per_month:,} writes/mo\n") + print( + f"\nWorkload: {w.vectors:,} vectors × {w.dim}d " + f"({w.raw_vector_gb:.1f} GB raw), {w.queries_per_month:,} queries/mo, " + f"{w.writes_per_month:,} writes/mo\n" + ) print("dynavec breakdown:") for k, v in dynavec_cost(w).items(): print(f" {k:<22} ${v:>10,.2f}") diff --git a/benchmarks/datasets.py b/benchmarks/datasets.py index 9ab3386..c88384c 100644 --- a/benchmarks/datasets.py +++ b/benchmarks/datasets.py @@ -9,8 +9,8 @@ @dataclass class Dataset: - vectors: np.ndarray # (N, dim) float32 - queries: np.ndarray # (Q, dim) float32 + vectors: np.ndarray # (N, dim) float32 + queries: np.ndarray # (Q, dim) float32 ids: list[str] ground_truth: np.ndarray # (Q, k) int indices into vectors (exact NN) @@ -55,7 +55,7 @@ def _l2_normalize(x: np.ndarray) -> np.ndarray: def _exact_topk(vectors: np.ndarray, queries: np.ndarray, k: int) -> np.ndarray: # cosine similarity == dot product on L2-normalized vectors - sims = queries @ vectors.T # (Q, N) + sims = queries @ vectors.T # (Q, N) return np.argpartition(-sims, kth=k - 1, axis=1)[:, :k] diff --git a/benchmarks/report.py b/benchmarks/report.py index 7c4f068..3201563 100644 --- a/benchmarks/report.py +++ b/benchmarks/report.py @@ -35,12 +35,12 @@ # Representative quality/latency profile (EDIT with measured values). # recall@10, warm p50/p95/p99 ms for a ~1M x 768 workload. _PROFILE = { - "dynavec": {"recall": 0.90, "p50": 45, "p95": 120, "p99": 180}, - "pinecone_serverless": {"recall": 0.95, "p50": 30, "p95": 70, "p99": 110}, - "opensearch_serverless": {"recall": 0.97, "p50": 15, "p95": 40, "p99": 70}, - "qdrant_cloud": {"recall": 0.98, "p50": 8, "p95": 20, "p99": 35}, - "weaviate_cloud": {"recall": 0.97, "p50": 10, "p95": 25, "p99": 45}, - "milvus_zilliz": {"recall": 0.98, "p50": 7, "p95": 18, "p99": 30}, + "dynavec": {"recall": 0.90, "p50": 45, "p95": 120, "p99": 180}, + "pinecone_serverless": {"recall": 0.95, "p50": 30, "p95": 70, "p99": 110}, + "opensearch_serverless": {"recall": 0.97, "p50": 15, "p95": 40, "p99": 70}, + "qdrant_cloud": {"recall": 0.98, "p50": 8, "p95": 20, "p99": 35}, + "weaviate_cloud": {"recall": 0.97, "p50": 10, "p95": 25, "p99": 45}, + "milvus_zilliz": {"recall": 0.98, "p50": 7, "p95": 18, "p99": 30}, } _LABELS = { @@ -73,16 +73,32 @@ def markdown_table(inp: ReportInputs) -> str: ("Latency p50 (ms)", {p: _PROFILE[p]["p50"] for p in products}, "min", "{:.0f}"), ("Latency p95 (ms)", {p: _PROFILE[p]["p95"] for p in products}, "min", "{:.0f}"), ("Cost ($/mo)", {p: costs[p] for p in products}, "min", "${:,.0f}"), - ("Serverless (scale-to-zero)", { - "dynavec": "Yes", "pinecone_serverless": "Yes", - "opensearch_serverless": "No (OCU floor)", "qdrant_cloud": "No (nodes)", - "weaviate_cloud": "No (nodes)", "milvus_zilliz": "No (CU)", - }, None, "{}"), - ("Data in your AWS account", { - "dynavec": "Yes", "pinecone_serverless": "No", - "opensearch_serverless": "Yes", "qdrant_cloud": "Self-host only", - "weaviate_cloud": "Self-host only", "milvus_zilliz": "Self-host only", - }, None, "{}"), + ( + "Serverless (scale-to-zero)", + { + "dynavec": "Yes", + "pinecone_serverless": "Yes", + "opensearch_serverless": "No (OCU floor)", + "qdrant_cloud": "No (nodes)", + "weaviate_cloud": "No (nodes)", + "milvus_zilliz": "No (CU)", + }, + None, + "{}", + ), + ( + "Data in your AWS account", + { + "dynavec": "Yes", + "pinecone_serverless": "No", + "opensearch_serverless": "Yes", + "qdrant_cloud": "Self-host only", + "weaviate_cloud": "Self-host only", + "milvus_zilliz": "Self-host only", + }, + None, + "{}", + ), ] header = "| Metric | " + " | ".join(_LABELS[p] for p in products) + " |" @@ -148,21 +164,30 @@ def cost_scale_chart(inp: ReportInputs, out_path: str) -> str: fig, ax = plt.subplots(figsize=(11, 6)) for p in products: ax.plot( - scales, series[p], marker="o", - color=_COLORS[p], linewidth=2.5 if p == "dynavec" else 1.6, + scales, + series[p], + marker="o", + color=_COLORS[p], + linewidth=2.5 if p == "dynavec" else 1.6, label=_LABELS[p], ) ax.set_xscale("log") ax.set_yscale("log") ax.set_xlabel("Number of vectors (log scale)") ax.set_ylabel("Estimated cost ($/month, log scale)") - ax.set_title(f"Monthly cost by scale — {inp.dim}d, {inp.qpm:,} queries/mo", - fontsize=14, weight="bold") + ax.set_title( + f"Monthly cost by scale — {inp.dim}d, {inp.qpm:,} queries/mo", fontsize=14, weight="bold" + ) ax.grid(True, which="both", alpha=0.25) ax.legend() - fig.text(0.5, -0.02, - "APPROX public list prices (see cost_model.py). Order-of-magnitude only.", - ha="center", fontsize=8, style="italic") + fig.text( + 0.5, + -0.02, + "APPROX public list prices (see cost_model.py). Order-of-magnitude only.", + ha="center", + fontsize=8, + style="italic", + ) fig.tight_layout() fig.savefig(out_path, dpi=140, bbox_inches="tight") plt.close(fig) @@ -196,9 +221,14 @@ def cost_matrix_chart(out_path: str, qpm: int = 1_000_000, wpm: int = 100_000) - for ax, dim in zip(axes, DIMENSIONS): for p in products: ys = [compare(Workload(n, dim, qpm, wpm))[p] for n in SCALES] - ax.plot(SCALES, ys, marker="o", - color=_COLORS[p], linewidth=2.4 if p == "dynavec" else 1.4, - label=_LABELS[p]) + ax.plot( + SCALES, + ys, + marker="o", + color=_COLORS[p], + linewidth=2.4 if p == "dynavec" else 1.4, + label=_LABELS[p], + ) ax.set_xscale("log") ax.set_yscale("log") ax.set_title(f"{dim}-dim embeddings", weight="bold") @@ -212,8 +242,11 @@ def cost_matrix_chart(out_path: str, qpm: int = 1_000_000, wpm: int = 100_000) - handles, labels = axes[0].get_legend_handles_labels() legend_ax.legend(handles, labels, loc="center", fontsize=12, title="Product") - fig.suptitle(f"Estimated monthly cost by scale & dimension · {qpm:,} queries/mo", - fontsize=16, weight="bold") + fig.suptitle( + f"Estimated monthly cost by scale & dimension · {qpm:,} queries/mo", + fontsize=16, + weight="bold", + ) fig.supxlabel("Number of vectors (log scale)") fig.supylabel("Cost $/month (log scale)") fig.tight_layout() @@ -228,19 +261,31 @@ def cost_by_dimension_chart(out_path: str, scale: int = 100_000_000, qpm: int = fig, ax = plt.subplots(figsize=(11, 6)) for p in products: ys = [compare(Workload(scale, d, qpm, 100_000))[p] for d in DIMENSIONS] - ax.plot(DIMENSIONS, ys, marker="s", - color=_COLORS[p], linewidth=2.4 if p == "dynavec" else 1.4, - label=_LABELS[p]) + ax.plot( + DIMENSIONS, + ys, + marker="s", + color=_COLORS[p], + linewidth=2.4 if p == "dynavec" else 1.4, + label=_LABELS[p], + ) ax.set_yscale("log") ax.set_xticks(DIMENSIONS) ax.set_xlabel("Embedding dimension") ax.set_ylabel("Cost $/month (log scale)") - ax.set_title(f"Cost by embedding dimension @ {_fmt_count(scale)} vectors", - fontsize=14, weight="bold") + ax.set_title( + f"Cost by embedding dimension @ {_fmt_count(scale)} vectors", fontsize=14, weight="bold" + ) ax.grid(True, which="both", alpha=0.25) ax.legend() - fig.text(0.5, -0.02, "APPROX list prices — see cost_model.py.", ha="center", - fontsize=8, style="italic") + fig.text( + 0.5, + -0.02, + "APPROX list prices — see cost_model.py.", + ha="center", + fontsize=8, + style="italic", + ) fig.tight_layout() fig.savefig(out_path, dpi=140, bbox_inches="tight") plt.close(fig) @@ -303,8 +348,10 @@ def generate(out_dir: str = "benchmarks/out", inp: ReportInputs | None = None) - table_md = markdown_table(inp) table_path = os.path.join(out_dir, "comparison.md") with open(table_path, "w") as f: - f.write(f"# dynavec benchmark comparison\n\n" - f"_Workload: {inp.vectors:,} × {inp.dim}d, {inp.qpm:,} queries/mo._\n\n") + f.write( + f"# dynavec benchmark comparison\n\n" + f"_Workload: {inp.vectors:,} × {inp.dim}d, {inp.qpm:,} queries/mo._\n\n" + ) f.write(table_md + "\n") bars = bar_chart(inp, os.path.join(out_dir, "quality_latency.png")) cost = cost_scale_chart(inp, os.path.join(out_dir, "cost_by_scale.png")) @@ -316,15 +363,21 @@ def generate(out_dir: str = "benchmarks/out", inp: ReportInputs | None = None) - scaling_path = os.path.join(out_dir, "scaling.md") with open(scaling_path, "w") as f: f.write("# dynavec scaling: dimensions × vector count (100K → 1B)\n") - f.write("\n_Cost from the cost model (APPROX public list prices). " - "Recall/latency unaffected by this sweep._\n") + f.write( + "\n_Cost from the cost model (APPROX public list prices). " + "Recall/latency unaffected by this sweep._\n" + ) f.write(scaling_tables_md(qpm=inp.qpm)) f.write("\n") return { - "table_md": table_path, "bar_chart": bars, "cost_chart": cost, - "cost_matrix": matrix, "cost_by_dimension": by_dim, - "storage_footprint": footprint, "scaling_md": scaling_path, + "table_md": table_path, + "bar_chart": bars, + "cost_chart": cost, + "cost_matrix": matrix, + "cost_by_dimension": by_dim, + "storage_footprint": footprint, + "scaling_md": scaling_path, } diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py index e6e05e5..5035673 100644 --- a/benchmarks/run_benchmark.py +++ b/benchmarks/run_benchmark.py @@ -76,10 +76,7 @@ def __init__(self, args): def build(self, vectors, ids): self._id_to_index = {vid: i for i, vid in enumerate(ids)} - docs = [ - self._Document(id=vid, vector=vectors[i].tolist()) - for i, vid in enumerate(ids) - ] + docs = [self._Document(id=vid, vector=vectors[i].tolist()) for i, vid in enumerate(ids)] # upsert in batches so we don't build one giant list in memory B = 500 for s in range(0, len(docs), B): @@ -91,8 +88,10 @@ def query(self, q, k): def run(args) -> None: - print(f"Generating synthetic dataset: {args.n:,} × {args.dim}d, " - f"{args.queries} queries, k={args.k}") + print( + f"Generating synthetic dataset: {args.n:,} × {args.dim}d, " + f"{args.queries} queries, k={args.k}" + ) ds = make_synthetic(n=args.n, dim=args.dim, n_queries=args.queries, k=args.k) if args.backend == "local": @@ -122,18 +121,23 @@ def run(args) -> None: print(f"backend : {args.backend}") print(f"dataset : {args.n:,} vectors × {args.dim}d") print(f"recall@{args.k:<8}: {recall:.4f}") - print(f"latency (ms) : p50={pct['p50']:.2f} p95={pct['p95']:.2f} " - f"p99={pct['p99']:.2f} mean={pct['mean']:.2f}") + print( + f"latency (ms) : p50={pct['p50']:.2f} p95={pct['p95']:.2f} " + f"p99={pct['p99']:.2f} mean={pct['mean']:.2f}" + ) print(f"build time : {build_s:.2f}s") if args.backend == "local": - print("\nNote: local backend is exact NN (recall≈1.0). Use --backend " - "dynavec for real S3 Vectors recall & end-to-end latency.") + print( + "\nNote: local backend is exact NN (recall≈1.0). Use --backend " + "dynavec for real S3 Vectors recall & end-to-end latency." + ) print("=========================================\n") def main() -> None: - p = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) p.add_argument("--backend", choices=["local", "dynavec"], default="local") p.add_argument("--n", type=int, default=10_000, help="number of vectors") p.add_argument("--dim", type=int, default=384) diff --git a/src/dynavec/agents/base.py b/src/dynavec/agents/base.py index fe4e84a..ad9ecfe 100644 --- a/src/dynavec/agents/base.py +++ b/src/dynavec/agents/base.py @@ -119,9 +119,7 @@ def __init__( self.fn = fn self.name = name or fn.__name__ self.description = description or (fn.__doc__ or f"Execute {self.name}").strip() - self.parameters = ( - parameters if parameters is not None else _generate_json_schema(fn) - ) + self.parameters = parameters if parameters is not None else _generate_json_schema(fn) self.tags = tags or [] def to_chat_tool(self) -> Tool: @@ -174,14 +172,15 @@ def execute(self, arguments: dict[str, Any] | str | None = None) -> str: try: parsed_args = self._parse_arguments(arguments) kwargs = self._prepare_call(parsed_args) - + if inspect.iscoroutinefunction(self.fn): import asyncio + try: loop = asyncio.get_running_loop() except RuntimeError: loop = None - + if loop and loop.is_running(): return f"Error: Cannot execute async tool {self.name!r} synchronously in a running event loop." result = asyncio.run(self.fn(**kwargs)) @@ -199,11 +198,12 @@ async def aexecute(self, arguments: dict[str, Any] | str | None = None) -> str: try: parsed_args = self._parse_arguments(arguments) kwargs = self._prepare_call(parsed_args) - + if inspect.iscoroutinefunction(self.fn): result = await self.fn(**kwargs) else: import asyncio + result = await asyncio.to_thread(self.fn, **kwargs) if isinstance(result, str): diff --git a/src/dynavec/agents/connectors.py b/src/dynavec/agents/connectors.py index debd4b9..5308efd 100644 --- a/src/dynavec/agents/connectors.py +++ b/src/dynavec/agents/connectors.py @@ -52,6 +52,7 @@ def http_request( if params: import urllib.parse + query_string = urllib.parse.urlencode(params) sep = "&" if "?" in full_url else "?" full_url = f"{full_url}{sep}{query_string}" @@ -112,13 +113,14 @@ def python_repl(code: str) -> str: with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): # We use a restricted globals dict to avoid polluting the main namespace exec_globals: dict[str, Any] = {"__builtins__": __builtins__} - + import ast + # Parse to check if the last statement is an expression tree = ast.parse(code) if not tree.body: return "" - + last_node = tree.body[-1] if isinstance(last_node, ast.Expr): # Compile all but the last node as statements @@ -131,10 +133,10 @@ def python_repl(code: str) -> str: print(repr(result)) else: exec(code, exec_globals) - + except Exception: traceback.print_exc(file=out) - + return out.getvalue() return python_repl @@ -145,13 +147,13 @@ def mcp_tools_from_session( tags: list[str] | None = None, ) -> list[AgentTool]: """Extract and adapt MCP tools from an MCP Client Session. - + Args: session: An MCP Client session exposing `list_tools()` and `call_tool(name, arguments)`. tags: Optional tags to apply to the generated tools. """ listing = session.list_tools() - + # Handle async listing if needed (e.g. if list_tools is a coroutine) if hasattr(listing, "__await__"): raise ValueError( @@ -161,15 +163,21 @@ def mcp_tools_from_session( tools = getattr(listing, "tools", listing) agent_tools = [] - + for t in tools: name = getattr(t, "name", None) or (t.get("name") if isinstance(t, dict) else None) if not name: continue - - description = getattr(t, "description", None) or (t.get("description") if isinstance(t, dict) else "") - schema = getattr(t, "inputSchema", None) or getattr(t, "input_schema", None) or (t.get("inputSchema") if isinstance(t, dict) else None) - + + description = getattr(t, "description", None) or ( + t.get("description") if isinstance(t, dict) else "" + ) + schema = ( + getattr(t, "inputSchema", None) + or getattr(t, "input_schema", None) + or (t.get("inputSchema") if isinstance(t, dict) else None) + ) + # We create a closure to bind the session and name def _make_caller(tool_name: str) -> Callable[..., str]: def call_mcp_tool(**kwargs: Any) -> str: @@ -180,17 +188,20 @@ def call_mcp_tool(**kwargs: Any) -> str: if isinstance(content, (list, tuple)): texts = [] for p in content: - txt = getattr(p, "text", None) or (p.get("text") if isinstance(p, dict) else None) + txt = getattr(p, "text", None) or ( + p.get("text") if isinstance(p, dict) else None + ) if txt: texts.append(txt) return "\n\n".join(texts) return str(result) + return call_mcp_tool - + fn = _make_caller(name) fn.__name__ = name fn.__doc__ = description - + agent_tool = AgentTool( fn=fn, name=name, @@ -199,7 +210,7 @@ def call_mcp_tool(**kwargs: Any) -> str: tags=tags, ) agent_tools.append(agent_tool) - + return agent_tools @@ -211,36 +222,45 @@ async def mcp_tools_from_session_async( listing = await session.list_tools() tools = getattr(listing, "tools", listing) agent_tools = [] - + for t in tools: name = getattr(t, "name", None) or (t.get("name") if isinstance(t, dict) else None) if not name: continue - - description = getattr(t, "description", None) or (t.get("description") if isinstance(t, dict) else "") - schema = getattr(t, "inputSchema", None) or getattr(t, "input_schema", None) or (t.get("inputSchema") if isinstance(t, dict) else None) - + + description = getattr(t, "description", None) or ( + t.get("description") if isinstance(t, dict) else "" + ) + schema = ( + getattr(t, "inputSchema", None) + or getattr(t, "input_schema", None) + or (t.get("inputSchema") if isinstance(t, dict) else None) + ) + def _make_async_caller(tool_name: str) -> Callable[..., Any]: async def acall_mcp_tool(**kwargs: Any) -> str: result = await session.call_tool(tool_name, arguments=kwargs) content = getattr(result, "content", result) is_error = getattr(result, "isError", False) prefix = "Error: " if is_error else "" - + if isinstance(content, (list, tuple)): texts = [] for p in content: - txt = getattr(p, "text", None) or (p.get("text") if isinstance(p, dict) else None) + txt = getattr(p, "text", None) or ( + p.get("text") if isinstance(p, dict) else None + ) if txt: texts.append(txt) return prefix + "\n\n".join(texts) return prefix + str(result) + return acall_mcp_tool - + fn = _make_async_caller(name) fn.__name__ = name fn.__doc__ = description - + agent_tool = AgentTool( fn=fn, name=name, @@ -249,5 +269,5 @@ async def acall_mcp_tool(**kwargs: Any) -> str: tags=tags, ) agent_tools.append(agent_tool) - + return agent_tools diff --git a/src/dynavec/agents/react.py b/src/dynavec/agents/react.py index d8be15c..c13f13e 100644 --- a/src/dynavec/agents/react.py +++ b/src/dynavec/agents/react.py @@ -111,7 +111,7 @@ def _init_messages( user_content = goal if state is not None and state.data: shared_context = json.dumps(state.data, default=str, sort_keys=True) - user_content = f"{goal}\n\n" f"Shared workflow context (JSON):\n{shared_context}" + user_content = f"{goal}\n\nShared workflow context (JSON):\n{shared_context}" messages.append(Message(role="user", content=user_content)) return messages @@ -195,18 +195,10 @@ def run( checkpoint_state = existing_cp.state if checkpoint_state is not None: if "messages" in checkpoint_state: - messages = [ - _dict_to_message(m) - for m in checkpoint_state["messages"] - ] + messages = [_dict_to_message(m) for m in checkpoint_state["messages"]] if "steps" in checkpoint_state: - steps = [ - _dict_to_step(s) - for s in checkpoint_state["steps"] - ] - total_tool_calls = int( - checkpoint_state.get("total_tool_calls", 0) - ) + steps = [_dict_to_step(s) for s in checkpoint_state["steps"]] + total_tool_calls = int(checkpoint_state.get("total_tool_calls", 0)) start_step = existing_cp.step + 1 if not messages: @@ -327,18 +319,10 @@ async def arun( checkpoint_state = existing_cp.state if checkpoint_state is not None: if "messages" in checkpoint_state: - messages = [ - _dict_to_message(m) - for m in checkpoint_state["messages"] - ] + messages = [_dict_to_message(m) for m in checkpoint_state["messages"]] if "steps" in checkpoint_state: - steps = [ - _dict_to_step(s) - for s in checkpoint_state["steps"] - ] - total_tool_calls = int( - checkpoint_state.get("total_tool_calls", 0) - ) + steps = [_dict_to_step(s) for s in checkpoint_state["steps"]] + total_tool_calls = int(checkpoint_state.get("total_tool_calls", 0)) start_step = existing_cp.step + 1 if not messages: diff --git a/src/dynavec/agents/registry.py b/src/dynavec/agents/registry.py index 76e09e3..606d065 100644 --- a/src/dynavec/agents/registry.py +++ b/src/dynavec/agents/registry.py @@ -70,9 +70,7 @@ def execute(self, name: str, arguments: dict[str, Any] | str | None = None) -> s return f"Error: Tool {name!r} is not registered." return t.execute(arguments) - async def aexecute( - self, name: str, arguments: dict[str, Any] | str | None = None - ) -> str: + async def aexecute(self, name: str, arguments: dict[str, Any] | str | None = None) -> str: """Execute a tool by name asynchronously.""" t = self.get(name) if not t: @@ -98,4 +96,5 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[str]: return iter(self._tools) + default_registry = ToolRegistry() diff --git a/src/dynavec/agents/supervisor.py b/src/dynavec/agents/supervisor.py index 06cb5eb..fc76bcf 100644 --- a/src/dynavec/agents/supervisor.py +++ b/src/dynavec/agents/supervisor.py @@ -69,9 +69,7 @@ def run(self, goal: str, *, state: AgentState) -> list[AgentResult]: if self.router is not None: if not isinstance(self.agents, dict): - raise TypeError( - "A router requires agents to be provided as a named dictionary." - ) + raise TypeError("A router requires agents to be provided as a named dictionary.") selected = self.router(goal, state) agent_names = [selected] if isinstance(selected, str) else selected @@ -86,10 +84,7 @@ def run(self, goal: str, *, state: AgentState) -> list[AgentResult]: f"Registered agents: {list(self.agents)}" ) - return [ - self.agents[name].run(goal, state=state) - for name in agent_names - ] + return [self.agents[name].run(goal, state=state) for name in agent_names] agents = self.agents.values() if isinstance(self.agents, dict) else self.agents return [agent.run(goal, state=state) for agent in agents] @@ -128,8 +123,7 @@ def handoff( agent = self.agents.get(request.target) if agent is None: raise ValueError( - f"Unknown handoff target {request.target!r}. " - f"Registered agents: {list(self.agents)}" + f"Unknown handoff target {request.target!r}. Registered agents: {list(self.agents)}" ) return agent.run(request.goal, state=state) diff --git a/src/dynavec/async_client.py b/src/dynavec/async_client.py index f901d45..83d277d 100644 --- a/src/dynavec/async_client.py +++ b/src/dynavec/async_client.py @@ -72,18 +72,11 @@ def __init__( self._default_transform = as_pipeline(transform) self._cache = cache self._telemetry = telemetry - self._hot = ( - HotTier(config) - if config.hot_tier - else None - ) + self._hot = HotTier(config) if config.hot_tier else None self._exit_stack: AsyncExitStack | None = None - if ( - embedder is not None - and embedder.dimension != config.dimension - ): + if embedder is not None and embedder.dimension != config.dimension: raise ConfigurationError( f"Embedder dimension ({embedder.dimension}) " f"!= index dimension ({config.dimension}). " @@ -146,10 +139,7 @@ def _require_open(self) -> None: ) def _invalidate_cache(self, namespace: str) -> None: - if ( - self._cache is not None - and self.config.cache_invalidate_on_write - ): + if self._cache is not None and self.config.cache_invalidate_on_write: self._cache.invalidate(namespace) async def _resolve_query_vector( @@ -160,15 +150,12 @@ async def _resolve_query_vector( if vector is not None: if len(vector) != self.config.dimension: raise DimensionMismatchError( - f"Query vector dimension {len(vector)} " - f"!= {self.config.dimension}." + f"Query vector dimension {len(vector)} != {self.config.dimension}." ) return vector if query is None: - raise ValueError( - "Provide either 'query' text or a 'vector'." - ) + raise ValueError("Provide either 'query' text or a 'vector'.") if self.embedder is None: raise ConfigurationError( @@ -270,11 +257,7 @@ async def asearch_stream( yielded = 0 - effective_page_size = ( - page_size - if page_size is not None - else self.config.top_k_page_size - ) + effective_page_size = page_size if page_size is not None else self.config.top_k_page_size async for page in self._vectors.query_pages( query_vector=query_vector, @@ -336,10 +319,7 @@ async def _prepare( list[str], list[HotPayload], ]: - pipeline = ( - as_pipeline(transform) - or self._default_transform - ) + pipeline = as_pipeline(transform) or self._default_transform apply_transform_pipeline( docs, @@ -359,9 +339,7 @@ async def _prepare( texts = embedding_texts(to_embed) - vectors = await self.embedder.aembed_documents( - texts - ) + vectors = await self.embedder.aembed_documents(texts) assign_embeddings( docs, @@ -379,10 +357,7 @@ async def _prepare( async def aupsert( self, - documents: Sequence[ - Document | dict[str, Any] - ] - | None = None, + documents: Sequence[Document | dict[str, Any]] | None = None, *, namespace: str = "default", auto_metadata: bool = False, @@ -391,9 +366,7 @@ async def aupsert( ) -> UpsertResult: """Insert or overwrite documents asynchronously.""" if ttl_seconds is not None and ttl_seconds <= 0: - raise ValueError( - f"ttl_seconds must be positive, got {ttl_seconds}." - ) + raise ValueError(f"ttl_seconds must be positive, got {ttl_seconds}.") if not documents: return UpsertResult( @@ -404,9 +377,7 @@ async def aupsert( self._require_open() docs = [ - document - if isinstance(document, Document) - else Document(**document) + document if isinstance(document, Document) else Document(**document) for document in documents ] diff --git a/src/dynavec/bm25.py b/src/dynavec/bm25.py index ae0d4e8..1e7b373 100644 --- a/src/dynavec/bm25.py +++ b/src/dynavec/bm25.py @@ -19,11 +19,43 @@ _TOKEN_RE = re.compile(r"[a-zA-Z0-9]+(?:[-_.:][a-zA-Z0-9]+)*") _SUBTOKEN_RE = re.compile(r"[a-zA-Z0-9]+") -DEFAULT_STOPWORDS: frozenset[str] = frozenset({ - "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", - "into", "is", "it", "no", "not", "of", "on", "or", "such", "that", "the", - "their", "then", "there", "these", "they", "this", "to", "was", "will", "with" -}) +DEFAULT_STOPWORDS: frozenset[str] = frozenset( + { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "if", + "in", + "into", + "is", + "it", + "no", + "not", + "of", + "on", + "or", + "such", + "that", + "the", + "their", + "then", + "there", + "these", + "they", + "this", + "to", + "was", + "will", + "with", + } +) def default_tokenize( diff --git a/src/dynavec/chat/anthropic.py b/src/dynavec/chat/anthropic.py index 99c4743..28e8ab5 100644 --- a/src/dynavec/chat/anthropic.py +++ b/src/dynavec/chat/anthropic.py @@ -29,17 +29,13 @@ def __init__( try: from anthropic import Anthropic, AsyncAnthropic except ImportError as exc: - raise MissingDependencyError( - "AnthropicChatModel", "anthropic", "anthropic" - ) from exc + raise MissingDependencyError("AnthropicChatModel", "anthropic", "anthropic") from exc self.model = model self._client = Anthropic(api_key=api_key) self._async_client = AsyncAnthropic(api_key=api_key) - def _convert_messages( - self, messages: list[Message] - ) -> tuple[str, list[dict[str, Any]]]: + def _convert_messages(self, messages: list[Message]) -> tuple[str, list[dict[str, Any]]]: system_prompt = "" anthropic_msgs = [] diff --git a/src/dynavec/chat/base.py b/src/dynavec/chat/base.py index 876c126..57ce5ee 100644 --- a/src/dynavec/chat/base.py +++ b/src/dynavec/chat/base.py @@ -10,80 +10,80 @@ Role = Literal["system", "user", "assistant", "tool"] + @dataclass class ToolCall: """A tool invocation requested by the model.""" + id: str name: str arguments: str # JSON string + @dataclass class Message: """A single message in a chat conversation.""" + role: Role content: str | None = None tool_calls: list[ToolCall] = field(default_factory=list) tool_call_id: str | None = None + @dataclass class Tool: """A tool/function specification provided to the model.""" + name: str description: str parameters: dict[str, Any] # JSON schema dict + @dataclass class ChatResult: """The final result of a chat invocation.""" + message: Message finish_reason: str | None = None + @dataclass class ChatChunk: """A streamed chunk from a chat invocation.""" + content: str | None = None tool_calls: list[ToolCall] = field(default_factory=list) + class ChatModel(ABC): """Base class for all chat/LLM backends.""" @abstractmethod def invoke( - self, - messages: list[Message], - tools: list[Tool] | None = None, - **kwargs: Any + self, messages: list[Message], tools: list[Tool] | None = None, **kwargs: Any ) -> ChatResult: """Invoke the chat model synchronously.""" @abstractmethod def stream( - self, - messages: list[Message], - tools: list[Tool] | None = None, - **kwargs: Any + self, messages: list[Message], tools: list[Tool] | None = None, **kwargs: Any ) -> Iterator[ChatChunk]: """Stream the chat model response.""" async def ainvoke( - self, - messages: list[Message], - tools: list[Tool] | None = None, - **kwargs: Any + self, messages: list[Message], tools: list[Tool] | None = None, **kwargs: Any ) -> ChatResult: """Invoke the chat model asynchronously. Default delegates to thread.""" return await asyncio.to_thread(self.invoke, messages, tools, **kwargs) async def astream( - self, - messages: list[Message], - tools: list[Tool] | None = None, - **kwargs: Any + self, messages: list[Message], tools: list[Tool] | None = None, **kwargs: Any ) -> AsyncIterator[ChatChunk]: """Stream the chat model response asynchronously. Default delegates to thread.""" + def _sync_stream() -> list[ChatChunk]: return list(self.stream(messages, tools, **kwargs)) - + chunks = await asyncio.to_thread(_sync_stream) for chunk in chunks: yield chunk diff --git a/src/dynavec/chat/bedrock.py b/src/dynavec/chat/bedrock.py index 710ecc8..9f4787b 100644 --- a/src/dynavec/chat/bedrock.py +++ b/src/dynavec/chat/bedrock.py @@ -34,7 +34,9 @@ def __init__( self.model = model self._client = boto3.client("bedrock-runtime", region_name=region_name) - def _convert_messages(self, messages: list[Message]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + def _convert_messages( + self, messages: list[Message] + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: system_prompts: list[dict[str, Any]] = [] bedrock_msgs: list[dict[str, Any]] = [] @@ -50,32 +52,35 @@ def _convert_messages(self, messages: list[Message]) -> tuple[list[dict[str, Any if msg.tool_calls: for tc in msg.tool_calls: - content_blocks.append({ - "toolUse": { - "toolUseId": tc.id, - "name": tc.name, - "input": json.loads(tc.arguments) if tc.arguments else {} + content_blocks.append( + { + "toolUse": { + "toolUseId": tc.id, + "name": tc.name, + "input": json.loads(tc.arguments) if tc.arguments else {}, + } } - }) + ) if msg.role == "tool" and msg.tool_call_id: - content_blocks.append({ - "toolResult": { - "toolUseId": msg.tool_call_id, - "content": [{"text": msg.content or ""}], - "status": "success" + content_blocks.append( + { + "toolResult": { + "toolUseId": msg.tool_call_id, + "content": [{"text": msg.content or ""}], + "status": "success", + } } - }) - bedrock_msgs.append({ - "role": "user", - "content": content_blocks - }) + ) + bedrock_msgs.append({"role": "user", "content": content_blocks}) continue - bedrock_msgs.append({ - "role": "assistant" if msg.role == "assistant" else "user", - "content": content_blocks - }) + bedrock_msgs.append( + { + "role": "assistant" if msg.role == "assistant" else "user", + "content": content_blocks, + } + ) return system_prompts, bedrock_msgs @@ -88,9 +93,7 @@ def _convert_tools(self, tools: list[Tool] | None) -> dict[str, Any] | None: "toolSpec": { "name": t.name, "description": t.description, - "inputSchema": { - "json": t.parameters - } + "inputSchema": {"json": t.parameters}, } } for t in tools @@ -103,39 +106,33 @@ def invoke( system, bedrock_msgs = self._convert_messages(messages) bedrock_tools = self._convert_tools(tools) - args: dict[str, Any] = { - "modelId": self.model, - "messages": bedrock_msgs, - **kwargs - } + args: dict[str, Any] = {"modelId": self.model, "messages": bedrock_msgs, **kwargs} if system: args["system"] = system if bedrock_tools: args["toolConfig"] = bedrock_tools resp = self._client.converse(**args) - + output_msg = resp["output"]["message"] role = output_msg["role"] - + text_content = "" tool_calls = [] - + for block in output_msg.get("content", []): if "text" in block: text_content += block["text"] elif "toolUse" in block: tu = block["toolUse"] tool_calls.append( - ToolCall( - id=tu["toolUseId"], - name=tu["name"], - arguments=json.dumps(tu["input"]) - ) + ToolCall(id=tu["toolUseId"], name=tu["name"], arguments=json.dumps(tu["input"])) ) stop_reason = resp.get("stopReason") - out = Message(role=role, content=text_content if text_content else None, tool_calls=tool_calls) + out = Message( + role=role, content=text_content if text_content else None, tool_calls=tool_calls + ) return ChatResult(message=out, finish_reason=stop_reason) def stream( @@ -144,11 +141,7 @@ def stream( system, bedrock_msgs = self._convert_messages(messages) bedrock_tools = self._convert_tools(tools) - args: dict[str, Any] = { - "modelId": self.model, - "messages": bedrock_msgs, - **kwargs - } + args: dict[str, Any] = {"modelId": self.model, "messages": bedrock_msgs, **kwargs} if system: args["system"] = system if bedrock_tools: @@ -167,10 +160,6 @@ def stream( elif "toolUse" in delta: yield ChatChunk( tool_calls=[ - ToolCall( - id="", - name="", - arguments=delta["toolUse"].get("input", "") - ) + ToolCall(id="", name="", arguments=delta["toolUse"].get("input", "")) ] ) diff --git a/src/dynavec/chat/openai.py b/src/dynavec/chat/openai.py index c4aa4ab..873d39b 100644 --- a/src/dynavec/chat/openai.py +++ b/src/dynavec/chat/openai.py @@ -78,9 +78,7 @@ def _convert_tools(self, tools: list[Tool] | None) -> list[dict[str, Any]] | Non ] def _format_result(self, choice: Any) -> ChatResult: - out_msg = Message( - role=choice.message.role or "assistant", content=choice.message.content - ) + out_msg = Message(role=choice.message.role or "assistant", content=choice.message.content) if choice.message.tool_calls: out_msg.tool_calls = [ ToolCall( @@ -153,9 +151,7 @@ def stream( tool_calls.append( ToolCall( id=tc.id or "", - name=tc.function.name - if (tc.function and tc.function.name) - else "", + name=tc.function.name if (tc.function and tc.function.name) else "", arguments=tc.function.arguments if (tc.function and tc.function.arguments) else "", @@ -195,9 +191,7 @@ async def astream( tool_calls.append( ToolCall( id=tc.id or "", - name=tc.function.name - if (tc.function and tc.function.name) - else "", + name=tc.function.name if (tc.function and tc.function.name) else "", arguments=tc.function.arguments if (tc.function and tc.function.arguments) else "", diff --git a/src/dynavec/cli.py b/src/dynavec/cli.py index 817b6d6..77d7539 100644 --- a/src/dynavec/cli.py +++ b/src/dynavec/cli.py @@ -13,297 +13,306 @@ def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="dynavec") - subparsers = parser.add_subparsers(dest="command") - subparsers.add_parser("version", help="show the installed dynavec version") - doctor = subparsers.add_parser("doctor", help="check AWS credentials and resource access") - doctor.add_argument("--bucket", help="S3 Vectors bucket name") - doctor.add_argument("--index", help="S3 Vectors index name") - doctor.add_argument("--table", help="DynamoDB table name") - doctor.add_argument("--region", help="AWS region") - doctor.add_argument("--profile", help="AWS profile name") - - export_cmd = subparsers.add_parser( - "export", help="dump vectors and documents for a namespace to JSONL" - ) - export_cmd.add_argument( - "--namespace", - "-n", - default="default", - help="namespace to export (default: 'default')", - ) - export_cmd.add_argument( - "--output", - "-o", - help="output JSONL file path (default: stdout)", - ) - export_cmd.add_argument("--bucket", help="S3 Vectors bucket name (or DYNAVEC_VECTOR_BUCKET)") - export_cmd.add_argument("--index", help="S3 Vectors index name (or DYNAVEC_INDEX)") - export_cmd.add_argument("--table", help="DynamoDB table name (or DYNAVEC_TABLE)") - export_cmd.add_argument("--dimension", type=int, help="vector dimension (default: 1536)") - export_cmd.add_argument("--region", help="AWS region") - export_cmd.add_argument("--profile", help="AWS profile name") - - import_cmd = subparsers.add_parser( - "import", help="restore vectors and documents for a namespace from JSONL" - ) - import_cmd.add_argument( - "--namespace", - "-n", - default="default", - help="target namespace (default: 'default')", - ) - import_cmd.add_argument( - "--input", - "-i", - help="input JSONL file path (default: stdin)", - ) - import_cmd.add_argument( - "--batch-size", - type=int, - default=100, - help="batch size for upserting records (default: 100)", - ) - import_cmd.add_argument("--bucket", help="S3 Vectors bucket name (or DYNAVEC_VECTOR_BUCKET)") - import_cmd.add_argument("--index", help="S3 Vectors index name (or DYNAVEC_INDEX)") - import_cmd.add_argument("--table", help="DynamoDB table name (or DYNAVEC_TABLE)") - import_cmd.add_argument( - "--dimension", type=int, help="vector dimension (inferred from input if omitted)" - ) - import_cmd.add_argument("--region", help="AWS region") - import_cmd.add_argument("--profile", help="AWS profile name") - - mcp = subparsers.add_parser("mcp", help="run the FastMCP server for AI clients") - mcp.add_argument( - "--transport", - choices=["stdio", "sse"], - default="stdio", - help="Transport mode (default: stdio)", - ) - mcp.add_argument( - "--port", - type=int, - default=8000, - help="Port for SSE transport (default: 8000)", - ) - return parser + parser = argparse.ArgumentParser(prog="dynavec") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("version", help="show the installed dynavec version") + doctor = subparsers.add_parser("doctor", help="check AWS credentials and resource access") + doctor.add_argument("--bucket", help="S3 Vectors bucket name") + doctor.add_argument("--index", help="S3 Vectors index name") + doctor.add_argument("--table", help="DynamoDB table name") + doctor.add_argument("--region", help="AWS region") + doctor.add_argument("--profile", help="AWS profile name") + + export_cmd = subparsers.add_parser( + "export", help="dump vectors and documents for a namespace to JSONL" + ) + export_cmd.add_argument( + "--namespace", + "-n", + default="default", + help="namespace to export (default: 'default')", + ) + export_cmd.add_argument( + "--output", + "-o", + help="output JSONL file path (default: stdout)", + ) + export_cmd.add_argument("--bucket", help="S3 Vectors bucket name (or DYNAVEC_VECTOR_BUCKET)") + export_cmd.add_argument("--index", help="S3 Vectors index name (or DYNAVEC_INDEX)") + export_cmd.add_argument("--table", help="DynamoDB table name (or DYNAVEC_TABLE)") + export_cmd.add_argument("--dimension", type=int, help="vector dimension (default: 1536)") + export_cmd.add_argument("--region", help="AWS region") + export_cmd.add_argument("--profile", help="AWS profile name") + + import_cmd = subparsers.add_parser( + "import", help="restore vectors and documents for a namespace from JSONL" + ) + import_cmd.add_argument( + "--namespace", + "-n", + default="default", + help="target namespace (default: 'default')", + ) + import_cmd.add_argument( + "--input", + "-i", + help="input JSONL file path (default: stdin)", + ) + import_cmd.add_argument( + "--batch-size", + type=int, + default=100, + help="batch size for upserting records (default: 100)", + ) + import_cmd.add_argument("--bucket", help="S3 Vectors bucket name (or DYNAVEC_VECTOR_BUCKET)") + import_cmd.add_argument("--index", help="S3 Vectors index name (or DYNAVEC_INDEX)") + import_cmd.add_argument("--table", help="DynamoDB table name (or DYNAVEC_TABLE)") + import_cmd.add_argument( + "--dimension", type=int, help="vector dimension (inferred from input if omitted)" + ) + import_cmd.add_argument("--region", help="AWS region") + import_cmd.add_argument("--profile", help="AWS profile name") + + mcp = subparsers.add_parser("mcp", help="run the FastMCP server for AI clients") + mcp.add_argument( + "--transport", + choices=["stdio", "sse"], + default="stdio", + help="Transport mode (default: stdio)", + ) + mcp.add_argument( + "--port", + type=int, + default=8000, + help="Port for SSE transport (default: 8000)", + ) + mcp.add_argument( + "--enable-write", + action="store_true", + help="Enable memory write and management tools", + ) + return parser def _session(profile: str | None, region: str | None) -> Any: - import boto3 + import boto3 - kwargs: dict[str, str] = {} - if profile: - kwargs["profile_name"] = profile - if region: - kwargs["region_name"] = region - return boto3.Session(**kwargs) + kwargs: dict[str, str] = {} + if profile: + kwargs["profile_name"] = profile + if region: + kwargs["region_name"] = region + return boto3.Session(**kwargs) def _resolve_resources(args: argparse.Namespace) -> tuple[str, str, str]: - import os - - bucket = args.bucket or os.environ.get("DYNAVEC_VECTOR_BUCKET") or os.environ.get("DYNAVEC_BUCKET") - index = args.index or os.environ.get("DYNAVEC_INDEX") - table = args.table or os.environ.get("DYNAVEC_TABLE") - - missing = [] - if not bucket: - missing.append("--bucket") - if not index: - missing.append("--index") - if not table: - missing.append("--table") - - if missing: - raise ValueError( - f"Missing required resource configuration: {', '.join(missing)} " - "(provide via flags or DYNAVEC_* environment variables)." - ) - return str(bucket), str(index), str(table) + import os + + bucket = ( + args.bucket or os.environ.get("DYNAVEC_VECTOR_BUCKET") or os.environ.get("DYNAVEC_BUCKET") + ) + index = args.index or os.environ.get("DYNAVEC_INDEX") + table = args.table or os.environ.get("DYNAVEC_TABLE") + + missing = [] + if not bucket: + missing.append("--bucket") + if not index: + missing.append("--index") + if not table: + missing.append("--table") + + if missing: + raise ValueError( + f"Missing required resource configuration: {', '.join(missing)} " + "(provide via flags or DYNAVEC_* environment variables)." + ) + return str(bucket), str(index), str(table) def _export(args: argparse.Namespace) -> int: - try: - bucket, index, table = _resolve_resources(args) - except ValueError as exc: - print(f"[FAIL] {exc}", file=sys.stderr) - return 1 - - session = _session(args.profile, args.region) - dimension = args.dimension or 1536 - config = DynavecConfig( - vector_bucket=bucket, - index=index, - table=table, - dimension=dimension, - region=args.region, - ) - db = Dynavec(config, boto_session=session) - - try: - if args.output and args.output != "-": - count = db.export_namespace(args.output, namespace=args.namespace) - print(f"Exported {count} documents from namespace '{args.namespace}' to {args.output}") - else: - count = db.export_namespace(sys.stdout, namespace=args.namespace) - print(f"Exported {count} documents from namespace '{args.namespace}'", file=sys.stderr) - return 0 - except Exception as exc: - print(f"[FAIL] Export failed: {exc}", file=sys.stderr) - return 1 + try: + bucket, index, table = _resolve_resources(args) + except ValueError as exc: + print(f"[FAIL] {exc}", file=sys.stderr) + return 1 + + session = _session(args.profile, args.region) + dimension = args.dimension or 1536 + config = DynavecConfig( + vector_bucket=bucket, + index=index, + table=table, + dimension=dimension, + region=args.region, + ) + db = Dynavec(config, boto_session=session) + + try: + if args.output and args.output != "-": + count = db.export_namespace(args.output, namespace=args.namespace) + print(f"Exported {count} documents from namespace '{args.namespace}' to {args.output}") + else: + count = db.export_namespace(sys.stdout, namespace=args.namespace) + print(f"Exported {count} documents from namespace '{args.namespace}'", file=sys.stderr) + return 0 + except Exception as exc: + print(f"[FAIL] Export failed: {exc}", file=sys.stderr) + return 1 def _import(args: argparse.Namespace) -> int: - import json - - try: - bucket, index, table = _resolve_resources(args) - except ValueError as exc: - print(f"[FAIL] {exc}", file=sys.stderr) - return 1 - - session = _session(args.profile, args.region) - dimension = args.dimension - - if dimension is None and args.input and args.input != "-": - try: - with open(args.input, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line: - first_item = json.loads(line) - if "vector" in first_item and isinstance(first_item["vector"], list): - dimension = len(first_item["vector"]) - break - except Exception: - pass - - if dimension is None: - try: - client = session.client("s3vectors", region_name=args.region) - idx = client.get_index(vectorBucketName=bucket, indexName=index) - dimension = idx.get("index", {}).get("dimension") - except Exception: - pass - - if dimension is None: - dimension = 1536 - - config = DynavecConfig( - vector_bucket=bucket, - index=index, - table=table, - dimension=dimension, - region=args.region, - ) - db = Dynavec(config, boto_session=session) - - try: - if args.input and args.input != "-": - count = db.import_namespace( - args.input, namespace=args.namespace, batch_size=args.batch_size - ) - print(f"Imported {count} documents into namespace '{args.namespace}' from {args.input}") - else: - count = db.import_namespace( - sys.stdin, namespace=args.namespace, batch_size=args.batch_size - ) - print(f"Imported {count} documents into namespace '{args.namespace}'") - return 0 - except Exception as exc: - print(f"[FAIL] Import failed: {exc}", file=sys.stderr) - return 1 + import json + + try: + bucket, index, table = _resolve_resources(args) + except ValueError as exc: + print(f"[FAIL] {exc}", file=sys.stderr) + return 1 + + session = _session(args.profile, args.region) + dimension = args.dimension + + if dimension is None and args.input and args.input != "-": + try: + with open(args.input, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + first_item = json.loads(line) + if "vector" in first_item and isinstance(first_item["vector"], list): + dimension = len(first_item["vector"]) + break + except Exception: + pass + + if dimension is None: + try: + client = session.client("s3vectors", region_name=args.region) + idx = client.get_index(vectorBucketName=bucket, indexName=index) + dimension = idx.get("index", {}).get("dimension") + except Exception: + pass + + if dimension is None: + dimension = 1536 + + config = DynavecConfig( + vector_bucket=bucket, + index=index, + table=table, + dimension=dimension, + region=args.region, + ) + db = Dynavec(config, boto_session=session) + + try: + if args.input and args.input != "-": + count = db.import_namespace( + args.input, namespace=args.namespace, batch_size=args.batch_size + ) + print(f"Imported {count} documents into namespace '{args.namespace}' from {args.input}") + else: + count = db.import_namespace( + sys.stdin, namespace=args.namespace, batch_size=args.batch_size + ) + print(f"Imported {count} documents into namespace '{args.namespace}'") + return 0 + except Exception as exc: + print(f"[FAIL] Import failed: {exc}", file=sys.stderr) + return 1 def _check(label: str, callback: Callable[[], str]) -> bool: - try: - detail = callback() - except Exception as exc: # noqa: BLE001 - print(f"[FAIL] {label}\n {exc}") - return False - print(f"[PASS] {label}") - if detail: - print(f" {detail}") - return True + try: + detail = callback() + except Exception as exc: # noqa: BLE001 + print(f"[FAIL] {label}\n {exc}") + return False + print(f"[PASS] {label}") + if detail: + print(f" {detail}") + return True def _doctor(args: argparse.Namespace) -> int: - print("Dynavec doctor\n") - session = None - checks_passed = True - - def get_session() -> Any: - nonlocal session - if session is None: - session = _session(args.profile, args.region) - return session - - checks_passed &= _check( - "AWS credentials / STS identity", - lambda: _identity(get_session()), - ) - - if args.bucket or args.index: - if not args.bucket or not args.index: - print("[FAIL] S3 Vectors configuration\n --bucket and --index must be provided together") - checks_passed = False - else: - checks_passed &= _check( - f"S3 Vectors index: {args.index}", - lambda: _check_s3vectors(get_session(), args.bucket, args.index, args.region), - ) - - if args.table: - checks_passed &= _check( - f"DynamoDB table: {args.table}", - lambda: _check_dynamodb(get_session(), args.table, args.region), - ) - - print("\nDoctor checks passed." if checks_passed else "\nDoctor checks failed.") - return 0 if checks_passed else 1 + print("Dynavec doctor\n") + session = None + checks_passed = True + + def get_session() -> Any: + nonlocal session + if session is None: + session = _session(args.profile, args.region) + return session + + checks_passed &= _check( + "AWS credentials / STS identity", + lambda: _identity(get_session()), + ) + + if args.bucket or args.index: + if not args.bucket or not args.index: + print( + "[FAIL] S3 Vectors configuration\n --bucket and --index must be provided together" + ) + checks_passed = False + else: + checks_passed &= _check( + f"S3 Vectors index: {args.index}", + lambda: _check_s3vectors(get_session(), args.bucket, args.index, args.region), + ) + + if args.table: + checks_passed &= _check( + f"DynamoDB table: {args.table}", + lambda: _check_dynamodb(get_session(), args.table, args.region), + ) + + print("\nDoctor checks passed." if checks_passed else "\nDoctor checks failed.") + return 0 if checks_passed else 1 def _identity(session: Any) -> str: - identity = session.client("sts").get_caller_identity() - return f"Account: {identity.get('Account', 'unknown')}" + identity = session.client("sts").get_caller_identity() + return f"Account: {identity.get('Account', 'unknown')}" def _check_s3vectors(session: Any, bucket: str, index: str, region: str | None) -> str: - client = session.client("s3vectors", region_name=region) - client.get_index(vectorBucketName=bucket, indexName=index) - return f"Bucket: {bucket}" + client = session.client("s3vectors", region_name=region) + client.get_index(vectorBucketName=bucket, indexName=index) + return f"Bucket: {bucket}" def _check_dynamodb(session: Any, table: str, region: str | None) -> str: - session.client("dynamodb", region_name=region).describe_table(TableName=table) - return "Accessible" + session.client("dynamodb", region_name=region).describe_table(TableName=table) + return "Accessible" def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv) - if args.command == "version": - print(__version__) - return 0 - if args.command == "doctor": - return _doctor(args) - if args.command == "export": - return _export(args) - if args.command == "import": - return _import(args) - if args.command == "mcp": - from .mcp.server import create_mcp_server - - server = create_mcp_server() - if args.transport == "sse": - server.settings.port = args.port - server.run(transport="sse") - else: - server.run(transport="stdio") - return 0 - _parser().print_help() - return 0 + args = _parser().parse_args(argv) + if args.command == "version": + print(__version__) + return 0 + if args.command == "doctor": + return _doctor(args) + if args.command == "export": + return _export(args) + if args.command == "import": + return _import(args) + if args.command == "mcp": + from .mcp.server import create_mcp_server + + server = create_mcp_server(enable_write=args.enable_write) + if args.transport == "sse": + server.settings.port = args.port + server.run(transport="sse") + else: + server.run(transport="stdio") + return 0 + _parser().print_help() + return 0 if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) diff --git a/src/dynavec/client_common.py b/src/dynavec/client_common.py index 878510c..7485c21 100644 --- a/src/dynavec/client_common.py +++ b/src/dynavec/client_common.py @@ -21,11 +21,7 @@ def s3_key(namespace: str, doc_id: str) -> str: - return ( - f"{encode_key_component(namespace)}" - f"{KEY_SEPARATOR}" - f"{encode_key_component(doc_id)}" - ) + return f"{encode_key_component(namespace)}{KEY_SEPARATOR}{encode_key_component(doc_id)}" def split_key(key: str) -> tuple[str, str]: @@ -63,11 +59,7 @@ def apply_transform_pipeline( def documents_to_embed( docs: list[Document], ) -> list[EmbeddingTarget]: - return [ - (index, doc.text) - for index, doc in enumerate(docs) - if doc.vector is None - ] + return [(index, doc.text) for index, doc in enumerate(docs) if doc.vector is None] def embedding_texts( @@ -76,15 +68,9 @@ def embedding_texts( optional_texts = [text for _, text in targets] if any(text is None for text in optional_texts): - raise ConfigurationError( - "A document has neither text nor vector." - ) + raise ConfigurationError("A document has neither text nor vector.") - return [ - text - for text in optional_texts - if text is not None - ] + return [text for text in optional_texts if text is not None] def assign_embeddings( @@ -94,8 +80,7 @@ def assign_embeddings( ) -> None: if len(vectors) != len(targets): raise ConfigurationError( - f"Embedder returned {len(vectors)} vectors for " - f"{len(targets)} documents." + f"Embedder returned {len(vectors)} vectors for {len(targets)} documents." ) for (index, _), vector in zip(targets, vectors): @@ -105,8 +90,7 @@ def assign_embeddings( def single_embedding(vectors: list[list[float]], doc_id: str) -> list[float]: if len(vectors) != 1: raise ConfigurationError( - f"Embedder returned {len(vectors)} vectors for 1 document " - f"({doc_id!r})." + f"Embedder returned {len(vectors)} vectors for 1 document ({doc_id!r})." ) return vectors[0] @@ -133,10 +117,7 @@ def build_write_payloads( vector = doc.vector if vector is None: - raise ConfigurationError( - f"Embedder did not return a vector for document " - f"{doc.id!r}." - ) + raise ConfigurationError(f"Embedder did not return a vector for document {doc.id!r}.") if len(vector) != config.dimension: raise DimensionMismatchError( @@ -158,23 +139,14 @@ def build_write_payloads( doc.text, ) - doc_ttl_seconds = ( - doc.ttl_seconds - if doc.ttl_seconds is not None - else default_ttl_seconds - ) + doc_ttl_seconds = doc.ttl_seconds if doc.ttl_seconds is not None else default_ttl_seconds if doc_ttl_seconds is not None and doc_ttl_seconds <= 0: raise ValueError( - f"Document {doc.id!r} ttl_seconds must be positive, " - f"got {doc_ttl_seconds}." + f"Document {doc.id!r} ttl_seconds must be positive, got {doc_ttl_seconds}." ) - ttl_timestamp = ( - int(time.time() + doc_ttl_seconds) - if doc_ttl_seconds is not None - else None - ) + ttl_timestamp = int(time.time() + doc_ttl_seconds) if doc_ttl_seconds is not None else None if ttl_timestamp is not None: ddb_metadata["_ttl"] = ttl_timestamp diff --git a/src/dynavec/config.py b/src/dynavec/config.py index bcd1fbd..57a6fef 100644 --- a/src/dynavec/config.py +++ b/src/dynavec/config.py @@ -158,15 +158,16 @@ def __post_init__(self) -> None: valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if self.log_level.upper() not in valid_log_levels: raise ValueError(f"log_level must be one of {valid_log_levels}") - + if self.dimension > 4096: logger = logging.getLogger(__name__) - logger.warning("Amazon S3 Vectors currently supports a maximum embedding dimension of 4096. You have configured a dimension of %d. This may result in an API error during provisioning or writing data.", self.dimension) + logger.warning( + "Amazon S3 Vectors currently supports a maximum embedding dimension of 4096. You have configured a dimension of %d. This may result in an API error during provisioning or writing data.", + self.dimension, + ) if self.put_rps is not None and self.put_rps <= 0: raise ValueError("put_rps must be positive") if self.query_rps is not None and self.query_rps <= 0: raise ValueError("query_rps must be positive") - - diff --git a/src/dynavec/embeddings/cache.py b/src/dynavec/embeddings/cache.py index f65d364..05c59db 100644 --- a/src/dynavec/embeddings/cache.py +++ b/src/dynavec/embeddings/cache.py @@ -93,7 +93,7 @@ def __init__(self, max_size: int | None = 10_000) -> None: def get(self, key: str) -> Vector | None: if key not in self._store: return None - self._store.move_to_end(key) # mark as recently used + self._store.move_to_end(key) # mark as recently used return self._store[key] def set(self, key: str, vector: Vector) -> None: @@ -101,7 +101,7 @@ def set(self, key: str, vector: Vector) -> None: self._store.move_to_end(key) else: if self.max_size is not None and len(self._store) >= self.max_size: - self._store.popitem(last=False) # evict LRU + self._store.popitem(last=False) # evict LRU self._store[key] = vector def __len__(self) -> int: diff --git a/src/dynavec/embeddings/cached.py b/src/dynavec/embeddings/cached.py index 2357c66..cd2f2e3 100644 --- a/src/dynavec/embeddings/cached.py +++ b/src/dynavec/embeddings/cached.py @@ -133,7 +133,4 @@ def inner(self) -> Embedder: return self._inner def __repr__(self) -> str: - return ( - f"CachedEmbedder(inner={self._inner!r}, " - f"model_key={self._model_key!r})" - ) + return f"CachedEmbedder(inner={self._inner!r}, model_key={self._model_key!r})" diff --git a/src/dynavec/embeddings/huggingface.py b/src/dynavec/embeddings/huggingface.py index 207cddf..749385d 100644 --- a/src/dynavec/embeddings/huggingface.py +++ b/src/dynavec/embeddings/huggingface.py @@ -30,9 +30,7 @@ def __init__( ) -> None: """Initialize HFInferenceEmbedder.""" self.model_name = model_name - self.api_key = ( - api_key or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") - ) + self.api_key = api_key or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") self.batch_size = batch_size self.dimension = dimension or _MODEL_DIMS.get(model_name, 384) @@ -41,7 +39,9 @@ def __init__( "Hugging Face API token is required. Pass api_key or set HF_TOKEN environment variable." ) - self.api_url = f"https://api-inference.huggingface.co/pipeline/feature-extraction/{self.model_name}" + self.api_url = ( + f"https://api-inference.huggingface.co/pipeline/feature-extraction/{self.model_name}" + ) self.headers = {"Authorization": f"Bearer {self.api_key}"} def embed_documents(self, texts: Sequence[str]) -> list[Vector]: @@ -62,13 +62,9 @@ def embed_documents(self, texts: Sequence[str]) -> list[Vector]: if response.status_code != 200: if response.status_code in (401, 403): - raise ValueError( - "Invalid Hugging Face API token or unauthorized request." - ) + raise ValueError("Invalid Hugging Face API token or unauthorized request.") elif response.status_code == 404: - raise ValueError( - f"Model '{self.model_name}' not found on Hugging Face Hub." - ) + raise ValueError(f"Model '{self.model_name}' not found on Hugging Face Hub.") else: raise RuntimeError( f"Hugging Face API request failed ({response.status_code}): {response.text}" diff --git a/src/dynavec/embeddings/openai.py b/src/dynavec/embeddings/openai.py index 05ad713..8360bd8 100644 --- a/src/dynavec/embeddings/openai.py +++ b/src/dynavec/embeddings/openai.py @@ -18,13 +18,13 @@ "text-embedding-ada-002": 1536, } + def _is_openai_retryable(exc: Exception) -> bool: """Return True for OpenAI rate-limit and server errors.""" status_code = getattr(exc, "status_code", None) - return status_code == 429 or ( - isinstance(status_code, int) and 500 <= status_code < 600 - ) + return status_code == 429 or (isinstance(status_code, int) and 500 <= status_code < 600) + def _openai_retry_after(exc: Exception) -> float | None: """Return the server-provided Retry-After delay, if available.""" @@ -88,7 +88,6 @@ def embed_documents(self, texts: list[str]) -> list[Vector]: out.extend(d.embedding for d in resp.data) return out - @retry( retry_on=_is_openai_retryable, retry_delay=_openai_retry_after, @@ -98,6 +97,4 @@ def _create_embeddings( ) -> CreateEmbeddingResponse: if dimensions is None: return self._client.embeddings.create(model=model, input=input) - return self._client.embeddings.create( - model=model, input=input, dimensions=dimensions - ) + return self._client.embeddings.create(model=model, input=input, dimensions=dimensions) diff --git a/src/dynavec/eval/chart.py b/src/dynavec/eval/chart.py index 32b6140..8772436 100644 --- a/src/dynavec/eval/chart.py +++ b/src/dynavec/eval/chart.py @@ -229,5 +229,3 @@ def plot_retrieval_metrics( plt.close(fig) return output_path - - diff --git a/src/dynavec/eval/trend.py b/src/dynavec/eval/trend.py index 5d91ab0..5ad92c8 100644 --- a/src/dynavec/eval/trend.py +++ b/src/dynavec/eval/trend.py @@ -33,9 +33,9 @@ class EvalRun: """A recorded eval run with metadata tags and metric snapshots.""" run_id: str - ts: float # epoch seconds when the run was recorded - tags: dict[str, str] # e.g. {"dataset": "squad", "model": "ada-002"} - metrics: dict[str, Any] # flat metric snapshot (recall@k, ndcg@k, faithfulness, …) + ts: float # epoch seconds when the run was recorded + tags: dict[str, str] # e.g. {"dataset": "squad", "model": "ada-002"} + metrics: dict[str, Any] # flat metric snapshot (recall@k, ndcg@k, faithfulness, …) def to_dict(self) -> dict[str, Any]: return { @@ -121,7 +121,7 @@ def record( ) self._runs.append(run) if len(self._runs) > self._max_runs: - self._runs = self._runs[-self._max_runs:] + self._runs = self._runs[-self._max_runs :] return run # ------------------------------------------------------------------ filter diff --git a/src/dynavec/fusion.py b/src/dynavec/fusion.py index 5e73b99..b17d63b 100644 --- a/src/dynavec/fusion.py +++ b/src/dynavec/fusion.py @@ -35,6 +35,7 @@ # FitResult # --------------------------------------------------------------------------- + @dataclass class FitResult: """Result of an ``RRFWeightFitter.fit()`` call. @@ -85,6 +86,7 @@ def load(cls, path: str | Path) -> FitResult: # RRFWeightFitter # --------------------------------------------------------------------------- + class RRFWeightFitter: """Learn per-retriever RRF weights from labeled evaluation queries. @@ -125,10 +127,7 @@ def score(self, weights: list[float]) -> float: for query_idx, labeled_query in enumerate(self.labeled_queries): # result_lists[retriever_idx][query_idx] → list[SearchResult] - per_retriever = [ - self.result_lists[r][query_idx] - for r in range(len(self.result_lists)) - ] + per_retriever = [self.result_lists[r][query_idx] for r in range(len(self.result_lists))] fused = reciprocal_rank_fusion(per_retriever, k=self.k, weights=weights) ranked_ids = [r.id for r in fused] @@ -241,7 +240,9 @@ def _neg_ndcg(x: np.ndarray) -> float: best_w = _softmax(result.x) best_score = -float(result.fun) - return FitResult(weights=best_w, score=best_score, method="bayesian", n_evaluations=eval_count) + return FitResult( + weights=best_w, score=best_score, method="bayesian", n_evaluations=eval_count + ) # -- public API ---------------------------------------------------------- diff --git a/src/dynavec/graph.py b/src/dynavec/graph.py index 78b61bf..534e8dd 100644 --- a/src/dynavec/graph.py +++ b/src/dynavec/graph.py @@ -105,9 +105,7 @@ def _mermaid_aliases(nodes: list[str]) -> dict[str, str]: reserved keyword) gets an `nN` alias carrying the real id as its label. Alias numbering follows sorted id order, so it is stable across exports. """ - bare = { - n for n in nodes if _MERMAID_SAFE_ID.match(n) and n.lower() not in _MERMAID_RESERVED - } + bare = {n for n in nodes if _MERMAID_SAFE_ID.match(n) and n.lower() not in _MERMAID_RESERVED} aliases: dict[str, str] = {} counter = 0 for node in nodes: @@ -368,8 +366,7 @@ def _drop_edges(self, ns: str, src: str, dst: str, relation: str | None = None) edge for edge in edges if not ( - edge.get("target") == dst - and (relation is None or edge.get("relation") == relation) + edge.get("target") == dst and (relation is None or edge.get("relation") == relation) ) ] if len(kept) == len(edges): diff --git a/src/dynavec/integrations/dspy.py b/src/dynavec/integrations/dspy.py index 821ed83..041d819 100644 --- a/src/dynavec/integrations/dspy.py +++ b/src/dynavec/integrations/dspy.py @@ -1,13 +1,13 @@ """DSPy retrieval module backed by dynavec. - import dspy - from dynavec.integrations.dspy import DynavecRM +import dspy +from dynavec.integrations.dspy import DynavecRM - rm = DynavecRM(dynavec_client, namespace="kb", k=4) - dspy.configure(rm=rm) +rm = DynavecRM(dynavec_client, namespace="kb", k=4) +dspy.configure(rm=rm) - retriever = dspy.Retrieve(k=4) - passages = retriever("what is retrieval-augmented generation?").passages +retriever = dspy.Retrieve(k=4) +passages = retriever("what is retrieval-augmented generation?").passages """ from __future__ import annotations diff --git a/src/dynavec/integrations/haystack.py b/src/dynavec/integrations/haystack.py index 247ec4e..7846e07 100644 --- a/src/dynavec/integrations/haystack.py +++ b/src/dynavec/integrations/haystack.py @@ -1,4 +1,5 @@ """Haystack integration for Dynavec.""" + from __future__ import annotations from dataclasses import asdict @@ -38,8 +39,11 @@ def _convert_filter( return {field: filters["value"]} return filters + + class DynavecDocumentStore: """Haystack DocumentStore backed by Dynavec.""" + def __init__(self, client: Dynavec, namespace: str = "default") -> None: self.client = client self.namespace = namespace @@ -53,6 +57,7 @@ def count_documents(self) -> int: hydrate=False, ) ) + def write_documents( self, documents: list[Document], @@ -77,9 +82,7 @@ def write_documents( elif policy == DuplicatePolicy.FAIL: duplicates = [document.id for document in documents if document.id in existing] if duplicates: - raise DuplicateDocumentError( - f"Documents with IDs already exist: {duplicates}" - ) + raise DuplicateDocumentError(f"Documents with IDs already exist: {duplicates}") dynavec_documents = [ DynavecDocument( @@ -111,8 +114,7 @@ def filter_documents( namespace=self.namespace, hydrate=True, ): - if filters and any(result.metadata.get(key) != value for key, value in filters.items() - ): + if filters and any(result.metadata.get(key) != value for key, value in filters.items()): continue documents.append( @@ -134,8 +136,7 @@ def to_dict(self) -> dict[str, Any]: "config": asdict(self.client.config), "namespace": self.namespace, }, - } - + } @classmethod def from_dict(cls, data: dict[str, Any]) -> DynavecDocumentStore: @@ -151,7 +152,6 @@ def from_dict(cls, data: dict[str, Any]) -> DynavecDocumentStore: ) - class DynavecRetriever: """Haystack retriever backed by Dynavec.""" diff --git a/src/dynavec/integrations/langchain.py b/src/dynavec/integrations/langchain.py index bafaf2e..96f7cbc 100644 --- a/src/dynavec/integrations/langchain.py +++ b/src/dynavec/integrations/langchain.py @@ -70,10 +70,7 @@ def add_texts( texts = list(texts) ids = ids or [str(uuid.uuid4()) for _ in texts] metadatas = metadatas or [{} for _ in texts] - docs = [ - DVDocument(id=i, text=t, metadata=m) - for i, t, m in zip(ids, texts, metadatas) - ] + docs = [DVDocument(id=i, text=t, metadata=m) for i, t, m in zip(ids, texts, metadatas)] self._client.upsert(docs, namespace=self._namespace) return ids @@ -86,11 +83,11 @@ def delete(self, ids: list[str] | None = None, **kwargs: Any) -> bool | None: def similarity_search( self, query: str, k: int = 4, filter: dict[str, Any] | None = None, **kwargs: Any ) -> list[LCDocument]: - results = self._client.search( - query, top_k=k, namespace=self._namespace, filter=filter - ) + results = self._client.search(query, top_k=k, namespace=self._namespace, filter=filter) return [ - LCDocument(page_content=r.text or "", metadata={**r.metadata, "id": r.id, "score": r.score}) + LCDocument( + page_content=r.text or "", metadata={**r.metadata, "id": r.id, "score": r.score} + ) for r in results ] @@ -119,9 +116,7 @@ async def asimilarity_search( def similarity_search_with_score( self, query: str, k: int = 4, filter: dict[str, Any] | None = None, **kwargs: Any ) -> list[tuple[LCDocument, float]]: - results = self._client.search( - query, top_k=k, namespace=self._namespace, filter=filter - ) + results = self._client.search(query, top_k=k, namespace=self._namespace, filter=filter) return [ ( LCDocument(page_content=r.text or "", metadata={**r.metadata, "id": r.id}), @@ -173,7 +168,9 @@ def max_marginal_relevance_search( mmr_lambda=lambda_mult, ) return [ - LCDocument(page_content=r.text or "", metadata={**r.metadata, "id": r.id, "score": r.score}) + LCDocument( + page_content=r.text or "", metadata={**r.metadata, "id": r.id, "score": r.score} + ) for r in results ] diff --git a/src/dynavec/integrations/llamaindex.py b/src/dynavec/integrations/llamaindex.py index 8443ff6..55f9d14 100644 --- a/src/dynavec/integrations/llamaindex.py +++ b/src/dynavec/integrations/llamaindex.py @@ -86,8 +86,7 @@ def _translate_filters(metadata_filters: MetadataFilters) -> dict[str, Any]: return {"$or": translated} raise ValueError( - "Unsupported LlamaIndex metadata filter condition for S3 Vectors: " - f"{condition.value}" + f"Unsupported LlamaIndex metadata filter condition for S3 Vectors: {condition.value}" ) diff --git a/src/dynavec/mcp/__init__.py b/src/dynavec/mcp/__init__.py index 0df5c80..fe39777 100644 --- a/src/dynavec/mcp/__init__.py +++ b/src/dynavec/mcp/__init__.py @@ -9,4 +9,3 @@ from .server import client_from_env, create_mcp_server, main __all__ = ["client_from_env", "create_mcp_server", "main"] - diff --git a/src/dynavec/mcp/server.py b/src/dynavec/mcp/server.py index af2d9df..fa7daec 100644 --- a/src/dynavec/mcp/server.py +++ b/src/dynavec/mcp/server.py @@ -6,6 +6,7 @@ import json import os import sys +import uuid from collections.abc import Mapping from typing import Any, cast @@ -13,6 +14,7 @@ from ..config import DistanceMetric, DynavecConfig from ..embeddings.base import Embedder from ..exceptions import ConfigurationError, MissingDependencyError +from ..models import Document def _resolve_embedder(env: Mapping[str, str]) -> Embedder | None: @@ -37,7 +39,9 @@ def _resolve_embedder(env: Mapping[str, str]) -> Embedder | None: if embedder_type == "openai": from ..embeddings.openai import OpenAIEmbedder - return OpenAIEmbedder(model=model or "text-embedding-3-small", api_key=env.get("OPENAI_API_KEY")) + return OpenAIEmbedder( + model=model or "text-embedding-3-small", api_key=env.get("OPENAI_API_KEY") + ) if embedder_type == "gemini": from ..embeddings.gemini import GeminiEmbedder @@ -124,7 +128,11 @@ def _format_hits(hits: list[Any], query: str, context_label: str = "") -> str: return "\n".join(lines).strip() -def create_mcp_server(db: Dynavec | None = None, name: str = "dynavec") -> Any: +def create_mcp_server( + db: Dynavec | None = None, + name: str = "dynavec", + enable_write: bool = False, +) -> Any: """Create and configure a FastMCP server exposing dynavec search and graph search tools.""" try: from mcp.server.fastmcp import FastMCP @@ -136,6 +144,100 @@ def create_mcp_server(db: Dynavec | None = None, name: str = "dynavec") -> Any: def _get_db() -> Dynavec: return db if db is not None else client_from_env() + def dynavec_remember( + text: str, + doc_id: str | None = None, + namespace: str = "default", + metadata_json: str | None = None, + ttl_seconds: int | None = None, + ) -> str: + """Store a memory document in the Dynavec database.""" + try: + if not text.strip(): + return "Error remembering document: text cannot be empty" + + metadata: dict[str, Any] = {} + + if metadata_json: + try: + parsed: object = json.loads(metadata_json) + + if not isinstance(parsed, dict): + return "Error parsing metadata_json: expected a JSON object" + + metadata = cast(dict[str, Any], parsed) + + except (json.JSONDecodeError, TypeError) as exc: + return f"Error parsing metadata_json: {exc}" + + document_id = doc_id or str(uuid.uuid4()) + + document = Document( + id=document_id, + text=text, + metadata=metadata, + ttl_seconds=ttl_seconds, + ) + + client = _get_db() + + client.upsert( + [document], + namespace=namespace, + ttl_seconds=ttl_seconds, + ) + + return f"Successfully remembered document '{document_id}'." + + except Exception as exc: + return f"Error remembering document: {exc}" + + def dynavec_get( + ids: list[str], + namespace: str = "default", + ) -> str: + """Retrieve stored memory documents by their IDs.""" + try: + if not ids: + return "Error retrieving documents: ids cannot be empty" + + client = _get_db() + documents = client.get(ids, namespace) + + if not documents: + return "No documents found." + + lines = [] + + for document in documents: + lines.append( + f"ID: {document.id}\n" + f"Text: {document.text or ''}\n" + f"Metadata: {json.dumps(document.metadata, default=str)}" + ) + + return "\n\n".join(lines) + + except Exception as exc: + return f"Error retrieving documents: {exc}" + + def dynavec_forget( + ids: list[str], + namespace: str = "default", + ) -> str: + """Delete stored memory documents by their IDs.""" + try: + if not ids: + return "Error deleting documents: ids cannot be empty" + + client = _get_db() + client.delete(ids, namespace) + + return f"Successfully deleted {len(ids)} document(s) from namespace '{namespace}'." + + except Exception as exc: + return f"Error deleting documents: {exc}" + def dynavec_search( query: str, top_k: int = 5, @@ -214,14 +316,23 @@ def dynavec_graph_search( ) return _format_hits(hits, query, f" (seeds: {seed_entities}, hops: {hops})") + # Register existing MCP tools mcp.tool()(dynavec_search) mcp.tool()(dynavec_graph_search) + + if enable_write: + mcp.tool()(dynavec_remember) + mcp.tool()(dynavec_get) + mcp.tool()(dynavec_forget) + return mcp def main(argv: list[str] | None = None) -> int: """Run the FastMCP server with stdio or SSE transport.""" - parser = argparse.ArgumentParser(prog="dynavec mcp", description="Run the dynavec FastMCP server.") + parser = argparse.ArgumentParser( + prog="dynavec mcp", description="Run the dynavec FastMCP server." + ) parser.add_argument( "--transport", choices=["stdio", "sse"], @@ -234,9 +345,15 @@ def main(argv: list[str] | None = None) -> int: default=8000, help="Port for SSE transport (default: 8000)", ) + parser.add_argument( + "--enable-write", + action="store_true", + help="Enable memory write and management tools", + ) args = parser.parse_args(argv) - server = create_mcp_server() + server = create_mcp_server(enable_write=args.enable_write) + if args.transport == "sse": server.settings.port = args.port server.run(transport="sse") diff --git a/src/dynavec/models.py b/src/dynavec/models.py index 3d34bb0..db29545 100644 --- a/src/dynavec/models.py +++ b/src/dynavec/models.py @@ -25,9 +25,7 @@ class Document: def __post_init__(self) -> None: if self.text is None and self.vector is None: - raise ValueError( - f"Document {self.id!r} must have either 'text' or 'vector' set." - ) + raise ValueError(f"Document {self.id!r} must have either 'text' or 'vector' set.") if self.ttl_seconds is not None and self.ttl_seconds <= 0: raise ValueError( f"Document {self.id!r} ttl_seconds must be positive, got {self.ttl_seconds}." diff --git a/src/dynavec/namespace.py b/src/dynavec/namespace.py index 45d6c6e..7fa91ac 100644 --- a/src/dynavec/namespace.py +++ b/src/dynavec/namespace.py @@ -38,9 +38,7 @@ def upsert( ttl_seconds: int | None = None, **kw: Any, ) -> UpsertResult: - return self._db.upsert( - documents, namespace=self._ns, ttl_seconds=ttl_seconds, **kw - ) + return self._db.upsert(documents, namespace=self._ns, ttl_seconds=ttl_seconds, **kw) def update( self, diff --git a/src/dynavec/nodes/graph.py b/src/dynavec/nodes/graph.py index b7c6684..54195e4 100644 --- a/src/dynavec/nodes/graph.py +++ b/src/dynavec/nodes/graph.py @@ -168,8 +168,7 @@ def run( state: GraphRunState | dict[str, Any] | None = None, return_state: Literal[False] = False, raise_on_failure: bool = True, - ) -> dict[str, Any]: - ... + ) -> dict[str, Any]: ... @overload def run( @@ -178,8 +177,7 @@ def run( state: GraphRunState | dict[str, Any] | None = None, return_state: Literal[True] = ..., raise_on_failure: bool = True, - ) -> GraphRunState: - ... + ) -> GraphRunState: ... @overload def run( @@ -188,8 +186,7 @@ def run( state: GraphRunState | dict[str, Any] | None = None, return_state: bool = False, raise_on_failure: bool = True, - ) -> dict[str, Any] | GraphRunState: - ... + ) -> dict[str, Any] | GraphRunState: ... def run( self, diff --git a/src/dynavec/quantization.py b/src/dynavec/quantization.py index 2ba26a1..87105f6 100644 --- a/src/dynavec/quantization.py +++ b/src/dynavec/quantization.py @@ -264,9 +264,7 @@ def encode(self, vectors: np.ndarray) -> np.ndarray: if vectors.ndim != 2: raise ValueError("vectors must be a 2D array") - codes = np.round( - (vectors - mins) / scales - 128 - ) + codes = np.round((vectors - mins) / scales - 128) return np.asarray(np.clip(codes, -128, 127), dtype=np.int8) @@ -276,8 +274,7 @@ def decode(self, codes: np.ndarray) -> np.ndarray: codes = np.asarray(codes, dtype=np.int8) return np.asarray( - (codes.astype(np.float32) + 128) * scales - + mins, + (codes.astype(np.float32) + 128) * scales + mins, dtype=np.float32, ) @@ -315,15 +312,11 @@ def fit(self, vectors: np.ndarray) -> OPQRotation: raise ValueError("vectors must be a 2D array") if x.shape[1] != self.dim: - raise ValueError( - f"Expected dimension={self.dim}, got {x.shape[1]}" - ) + raise ValueError(f"Expected dimension={self.dim}, got {x.shape[1]}") rng = np.random.default_rng(self.seed) - random_matrix = rng.normal( - size=(self.dim, self.dim) - ).astype(np.float32) + random_matrix = rng.normal(size=(self.dim, self.dim)).astype(np.float32) q, _ = np.linalg.qr(random_matrix) @@ -349,12 +342,9 @@ def inverse_transform(self, vectors: np.ndarray) -> np.ndarray: def _fitted_rotation(self) -> np.ndarray: if self._rotation is None: - raise RuntimeError( - "OPQRotation must be .fit() before use" - ) + raise RuntimeError("OPQRotation must be .fit() before use") return self._rotation - def _update_rotation( self, original: np.ndarray, @@ -402,9 +392,7 @@ def fit(self, vectors: np.ndarray) -> OptimizedProductQuantizer: self._dim = x.shape[1] if self._dim % self.m != 0: - raise ValueError( - f"m={self.m} must divide dimension={self._dim}" - ) + raise ValueError(f"m={self.m} must divide dimension={self._dim}") self._opq = OPQRotation( dim=self._dim, @@ -428,11 +416,7 @@ def fit(self, vectors: np.ndarray) -> OptimizedProductQuantizer: codes = self._pq.encode(rotated) reconstructed = self._pq.decode(codes) - error = float( - ((rotated - reconstructed) ** 2) - .sum(axis=1) - .mean() - ) + error = float(((rotated - reconstructed) ** 2).sum(axis=1).mean()) self._training_errors.append(error) self._opq._update_rotation( @@ -464,9 +448,7 @@ def asymmetric_distances( ) -> np.ndarray: pq, opq = self._fitted_components() - rotated_query = opq.transform( - np.asarray(query, dtype=np.float32) - ) + rotated_query = opq.transform(np.asarray(query, dtype=np.float32)) return pq.asymmetric_distances( rotated_query, @@ -476,15 +458,9 @@ def asymmetric_distances( def reconstruction_error(self, vectors: np.ndarray) -> float: x = np.asarray(vectors, dtype=np.float32) - reconstructed = self.decode( - self.encode(x) - ) + reconstructed = self.decode(self.encode(x)) - return float( - ((x - reconstructed) ** 2) - .sum(axis=1) - .mean() - ) + return float(((x - reconstructed) ** 2).sum(axis=1).mean()) @property def training_errors(self) -> list[float]: @@ -493,7 +469,5 @@ def training_errors(self) -> list[float]: def _fitted_components(self) -> tuple[ProductQuantizer, OPQRotation]: if self._pq is None or self._opq is None: - raise RuntimeError( - "OptimizedProductQuantizer must be .fit() before use" - ) + raise RuntimeError("OptimizedProductQuantizer must be .fit() before use") return self._pq, self._opq diff --git a/src/dynavec/retrievers.py b/src/dynavec/retrievers.py index 79005cb..6cf6209 100644 --- a/src/dynavec/retrievers.py +++ b/src/dynavec/retrievers.py @@ -600,7 +600,9 @@ def __init__( if hasattr(weights, "weights"): weights = weights.weights if len(weights) != 2: - raise ValueError("weights must contain exactly 2 elements [dense_weight, sparse_weight]") + raise ValueError( + "weights must contain exactly 2 elements [dense_weight, sparse_weight]" + ) dense_weight, sparse_weight = float(weights[0]), float(weights[1]) if dense_weight <= 0 or sparse_weight <= 0: @@ -668,7 +670,9 @@ def search( if hasattr(weights, "weights"): weights = weights.weights if len(weights) != 2: - raise ValueError("weights must contain exactly 2 elements [dense_weight, sparse_weight]") + raise ValueError( + "weights must contain exactly 2 elements [dense_weight, sparse_weight]" + ) w_dense, w_sparse = float(weights[0]), float(weights[1]) # Run dense vector search and sparse BM25 search in parallel diff --git a/src/dynavec/stores/async_dynamodb.py b/src/dynavec/stores/async_dynamodb.py index 496cdcb..4469c39 100644 --- a/src/dynavec/stores/async_dynamodb.py +++ b/src/dynavec/stores/async_dynamodb.py @@ -124,8 +124,7 @@ async def put_many( self, namespace: str, items: Sequence[ - tuple[str, str | None, Metadata] - | tuple[str, str | None, Metadata, int | None] + tuple[str, str | None, Metadata] | tuple[str, str | None, Metadata, int | None] ], ) -> None: """Upsert document tuples using DynamoDB's async batch writer.""" @@ -195,10 +194,7 @@ async def get_many( ddb = self._require_ddb() - keys = [ - {"pk": self._pk(namespace, doc_id)} - for doc_id in ids - ] + keys = [{"pk": self._pk(namespace, doc_id)} for doc_id in ids] out: dict[str, dict[str, Any]] = {} @@ -222,9 +218,7 @@ async def get_many( ): entry: dict[str, Any] = { "text": _read_text(item), - "metadata": _from_dynamo( - item.get("metadata", {}) - ), + "metadata": _from_dynamo(item.get("metadata", {})), } ttl_attr = self._config.dynamodb_ttl_attribute @@ -233,15 +227,9 @@ async def get_many( out[item["id"]] = entry - unprocessed = ( - response.get("UnprocessedKeys") or {} - ) + unprocessed = response.get("UnprocessedKeys") or {} - request = ( - unprocessed - if unprocessed - else None - ) + request = unprocessed if unprocessed else None log_store_event( self._logger, diff --git a/src/dynavec/stores/async_s3vectors.py b/src/dynavec/stores/async_s3vectors.py index f3fd66e..03eede0 100644 --- a/src/dynavec/stores/async_s3vectors.py +++ b/src/dynavec/stores/async_s3vectors.py @@ -24,15 +24,9 @@ class AsyncS3VectorsStore: def __init__(self, config: DynavecConfig, boto_session: Any) -> None: self._config = config self._session = boto_session - self._put_limiter = ( - TokenBucket(config.put_rps) - if config.put_rps is not None - else None - ) + self._put_limiter = TokenBucket(config.put_rps) if config.put_rps is not None else None self._query_limiter = ( - TokenBucket(config.query_rps) - if config.query_rps is not None - else None + TokenBucket(config.query_rps) if config.query_rps is not None else None ) self._client_context: Any | None = None self._client: Any | None = None @@ -119,10 +113,7 @@ async def put_vectors( raise ValueError("max_workers must be greater than 0.") t0 = time.perf_counter() - chunks = [ - vectors[i : i + _PUT_LIMIT] - for i in range(0, len(vectors), _PUT_LIMIT) - ] + chunks = [vectors[i : i + _PUT_LIMIT] for i in range(0, len(vectors), _PUT_LIMIT)] semaphore = asyncio.Semaphore(min(max_workers, len(chunks))) @@ -141,10 +132,7 @@ async def upload_chunk( async with semaphore: await self._put_batch(payload) - tasks = [ - asyncio.create_task(upload_chunk(chunk)) - for chunk in chunks - ] + tasks = [asyncio.create_task(upload_chunk(chunk)) for chunk in chunks] try: await asyncio.gather(*tasks) @@ -166,7 +154,6 @@ async def upload_chunk( duration_ms=round((time.perf_counter() - t0) * 1000, 2), ) - def _query_kwargs( self, query_vector: list[float], @@ -215,8 +202,7 @@ async def query( if top_k > _MAX_TOP_K: raise ValueError( - f"top_k ({top_k}) exceeds Amazon S3 Vectors " - f"maximum limit of {_MAX_TOP_K}." + f"top_k ({top_k}) exceeds Amazon S3 Vectors maximum limit of {_MAX_TOP_K}." ) results: list[dict[str, Any]] = [] @@ -263,15 +249,10 @@ async def query_pages( if top_k > _MAX_TOP_K: raise ValueError( - f"top_k ({top_k}) exceeds Amazon S3 Vectors " - f"maximum limit of {_MAX_TOP_K}." + f"top_k ({top_k}) exceeds Amazon S3 Vectors maximum limit of {_MAX_TOP_K}." ) - effective_page_size = ( - page_size - if page_size is not None - else self._config.top_k_page_size - ) + effective_page_size = page_size if page_size is not None else self._config.top_k_page_size if effective_page_size is not None and effective_page_size <= 0: raise ValueError("page_size must be a positive integer.") @@ -324,10 +305,7 @@ async def query_pages( else: buffer.extend(vectors) - while ( - len(buffer) >= effective_page_size - and yielded < top_k - ): + while len(buffer) >= effective_page_size and yielded < top_k: chunk = buffer[:effective_page_size] buffer = buffer[effective_page_size:] @@ -345,15 +323,10 @@ async def query_pages( if page.get("NextToken") is None: break - if ( - effective_page_size is not None - and buffer - and yielded < top_k - ): + if effective_page_size is not None and buffer and yielded < top_k: remaining = top_k - yielded yield buffer[:remaining] - @async_retry() async def get_vectors( self, diff --git a/src/dynavec/stores/s3vectors.py b/src/dynavec/stores/s3vectors.py index 79fb276..c4dd8a9 100644 --- a/src/dynavec/stores/s3vectors.py +++ b/src/dynavec/stores/s3vectors.py @@ -46,9 +46,7 @@ def __init__(self, config: DynavecConfig, boto_session: Any | None = None) -> No session = boto_session or boto3.Session() self._config = config - self._put_limiter = ( - TokenBucket(config.put_rps) if config.put_rps is not None else None - ) + self._put_limiter = TokenBucket(config.put_rps) if config.put_rps is not None else None self._query_limiter = ( TokenBucket(config.query_rps) if config.query_rps is not None else None ) @@ -78,7 +76,6 @@ def _put_batch(self, payload: list[dict[str, Any]]) -> None: vectors=payload, ) - def put_vectors( self, vectors: list[tuple[str, list[float], Metadata]], @@ -215,9 +212,7 @@ def query_pages( f"top_k ({top_k}) exceeds Amazon S3 Vectors maximum limit of {_MAX_TOP_K}." ) - effective_page_size = ( - page_size if page_size is not None else self._config.top_k_page_size - ) + effective_page_size = page_size if page_size is not None else self._config.top_k_page_size if effective_page_size is not None and effective_page_size <= 0: raise ValueError("page_size must be a positive integer.") @@ -313,9 +308,7 @@ def list_pages( leaves the page size to Amazon S3 Vectors. """ if page_size is not None and not _LIST_PAGE_MIN <= page_size <= _LIST_PAGE_MAX: - raise ValueError( - f"page_size must be between {_LIST_PAGE_MIN} and {_LIST_PAGE_MAX}." - ) + raise ValueError(f"page_size must be between {_LIST_PAGE_MIN} and {_LIST_PAGE_MAX}.") kwargs: dict[str, Any] = { "vectorBucketName": self._config.vector_bucket, diff --git a/src/dynavec/utils.py b/src/dynavec/utils.py index 70e8715..9d5199f 100644 --- a/src/dynavec/utils.py +++ b/src/dynavec/utils.py @@ -28,6 +28,7 @@ def decode_key_component(value: str) -> str: """Restore a component produced by :func:`encode_key_component`.""" return value.replace("%23", KEY_SEPARATOR).replace("%25", "%") + # botocore error codes that are safe to retry (throttling / transient). _RETRYABLE_CODES = frozenset( { @@ -181,7 +182,6 @@ def __init__( self.last_time = time.monotonic() self._lock = threading.Lock() - def _acquire_wait_time(self) -> float | None: with self._lock: now = time.monotonic() diff --git a/tests/integration/test_live_aws.py b/tests/integration/test_live_aws.py index bee083c..2d0f79c 100644 --- a/tests/integration/test_live_aws.py +++ b/tests/integration/test_live_aws.py @@ -97,7 +97,7 @@ def test_provision_upsert_search_roundtrip(live_db): hits = _query_with_retry(live_db, vecs[0].tolist(), top_k=5) assert hits, "no results returned from live S3 Vectors index" assert hits[0].id == "v0" - assert hits[0].text == "document number 0" # hydrated from DynamoDB + assert hits[0].text == "document number 0" # hydrated from DynamoDB # metadata pre-filter must scope results even_hits = _query_with_retry(live_db, vecs[0].tolist(), top_k=10, filter={"even": True}) @@ -110,7 +110,9 @@ def test_update_and_delete(live_db): rng = np.random.default_rng(1) v = _unit(rng, 1, DIM)[0].tolist() - live_db.upsert([Document(id="u1", vector=v, text="original", metadata={"tag": "a"})], namespace="it") + live_db.upsert( + [Document(id="u1", vector=v, text="original", metadata={"tag": "a"})], namespace="it" + ) live_db.update("u1", namespace="it", metadata={"tag": "b"}, merge_metadata=False) got = live_db.get(["u1"], namespace="it")[0] diff --git a/tests/test_async_client.py b/tests/test_async_client.py index c625910..3700406 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -38,9 +38,7 @@ def embed_query( self, text: str, ) -> list[float]: - raise AssertionError( - "AsyncDynavec must not call sync embed_query()." - ) + raise AssertionError("AsyncDynavec must not call sync embed_query().") async def aembed_query( self, @@ -53,9 +51,7 @@ def embed_documents( self, texts: list[str], ) -> list[list[float]]: - raise AssertionError( - "AsyncDynavec must not call sync embed_documents()." - ) + raise AssertionError("AsyncDynavec must not call sync embed_documents().") async def aembed_documents( self, @@ -63,19 +59,14 @@ async def aembed_documents( ) -> list[list[float]]: self.async_calls.append(texts) - return [ - [1.0, 2.0, 3.0, 4.0] - for _ in texts - ] + return [[1.0, 2.0, 3.0, 4.0] for _ in texts] class FakeVectorStore: def __init__(self) -> None: self.entered = False self.exited = False - self.put_calls: list[ - tuple[list[tuple[str, list[float], dict[str, Any]]], int] - ] = [] + self.put_calls: list[tuple[list[tuple[str, list[float], dict[str, Any]]], int]] = [] self.query_results: list[dict[str, Any]] = [] self.query_calls: list[dict[str, Any]] = [] self.query_page_results: list[list[dict[str, Any]]] = [] @@ -115,14 +106,10 @@ async def __aexit__( async def put_vectors( self, - vectors: list[ - tuple[str, list[float], dict[str, Any]] - ], + vectors: list[tuple[str, list[float], dict[str, Any]]], max_workers: int = 8, ) -> None: - self.put_calls.append( - (vectors, max_workers) - ) + self.put_calls.append((vectors, max_workers)) async def query_pages( self, @@ -179,11 +166,7 @@ async def get_many( ) ) - return { - doc_id: self.documents[doc_id] - for doc_id in ids - if doc_id in self.documents - } + return {doc_id: self.documents[doc_id] for doc_id in ids if doc_id in self.documents} async def __aenter__( self, @@ -210,9 +193,7 @@ async def put_many( ] ], ) -> None: - self.put_calls.append( - (namespace, items) - ) + self.put_calls.append((namespace, items)) def _install_fake_stores( @@ -299,9 +280,7 @@ async def test_aupsert_requires_open_client() -> None: async def test_aupsert_uses_async_embedding_and_writes_both_stores() -> None: embedder = AsyncOnlyEmbedder() - client, vectors, documents = _client( - embedder=embedder - ) + client, vectors, documents = _client(embedder=embedder) async with client: result = await client.aupsert( @@ -320,9 +299,7 @@ async def test_aupsert_uses_async_embedding_and_writes_both_stores() -> None: assert result.count == 1 assert result.ids == ["a"] - assert embedder.async_calls == [ - ["hello"] - ] + assert embedder.async_calls == [["hello"]] assert len(vectors.put_calls) == 1 assert len(documents.put_calls) == 1 @@ -337,9 +314,7 @@ async def test_aupsert_uses_async_embedding_and_writes_both_stores() -> None: 4.0, ] - namespace, document_payload = ( - documents.put_calls[0] - ) + namespace, document_payload = documents.put_calls[0] assert namespace == "tenant" assert document_payload[0][0] == "a" @@ -422,9 +397,7 @@ async def test_aupsert_runs_s3_and_dynamodb_writes_concurrently() -> None: s3_started = asyncio.Event() ddb_started = asyncio.Event() - class CoordinatedVectors( - FakeVectorStore - ): + class CoordinatedVectors(FakeVectorStore): async def put_vectors( self, vectors: list[ @@ -443,9 +416,7 @@ async def put_vectors( timeout=1, ) - class CoordinatedDocuments( - FakeDocumentStore - ): + class CoordinatedDocuments(FakeDocumentStore): async def put_many( self, namespace: str, @@ -503,15 +474,11 @@ async def put_many( async def test_partial_enter_failure_closes_first_store() -> None: vectors = FakeVectorStore() - class FailingDocumentStore( - FakeDocumentStore - ): + class FailingDocumentStore(FakeDocumentStore): async def __aenter__( self, ) -> FailingDocumentStore: - raise RuntimeError( - "DynamoDB open failed" - ) + raise RuntimeError("DynamoDB open failed") client = AsyncDynavec( _config(), diff --git a/tests/test_async_dynamodb.py b/tests/test_async_dynamodb.py index 46b5614..3cb2725 100644 --- a/tests/test_async_dynamodb.py +++ b/tests/test_async_dynamodb.py @@ -115,9 +115,7 @@ def resource( **kwargs: Any, ) -> FakeResourceContext: self.calls.append((service_name, kwargs)) - self.context = FakeResourceContext( - self.resource_instance - ) + self.context = FakeResourceContext(self.resource_instance) return self.context @@ -150,18 +148,14 @@ async def test_put_many_builds_and_writes_items(): ], ) - assert table.batch_writer_calls == [ - {"overwrite_by_pkeys": ["pk"]} - ] + assert table.batch_writer_calls == [{"overwrite_by_pkeys": ["pk"]}] assert len(table.writer.items) == 2 assert table.writer.items[0]["pk"] == "tenant#a" assert table.writer.items[0]["id"] == "a" assert table.writer.items[0]["text"] == "First" - assert table.writer.items[0]["metadata"]["score"] == Decimal( - "0.5" - ) + assert table.writer.items[0]["metadata"]["score"] == Decimal("0.5") async def test_put_many_writes_custom_ttl_attribute(): @@ -342,12 +336,7 @@ async def test_get_many_batches_at_100_keys(): async with AsyncDynamoDBStore(_config(), session) as store: await store.get_many("tenant", ids) - sizes = [ - len( - call["RequestItems"]["docs"]["Keys"] - ) - for call in resource.batch_get_calls - ] + sizes = [len(call["RequestItems"]["docs"]["Keys"]) for call in resource.batch_get_calls] assert sizes == [100, 100, 50] @@ -355,9 +344,7 @@ async def test_get_many_batches_at_100_keys(): async def test_operations_require_open_store(): store = AsyncDynamoDBStore( _config(), - FakeSession( - FakeDynamoResource(FakeTable()) - ), + FakeSession(FakeDynamoResource(FakeTable())), ) with pytest.raises(RuntimeError, match="not open"): diff --git a/tests/test_async_s3vectors.py b/tests/test_async_s3vectors.py index 9a46cba..2316fd2 100644 --- a/tests/test_async_s3vectors.py +++ b/tests/test_async_s3vectors.py @@ -112,10 +112,7 @@ async def test_put_vectors_batches_at_service_limit(): client = FakeS3Client() session = FakeSession(client) - vectors = [ - (f"key-{i}", [0.1, 0.2, 0.3, 0.4], {"tag": "test"}) - for i in range(1200) - ] + vectors = [(f"key-{i}", [0.1, 0.2, 0.3, 0.4], {"tag": "test"}) for i in range(1200)] async with AsyncS3VectorsStore(_config(), session) as store: await store.put_vectors(vectors) @@ -146,10 +143,7 @@ async def fake_put_batch(payload): monkeypatch.setattr(store, "_put_batch", fake_put_batch) - vectors = [ - (f"key-{i}", [0.1, 0.2, 0.3, 0.4], {}) - for i in range(1200) - ] + vectors = [(f"key-{i}", [0.1, 0.2, 0.3, 0.4], {}) for i in range(1200)] await store.put_vectors(vectors) @@ -167,35 +161,23 @@ async def fail(payload): monkeypatch.setattr(store, "_put_batch", fail) with pytest.raises(RuntimeError, match="S3 API failure"): - await store.put_vectors( - [("key", [0.1, 0.2, 0.3, 0.4], {})] - ) + await store.put_vectors([("key", [0.1, 0.2, 0.3, 0.4], {})]) async def test_put_vectors_requires_open_store(): store = AsyncS3VectorsStore(_config(), FakeSession(FakeS3Client())) with pytest.raises(RuntimeError, match="not open"): - await store.put_vectors( - [("key", [0.1, 0.2, 0.3, 0.4], {})] - ) + await store.put_vectors([("key", [0.1, 0.2, 0.3, 0.4], {})]) async def test_query_returns_paginated_results(): pages = [ { - "vectors": [ - {"key": f"k{i}", "distance": i * 0.01} - for i in range(100) - ], + "vectors": [{"key": f"k{i}", "distance": i * 0.01} for i in range(100)], "NextToken": "token-1", }, - { - "vectors": [ - {"key": f"k{i}", "distance": i * 0.01} - for i in range(100, 150) - ] - }, + {"vectors": [{"key": f"k{i}", "distance": i * 0.01} for i in range(100, 150)]}, ] client = FakeS3Client(pages) @@ -215,18 +197,10 @@ async def test_query_returns_paginated_results(): async def test_query_truncates_at_top_k(): pages = [ { - "vectors": [ - {"key": f"k{i}"} - for i in range(100) - ], + "vectors": [{"key": f"k{i}"} for i in range(100)], "NextToken": "token-1", }, - { - "vectors": [ - {"key": f"k{i}"} - for i in range(100, 200) - ] - }, + {"vectors": [{"key": f"k{i}"} for i in range(100, 200)]}, ] client = FakeS3Client(pages) @@ -243,14 +217,7 @@ async def test_query_truncates_at_top_k(): async def test_query_pages_respects_page_size(): - pages = [ - { - "vectors": [ - {"key": f"k{i}"} - for i in range(25) - ] - } - ] + pages = [{"vectors": [{"key": f"k{i}"} for i in range(25)]}] client = FakeS3Client(pages) session = FakeSession(client) diff --git a/tests/test_bedrock_multimodal.py b/tests/test_bedrock_multimodal.py index 707e952..7ffdec8 100644 --- a/tests/test_bedrock_multimodal.py +++ b/tests/test_bedrock_multimodal.py @@ -237,4 +237,3 @@ def test_empty_string_text_is_sent(mock_boto_session): payload = json.loads(client.invoke_model.call_args[1]["body"]) assert payload["inputText"] == "" assert "inputImage" in payload - diff --git a/tests/test_bm25_retriever.py b/tests/test_bm25_retriever.py index 666d16e..8827185 100644 --- a/tests/test_bm25_retriever.py +++ b/tests/test_bm25_retriever.py @@ -108,6 +108,7 @@ def db(monkeypatch): # Tokenizer tests # --------------------------------------------------------------------------- + def test_default_tokenize_basic(): tokens = default_tokenize("The quick brown fox jumps over the lazy dog.") # Stopwords ("the", "over") filtered out @@ -150,6 +151,7 @@ def test_default_tokenize_empty_and_special(): # BM25Index tests # --------------------------------------------------------------------------- + def test_bm25_index_validation(): with pytest.raises(ValueError, match="k1 must be non-negative"): BM25Index(k1=-1.0) @@ -245,12 +247,15 @@ def test_bm25_index_empty_query(): # BM25Retriever tests # --------------------------------------------------------------------------- + def test_bm25_retriever_basic(db): retriever = BM25Retriever(db) - retriever.index_documents([ - Document(id="doc1", text="Dell XPS-13-9310 laptop"), - Document(id="doc2", text="Lenovo ThinkPad laptop"), - ]) + retriever.index_documents( + [ + Document(id="doc1", text="Dell XPS-13-9310 laptop"), + Document(id="doc2", text="Lenovo ThinkPad laptop"), + ] + ) results = retriever.search("XPS-13-9310") assert len(results) >= 1 @@ -266,10 +271,12 @@ def test_bm25_retriever_basic(db): @pytest.mark.asyncio async def test_bm25_retriever_async(db): retriever = BM25Retriever(db) - retriever.index_documents([ - Document(id="d1", text="ConnectionResetError encountered"), - Document(id="d2", text="HTTP 500 internal server error"), - ]) + retriever.index_documents( + [ + Document(id="d1", text="ConnectionResetError encountered"), + Document(id="d2", text="HTTP 500 internal server error"), + ] + ) results = await retriever.asearch("ConnectionResetError") assert len(results) == 1 @@ -290,6 +297,7 @@ def test_bm25_retriever_populate_from_store(db): # BM25HybridRetriever tests # --------------------------------------------------------------------------- + def test_hybrid_retriever_initialization_and_weights(db): retriever = BM25HybridRetriever(db, dense_weight=1.2, sparse_weight=0.6) assert retriever.dense_weight == 1.2 @@ -347,6 +355,7 @@ async def test_hybrid_retriever_asearch(db): # Client & NamespaceView Ergonomics tests # --------------------------------------------------------------------------- + def test_client_retriever_ergonomics(db): # as_bm25_retriever bm25 = db.as_bm25_retriever(populate_from_store=True) diff --git a/tests/test_cache.py b/tests/test_cache.py index d44587b..26bb754 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -44,7 +44,7 @@ def test_dissimilar_miss(): def test_namespace_and_topk_scoping(): c = SemanticCache(threshold=0.5) c.put("ns1", [1.0, 0.0], 5, None, _res("a")) - assert c.get("ns2", [1.0, 0.0], 5, None) is None # different namespace + assert c.get("ns2", [1.0, 0.0], 5, None) is None # different namespace assert c.get("ns1", [1.0, 0.0], 10, None) is None # different top_k @@ -216,9 +216,10 @@ def Table(self, table_name): ttl_jitter_seconds=30, ) - with patch("dynavec.cache.time.time", return_value=1_000), patch( - "dynavec.cache.random.randint", side_effect=[0, 30] - ) as randint: + with ( + patch("dynavec.cache.time.time", return_value=1_000), + patch("dynavec.cache.random.randint", side_effect=[0, 30]) as randint, + ): cache.put("ns", [1.0, 0.0], 5, None, _res("a")) cache.put("ns", [0.0, 1.0], 5, None, _res("b")) diff --git a/tests/test_chat_models.py b/tests/test_chat_models.py index 29545a0..a00d8bc 100644 --- a/tests/test_chat_models.py +++ b/tests/test_chat_models.py @@ -89,6 +89,7 @@ def __init__(self) -> None: async def create(self, **kwargs: Any) -> Any: self.last_kwargs = kwargs if kwargs.get("stream"): + async def _async_gen() -> Any: yield SimpleNamespace( choices=[ @@ -107,6 +108,7 @@ async def _async_gen() -> Any: ) ] ) + return _async_gen() if kwargs.get("tools"): @@ -198,9 +200,7 @@ def __enter__(self) -> list[Any]: ), SimpleNamespace( type="content_block_delta", - delta=SimpleNamespace( - type="input_json_delta", partial_json='{"loc":"NY"}' - ), + delta=SimpleNamespace(type="input_json_delta", partial_json='{"loc":"NY"}'), ), ] @@ -254,6 +254,7 @@ async def _gen() -> Any: type="input_json_delta", partial_json='{"loc":"Async NY"}' ), ) + return _gen() async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: diff --git a/tests/test_client_inmemory.py b/tests/test_client_inmemory.py index 5d3ec0f..75c22b6 100644 --- a/tests/test_client_inmemory.py +++ b/tests/test_client_inmemory.py @@ -90,7 +90,11 @@ def query_pages( yield hits[i : i + chunk_size] def get_vectors(self, keys, return_metadata=False): - return {k: {"vector": self._store[k][0], "metadata": self._store[k][1]} for k in keys if k in self._store} + return { + k: {"vector": self._store[k][0], "metadata": self._store[k][1]} + for k in keys + if k in self._store + } def delete_vectors(self, keys): for k in keys: @@ -128,9 +132,12 @@ def get_versioned(self, namespace, doc_id): def get_many(self, namespace, ids): return { - i: {"text": self._store[(namespace, i)]["text"], - "metadata": self._store[(namespace, i)]["metadata"]} - for i in ids if (namespace, i) in self._store + i: { + "text": self._store[(namespace, i)]["text"], + "metadata": self._store[(namespace, i)]["metadata"], + } + for i in ids + if (namespace, i) in self._store } def delete_many(self, namespace, ids): @@ -229,6 +236,7 @@ def db(monkeypatch): ) return Dynavec(cfg, embedder=HashEmbedder(8)) + @pytest.fixture def cross_encoder_db(monkeypatch): monkeypatch.setattr(client_mod, "S3VectorsStore", FakeS3) @@ -244,6 +252,7 @@ def cross_encoder_db(monkeypatch): ) return Dynavec(cfg, embedder=HashEmbedder(8)) + def test_upsert_and_search_roundtrip(db): res = db.upsert( [ @@ -277,6 +286,7 @@ def test_graph_shortest_path_respects_hop_cap(db): assert db.graph_shortest_path("a", "d", hops=2) == [] + def test_graph_shortest_path_returns_minimum_hops(db): db.graph_add_edge("a", "related_to", "c") db.graph_add_edge("c", "related_to", "e") @@ -291,6 +301,7 @@ def test_graph_shortest_path_returns_minimum_hops(db): "d", ] + def test_search_explain_handles_empty_results(db): result = db.search("missing", explain=True) @@ -405,6 +416,7 @@ def test_dimension_mismatch_raises(db): with pytest.raises(DimensionMismatchError): db.upsert([Document(id="x", vector=[0.1, 0.2])]) # wrong dim (2 != 8) + class WrongOutputEmbedder(HashEmbedder): def embed_documents(self, texts): return [[0.1, 0.2] for _ in texts] @@ -516,7 +528,7 @@ def test_ns_tag_present_in_s3(db): db.upsert([Document(id="1", text="hello")], namespace="ns9") # reach into the fake to confirm the namespace tag was written store = db._vectors._store - (_, meta), = (v for k, v in store.items()) + ((_, meta),) = (v for k, v in store.items()) assert meta[NS_METADATA_KEY] == "ns9" @@ -526,7 +538,7 @@ def test_update_metadata_merge_preserves_vector(db): db.update("1", metadata={"rating": 5}) after_meta = db.get(["1"])[0].metadata assert after_meta["cat"] == "food" # preserved - assert after_meta["rating"] == 5 # added + assert after_meta["rating"] == 5 # added # vector unchanged because neither text nor vector was updated assert db._vectors._store["default#1"][0] == before @@ -582,7 +594,10 @@ def read_then_race(namespace, doc_id): snapshot = real_read(namespace, doc_id) # another writer commits after our read but before our write db._docs.put_versioned( - namespace, doc_id, snapshot["text"], {**snapshot["metadata"], "views": 2}, + namespace, + doc_id, + snapshot["text"], + {**snapshot["metadata"], "views": 2}, expected_version=snapshot["version"], ) return snapshot @@ -616,9 +631,7 @@ def test_update_upsert_if_missing_starts_at_version_one(db): def test_search_stream_yields_incrementally(db): - db.upsert( - [Document(id=str(i), text=f"apple item {i}") for i in range(5)] - ) + db.upsert([Document(id=str(i), text=f"apple item {i}") for i in range(5)]) gen = db.search_stream("apple", top_k=4) first = next(gen) assert first.text is not None @@ -688,7 +701,10 @@ def test_search_many_explain_preserves_order_and_namespace(db, explain): ns = db.namespace("kb") ns.upsert([Document(id="1", text="apple"), Document(id="2", text="rocket")]) batches = db.search_many( - ["rocket", "apple"], namespace="kb", top_k=1, explain=explain, + ["rocket", "apple"], + namespace="kb", + top_k=1, + explain=explain, normalize_scores=True, ) assert len(batches) == 2 @@ -724,6 +740,7 @@ def test_graph_search_scopes_to_related_docs(db): hits = db.graph_search("apple", seed_entities=["fruit"], top_k=5) assert {h.id for h in hits} == {"d1", "d2"} # d3 excluded by the graph + def test_hybrid_graph_search_fuses_ann_and_graph_results(db): db.upsert( [ @@ -888,11 +905,12 @@ def test_search_telemetry_marks_cache_hit(db): db._telemetry = rec db._cache = SemanticCache(threshold=0.99) db.upsert([Document(id="1", text="apple pie")]) - db.search("apple pie", top_k=3) # miss -> populates cache - db.search("apple pie", top_k=3) # hit + db.search("apple pie", top_k=3) # miss -> populates cache + db.search("apple pie", top_k=3) # hit hits = [e.cache_hit for e in rec.events()] assert True in hits and False in hits + def test_cross_encoder_rerank_on_toy_data(monkeypatch, cross_encoder_db): class FakeCrossEncoder: instance = None @@ -902,10 +920,7 @@ def __init__(self, model_name): FakeCrossEncoder.instance = self def predict(self, pairs): - return [ - 0.95 if "target document" in document else 0.10 - for _, document in pairs - ] + return [0.95 if "target document" in document else 0.10 for _, document in pairs] monkeypatch.setitem( sys.modules, @@ -913,18 +928,20 @@ def predict(self, pairs): SimpleNamespace(CrossEncoder=FakeCrossEncoder), ) - cross_encoder_db.upsert([ - Document( - id="d1", - text="ordinary document", - vector=[0.1] * 8, - ), - Document( - id="d2", - text="target document", - vector=[0.1] * 8, - ), - ]) + cross_encoder_db.upsert( + [ + Document( + id="d1", + text="ordinary document", + vector=[0.1] * 8, + ), + Document( + id="d2", + text="target document", + vector=[0.1] * 8, + ), + ] + ) results = cross_encoder_db.search( "find the target", @@ -937,18 +954,13 @@ def predict(self, pairs): assert FakeCrossEncoder.instance.model_name == "toy-cross-encoder" -def test_search_explain_records_cross_encoder_rerank_stage( - monkeypatch, cross_encoder_db -): +def test_search_explain_records_cross_encoder_rerank_stage(monkeypatch, cross_encoder_db): class FakeCrossEncoder: def __init__(self, model_name): self.model_name = model_name def predict(self, pairs): - return [ - 0.95 if "target document" in document else 0.10 - for _, document in pairs - ] + return [0.95 if "target document" in document else 0.10 for _, document in pairs] monkeypatch.setitem( sys.modules, @@ -956,18 +968,20 @@ def predict(self, pairs): SimpleNamespace(CrossEncoder=FakeCrossEncoder), ) - cross_encoder_db.upsert([ - Document( - id="d1", - text="ordinary document", - vector=[0.1] * 8, - ), - Document( - id="d2", - text="target document", - vector=[0.1] * 8, - ), - ]) + cross_encoder_db.upsert( + [ + Document( + id="d1", + text="ordinary document", + vector=[0.1] * 8, + ), + Document( + id="d2", + text="target document", + vector=[0.1] * 8, + ), + ] + ) result = cross_encoder_db.search( "find the target", @@ -987,13 +1001,15 @@ def predict(self, pairs): def test_cross_encoder_rerank_requires_text_query(cross_encoder_db): - cross_encoder_db.upsert([ - Document( - id="d1", - text="some document", - vector=[0.1] * 8, - ), - ]) + cross_encoder_db.upsert( + [ + Document( + id="d1", + text="some document", + vector=[0.1] * 8, + ), + ] + ) with pytest.raises(ConfigurationError, match="requires a text query"): cross_encoder_db.search( @@ -1002,6 +1018,7 @@ def test_cross_encoder_rerank_requires_text_query(cross_encoder_db): rerank="cross-encoder", ) + def test_cross_encoder_rerank_requires_document_text(monkeypatch, cross_encoder_db): class FakeCrossEncoder: def __init__(self, model_name): @@ -1013,13 +1030,15 @@ def __init__(self, model_name): SimpleNamespace(CrossEncoder=FakeCrossEncoder), ) - cross_encoder_db.upsert([ - Document( - id="d1", - text=None, - vector=[0.1] * 8, - ), - ]) + cross_encoder_db.upsert( + [ + Document( + id="d1", + text=None, + vector=[0.1] * 8, + ), + ] + ) with pytest.raises(ConfigurationError, match="requires document text"): cross_encoder_db.search( @@ -1033,15 +1052,15 @@ def test_writes_invalidate_query_cache(db): db._cache = SemanticCache(threshold=0.99) db.upsert([Document(id="1", text="apple pie")], namespace="ns") db.upsert([Document(id="o1", text="apple pie")], namespace="other") - db.search("apple pie", top_k=3, namespace="ns") # miss -> cached - db.search("apple pie", top_k=3, namespace="other") # miss -> cached + db.search("apple pie", top_k=3, namespace="ns") # miss -> cached + db.search("apple pie", top_k=3, namespace="other") # miss -> cached assert db._cache.misses == 2 # a write to "ns" evicts only that namespace's entries db.upsert([Document(id="2", text="apple pie tart")], namespace="ns") - db.search("apple pie", top_k=3, namespace="other") # still cached -> hit + db.search("apple pie", top_k=3, namespace="other") # still cached -> hit assert db._cache.hits == 1 - res = db.search("apple pie", top_k=3, namespace="ns") # evicted -> fresh + res = db.search("apple pie", top_k=3, namespace="ns") # evicted -> fresh assert db._cache.misses == 3 assert "2" in {r.id for r in res} @@ -1058,14 +1077,17 @@ def test_writes_keep_cache_when_invalidation_disabled(monkeypatch): monkeypatch.setattr(client_mod, "S3VectorsStore", FakeS3) monkeypatch.setattr(client_mod, "DynamoDBStore", FakeDDB) cfg = DynavecConfig( - vector_bucket="b", index="i", table="t", dimension=8, + vector_bucket="b", + index="i", + table="t", + dimension=8, cache_invalidate_on_write=False, ) db = Dynavec(cfg, embedder=HashEmbedder(8)) db._cache = SemanticCache(threshold=0.99) db.upsert([Document(id="1", text="apple pie")], namespace="ns") - db.search("apple pie", top_k=3, namespace="ns") # cached + db.search("apple pie", top_k=3, namespace="ns") # cached db.upsert([Document(id="2", text="apple pie tart")], namespace="ns") - db.search("apple pie", top_k=3, namespace="ns") # stale hit + db.search("apple pie", top_k=3, namespace="ns") # stale hit assert db._cache.hits == 1 diff --git a/tests/test_crewai_tool.py b/tests/test_crewai_tool.py index 852e575..4a9684c 100644 --- a/tests/test_crewai_tool.py +++ b/tests/test_crewai_tool.py @@ -80,4 +80,4 @@ def test_as_crewai_tool_missing_dependency_raises(): from dynavec.integrations.tools import as_crewai_tool with pytest.raises(MissingDependencyError): - as_crewai_tool(_FakeClient()) \ No newline at end of file + as_crewai_tool(_FakeClient()) diff --git a/tests/test_describe.py b/tests/test_describe.py index 4d2ba4f..1e72411 100644 --- a/tests/test_describe.py +++ b/tests/test_describe.py @@ -128,4 +128,4 @@ def test_get_index_request_matches_s3vectors_service_model(): result = store.get_index() stubber.assert_no_pending_responses() - assert result["index"]["indexName"] == "test-index" \ No newline at end of file + assert result["index"]["indexName"] == "test-index" diff --git a/tests/test_dynamodb.py b/tests/test_dynamodb.py index 917e314..a0ccfc9 100644 --- a/tests/test_dynamodb.py +++ b/tests/test_dynamodb.py @@ -138,7 +138,9 @@ def test_put_versioned_conflict_leaves_item_untouched(moto_store): moto_store.put_versioned("tenant", "a", "loser", {"by": "stale"}, expected_version=1) assert (info.value.doc_id, info.value.namespace, info.value.expected_version) == ( - "a", "tenant", 1, + "a", + "tenant", + 1, ) assert moto_store.get_versioned("tenant", "a")["metadata"] == {"by": "second"} diff --git a/tests/test_embedding_cache.py b/tests/test_embedding_cache.py index f17d8f3..b673cc7 100644 --- a/tests/test_embedding_cache.py +++ b/tests/test_embedding_cache.py @@ -16,13 +16,14 @@ # Fake embedders (same style as test_async_embeddings.py) # --------------------------------------------------------------------------- + class _CountingEmbedder(Embedder): """Deterministic embedder that tracks every text it actually embeds.""" dimension: ClassVar[int] = 4 def __init__(self) -> None: - self.calls: list[list[str]] = [] # one entry per embed_documents call + self.calls: list[list[str]] = [] # one entry per embed_documents call def embed_documents(self, texts: list[str]) -> list[Vector]: self.calls.append(list(texts)) @@ -59,6 +60,7 @@ async def aembed_documents(self, texts: list[str]) -> list[Vector]: # Custom cache backend (proves pluggability) # --------------------------------------------------------------------------- + class _MinimalCache(EmbeddingCache): """Bare-minimum custom cache to verify the ABC contract works.""" @@ -77,19 +79,23 @@ def set(self, key: str, vector: Vector) -> None: # 1. _make_key # --------------------------------------------------------------------------- + def test_make_key_same_inputs_same_key(): assert _make_key("hello", "m1") == _make_key("hello", "m1") + def test_make_key_different_text_different_key(): assert _make_key("hello", "m1") != _make_key("world", "m1") + def test_make_key_different_model_different_key(): assert _make_key("hello", "m1") != _make_key("hello", "m2") + def test_make_key_format(): key = _make_key("abc", "my-model") sha, model = key.split(":") - assert len(sha) == 64 # sha256 hex is always 64 chars + assert len(sha) == 64 # sha256 hex is always 64 chars assert model == "my-model" @@ -97,40 +103,47 @@ def test_make_key_format(): # 2. InMemoryCache # --------------------------------------------------------------------------- + def test_inmemory_miss_returns_none(): assert InMemoryCache().get("nonexistent") is None + def test_inmemory_set_then_get(): cache = InMemoryCache() cache.set("k", [1.0, 2.0]) assert cache.get("k") == [1.0, 2.0] + def test_inmemory_get_for_set_for(): cache = InMemoryCache() cache.set_for("hello", "model-a", [0.5, 0.5]) assert cache.get_for("hello", "model-a") == [0.5, 0.5] - assert cache.get_for("hello", "model-b") is None # different model → miss + assert cache.get_for("hello", "model-b") is None # different model → miss + def test_inmemory_lru_eviction(): cache = InMemoryCache(max_size=2) cache.set("a", [1.0]) cache.set("b", [2.0]) - cache.get("a") # touch "a" → now "b" is LRU - cache.set("c", [3.0]) # should evict "b", not "a" + cache.get("a") # touch "a" → now "b" is LRU + cache.set("c", [3.0]) # should evict "b", not "a" assert cache.get("b") is None assert cache.get("a") == [1.0] assert cache.get("c") == [3.0] + def test_inmemory_max_size_none_is_unlimited(): cache = InMemoryCache(max_size=None) for i in range(10_000): cache.set(str(i), [float(i)]) assert len(cache) == 10_000 + def test_inmemory_negative_max_size_raises(): with pytest.raises(ValueError): InMemoryCache(max_size=-1) + def test_inmemory_len_and_contains(): cache = InMemoryCache() assert len(cache) == 0 @@ -139,22 +152,25 @@ def test_inmemory_len_and_contains(): assert "x" in cache assert "y" not in cache + def test_inmemory_clear(): cache = InMemoryCache() cache.set("x", [0.0]) cache.clear() assert len(cache) == 0 + def test_inmemory_stats_hit_rate(): cache = InMemoryCache() cache.set_for("hi", "m", [1.0]) - cache.get_for("hi", "m") # hit - cache.get_for("bye", "m") # miss + cache.get_for("hi", "m") # hit + cache.get_for("bye", "m") # miss s = cache.stats() assert s["hits"] == 1 assert s["misses"] == 1 assert s["hit_rate"] == 0.5 + def test_inmemory_reset_stats(): cache = InMemoryCache() cache.set_for("hi", "m", [1.0]) @@ -167,28 +183,32 @@ def test_inmemory_reset_stats(): # 3. CachedEmbedder — embed_documents # --------------------------------------------------------------------------- + def test_first_call_hits_inner(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) embedder.embed_documents(["a", "b"]) assert inner.calls == [["a", "b"]] + def test_second_call_is_full_cache_hit(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) v1 = embedder.embed_documents(["a", "b"]) v2 = embedder.embed_documents(["a", "b"]) - assert len(inner.calls) == 1 # inner called only once + assert len(inner.calls) == 1 # inner called only once assert v1 == v2 + def test_partial_hit_calls_inner_only_for_misses(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) - embedder.embed_documents(["a"]) # warm "a" - embedder.embed_documents(["a", "b"]) # "a" hit, "b" miss + embedder.embed_documents(["a"]) # warm "a" + embedder.embed_documents(["a", "b"]) # "a" hit, "b" miss # second call should only have sent "b" to inner assert inner.calls[-1] == ["b"] + def test_order_preserved_with_mixed_hits_misses(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) @@ -199,12 +219,14 @@ def test_order_preserved_with_mixed_hits_misses(): assert v_zyx[1] == v_xyz[1] assert v_zyx[2] == v_xyz[0] + def test_empty_input_returns_empty(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) assert embedder.embed_documents([]) == [] assert inner.calls == [] + def test_dimension_delegated_to_inner(): inner = _CountingEmbedder() assert CachedEmbedder(inner).dimension == inner.dimension @@ -214,19 +236,21 @@ def test_dimension_delegated_to_inner(): # 4. CachedEmbedder — embed_query # --------------------------------------------------------------------------- + def test_embed_query_miss_then_hit(): inner = _CountingEmbedder() embedder = CachedEmbedder(inner, cache=InMemoryCache()) v1 = embedder.embed_query("hello") v2 = embedder.embed_query("hello") assert v1 == v2 - assert inner.total_texts_embedded == 1 # embedded only once + assert inner.total_texts_embedded == 1 # embedded only once # --------------------------------------------------------------------------- # 5. Async variants # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_aembed_documents_caches(): inner = _CountingEmbedder() @@ -236,6 +260,7 @@ async def test_aembed_documents_caches(): assert len(inner.calls) == 1 assert v1 == v2 + @pytest.mark.asyncio async def test_aembed_documents_partial_hit(): inner = _CountingEmbedder() @@ -244,6 +269,7 @@ async def test_aembed_documents_partial_hit(): await embedder.aembed_documents(["p", "q"]) assert inner.calls[-1] == ["q"] + @pytest.mark.asyncio async def test_aembed_query_caches(): inner = _CountingEmbedder() @@ -253,6 +279,7 @@ async def test_aembed_query_caches(): assert v1 == v2 assert inner.total_texts_embedded == 1 + @pytest.mark.asyncio async def test_aembed_documents_native_async_inner(): """Cache should work when inner has a native async path.""" @@ -260,7 +287,7 @@ async def test_aembed_documents_native_async_inner(): embedder = CachedEmbedder(inner, cache=InMemoryCache()) v1 = await embedder.aembed_documents(["foo", "bar"]) v2 = await embedder.aembed_documents(["foo", "bar"]) - assert len(inner.async_calls) == 1 # native async called only once + assert len(inner.async_calls) == 1 # native async called only once assert v1 == v2 @@ -268,6 +295,7 @@ async def test_aembed_documents_native_async_inner(): # 6. model_key isolation # --------------------------------------------------------------------------- + def test_different_model_keys_dont_share_entries(): shared_cache = InMemoryCache() inner_a = _CountingEmbedder() @@ -275,15 +303,16 @@ def test_different_model_keys_dont_share_entries(): ea = CachedEmbedder(inner_a, cache=shared_cache, model_key="model-A") eb = CachedEmbedder(inner_b, cache=shared_cache, model_key="model-B") ea.embed_documents(["hello"]) - eb.embed_documents(["hello"]) # same text, different key → must be a miss + eb.embed_documents(["hello"]) # same text, different key → must be a miss assert inner_a.total_texts_embedded == 1 - assert inner_b.total_texts_embedded == 1 # inner_b was also called + assert inner_b.total_texts_embedded == 1 # inner_b was also called # --------------------------------------------------------------------------- # 7. Pluggable custom backend # --------------------------------------------------------------------------- + def test_custom_cache_backend_is_used(): custom = _MinimalCache() inner = _CountingEmbedder() @@ -293,6 +322,7 @@ def test_custom_cache_backend_is_used(): assert inner.total_texts_embedded == 1 assert embedder.cache is custom + def test_default_cache_is_inmemory(): embedder = CachedEmbedder(_CountingEmbedder()) assert isinstance(embedder.cache, InMemoryCache) diff --git a/tests/test_eval_trend.py b/tests/test_eval_trend.py index 1bea2d7..3b031c8 100644 --- a/tests/test_eval_trend.py +++ b/tests/test_eval_trend.py @@ -12,6 +12,7 @@ # Helpers # --------------------------------------------------------------------------- + def _retrieval_summary(recall_10: float, ndcg_10: float, mrr: float = 0.8) -> RetrievalEvalSummary: return RetrievalEvalSummary( total_queries=10, @@ -40,6 +41,7 @@ def _rag_summary(faithfulness: float, relevance: float = 0.85) -> EvalSummary: # 1. record() # --------------------------------------------------------------------------- + def test_record_retrieval_summary_returns_eval_run(): store = EvalRunStore() summary = _retrieval_summary(0.85, 0.80) @@ -78,10 +80,11 @@ def test_record_respects_max_runs(): # 2. filter() # --------------------------------------------------------------------------- + def test_filter_by_single_tag(): store = EvalRunStore() store.record(_retrieval_summary(0.80, 0.75), tags={"dataset": "squad", "model": "ada"}) - store.record(_retrieval_summary(0.70, 0.65), tags={"dataset": "nq", "model": "ada"}) + store.record(_retrieval_summary(0.70, 0.65), tags={"dataset": "nq", "model": "ada"}) store.record(_retrieval_summary(0.90, 0.85), tags={"dataset": "squad", "model": "v3"}) squad_runs = store.filter(dataset="squad") @@ -109,6 +112,7 @@ def test_filter_no_match_returns_empty(): # 3. trend() # --------------------------------------------------------------------------- + def test_trend_recall_returns_sorted_time_series(): store = EvalRunStore() # record three runs at explicit timestamps @@ -139,7 +143,7 @@ def test_trend_faithfulness_from_rag_summaries(): def test_trend_with_tag_filter(): store = EvalRunStore() store.record(_retrieval_summary(0.60, 0.55), tags={"model": "ada"}, ts=100.0) - store.record(_retrieval_summary(0.80, 0.75), tags={"model": "v3"}, ts=200.0) + store.record(_retrieval_summary(0.80, 0.75), tags={"model": "v3"}, ts=200.0) store.record(_retrieval_summary(0.65, 0.60), tags={"model": "ada"}, ts=300.0) series = store.trend("recall", k=10, model="ada") @@ -163,6 +167,7 @@ def test_trend_skips_runs_without_metric(): # 4. compare() # --------------------------------------------------------------------------- + def test_compare_two_runs_returns_both(): store = EvalRunStore() r1 = store.record(_retrieval_summary(0.75, 0.70), tags={"model": "ada"}) @@ -186,8 +191,10 @@ def test_compare_missing_id_raises(): # 5. to_dict round-trip # --------------------------------------------------------------------------- + def test_eval_run_to_dict_is_serialisable(): import json + store = EvalRunStore() run = store.record(_retrieval_summary(0.82, 0.78), tags={"dataset": "squad"}) d = run.to_dict() diff --git a/tests/test_fusion.py b/tests/test_fusion.py index c2ab0cb..a072039 100644 --- a/tests/test_fusion.py +++ b/tests/test_fusion.py @@ -13,6 +13,7 @@ def _make_result(doc_id: str, score: float) -> SearchResult: # ── helpers ───────────────────────────────────────────────────────────────── + def _two_retriever_fixture(): """Two queries, two retrievers — dense is better.""" labeled = [ @@ -41,6 +42,7 @@ def _three_retriever_fixture(): # ── score ─────────────────────────────────────────────────────────────────── + def test_score_returns_float(): labeled, dense, sparse = _two_retriever_fixture() fitter = RRFWeightFitter(labeled, [dense, sparse]) @@ -50,6 +52,7 @@ def test_score_returns_float(): # ── fit() returns FitResult ───────────────────────────────────────────────── + @pytest.mark.parametrize("method", ["grid", "random"]) def test_fit_returns_fit_result(method): labeled, dense, sparse = _two_retriever_fixture() @@ -65,6 +68,7 @@ def test_fit_returns_fit_result(method): # ── grid search ───────────────────────────────────────────────────────────── + def test_fit_grid_improves_over_equal_weights(): labeled = [LabeledQuery(query="q1", relevant_ids={"doc_a"})] dense = [[_make_result("doc_a", 0.9), _make_result("doc_b", 0.1)]] @@ -91,6 +95,7 @@ def test_fit_grid_three_retrievers(): # ── random search ─────────────────────────────────────────────────────────── + def test_fit_random_three_retrievers(): labeled, r1, r2, r3 = _three_retriever_fixture() fitter = RRFWeightFitter(labeled, [r1, r2, r3]) @@ -102,6 +107,7 @@ def test_fit_random_three_retrievers(): # ── bayesian fitting ──────────────────────────────────────────────────────── + def test_fit_bayesian_two_retrievers(): """Bayesian fitting should return valid FitResult.""" labeled, dense, sparse = _two_retriever_fixture() @@ -128,6 +134,7 @@ def test_fit_bayesian_three_retrievers(): # ── serialisation (save / load) ───────────────────────────────────────────── + def test_save_load_roundtrip(tmp_path): """FitResult should survive a JSON roundtrip.""" original = FitResult( @@ -166,6 +173,7 @@ def test_to_dict(): # ── end-to-end: fit then save/load ────────────────────────────────────────── + def test_fit_and_save_load(tmp_path): """Full workflow: fit → save → load → use weights.""" labeled, dense, sparse = _two_retriever_fixture() @@ -182,6 +190,7 @@ def test_fit_and_save_load(tmp_path): # ── error handling ────────────────────────────────────────────────────────── + def test_empty_labeled_queries_raises(): with pytest.raises(ValueError, match="labeled_queries"): RRFWeightFitter([], [[]]) diff --git a/tests/test_graph_export.py b/tests/test_graph_export.py index 93a3c8a..dc04308 100644 --- a/tests/test_graph_export.py +++ b/tests/test_graph_export.py @@ -55,9 +55,7 @@ def graph(): def test_mermaid_export_renders_the_namespace_subgraph(graph): assert graph.graph_export("mermaid", "corp") == ( - "graph LR\n" - " acme -->|competes_with| globex\n" - " globex -->|supplies| initech\n" + "graph LR\n acme -->|competes_with| globex\n globex -->|supplies| initech\n" ) @@ -84,10 +82,7 @@ def test_cycle_terminates_and_emits_each_edge_once(): g.add_edge("ns", "a", "next", "b") # duplicate adjacency entry assert g.graph_export("mermaid", "ns") == ( - "graph LR\n" - " a -->|next| b\n" - " b -->|next| c\n" - " c -->|next| a\n" + "graph LR\n a -->|next| b\n b -->|next| c\n c -->|next| a\n" ) @@ -98,9 +93,7 @@ def test_cycle_terminates_when_walking_from_a_root(): g.add_node("ns", "orphan") # unreachable from "a", so absent assert g.graph_export("mermaid", "ns", roots=["a"]) == ( - "graph LR\n" - " a -->|next| b\n" - " b -->|next| a\n" + "graph LR\n a -->|next| b\n b -->|next| a\n" ) @@ -108,11 +101,7 @@ def test_self_loop_terminates(): g = FakeGraph() g.add_edge("ns", "a", "cites", "a") - assert g.graph_export("dot", "ns") == ( - "digraph G {\n" - ' "a" -> "a" [label="cites"];\n' - "}\n" - ) + assert g.graph_export("dot", "ns") == ('digraph G {\n "a" -> "a" [label="cites"];\n}\n') # -------------------------------------------------------------------- escaping @@ -156,11 +145,7 @@ def test_mermaid_alias_never_shadows_a_real_id(): g = FakeGraph() g.add_edge("ns", "n0", "points_at", "zz top") # "n0" is a legal bare id - assert g.graph_export("mermaid", "ns") == ( - "graph LR\n" - ' n1["zz top"]\n' - " n0 -->|points_at| n1\n" - ) + assert g.graph_export("mermaid", "ns") == ('graph LR\n n1["zz top"]\n n0 -->|points_at| n1\n') def test_dot_escapes_backslashes(): @@ -189,16 +174,9 @@ def test_isolated_nodes_are_declared(): g.add_node("ns", "lonely") g.add_edge("ns", "a", "next", "b") - assert g.graph_export("mermaid", "ns") == ( - "graph LR\n" - " lonely\n" - " a -->|next| b\n" - ) + assert g.graph_export("mermaid", "ns") == ("graph LR\n lonely\n a -->|next| b\n") assert g.graph_export("dot", "ns") == ( - "digraph G {\n" - ' "lonely";\n' - ' "a" -> "b" [label="next"];\n' - "}\n" + 'digraph G {\n "lonely";\n "a" -> "b" [label="next"];\n}\n' ) @@ -208,8 +186,7 @@ def test_relation_filter_keeps_only_matching_edges(): g.add_edge("ns", "a", "owns", "c") assert g.graph_export("mermaid", "ns", roots=["a"], relation="next") == ( - "graph LR\n" - " a -->|next| b\n" + "graph LR\n a -->|next| b\n" ) diff --git a/tests/test_graph_properties.py b/tests/test_graph_properties.py index 18ab68f..3b404aa 100644 --- a/tests/test_graph_properties.py +++ b/tests/test_graph_properties.py @@ -165,4 +165,3 @@ def test_add_node_partial_update_preserves_unspecified_attributes(store): node_after_ntype = store.get_node("ns", "user") assert node_after_ntype["ntype"] == "Admin" assert node_after_ntype["props"] == {"age": Decimal("31"), "city": "NYC"} - diff --git a/tests/test_haystack.py b/tests/test_haystack.py index b92a5d7..35dc5c0 100644 --- a/tests/test_haystack.py +++ b/tests/test_haystack.py @@ -1,4 +1,5 @@ """Tests for the Haystack integration.""" + # ruff: noqa: E402 import pytest @@ -85,6 +86,7 @@ def test_write_documents_with_embedding(): assert dynavec_document.metadata == {"category": "account"} assert dynavec_document.vector == [0.1, 0.2, 0.3] + def test_write_documents_overwrite_existing(): client = _FakeClient() store = DynavecDocumentStore(client, namespace="kb") @@ -95,9 +97,7 @@ def test_write_documents_overwrite_existing(): meta={"category": "account"}, ) - client.get = lambda ids, **kwargs: [ - type("Result", (), {"id": "doc-1"})() - ] + client.get = lambda ids, **kwargs: [type("Result", (), {"id": "doc-1"})()] count = store.write_documents( [document], @@ -113,42 +113,44 @@ def test_write_documents_overwrite_existing(): assert documents[0].text == "Updated password instructions" assert kwargs == {"namespace": "kb"} + def test_filter_documents(): client = _FakeClient() store = DynavecDocumentStore(client, namespace="kb") - client.list_vectors = lambda **kwargs: iter([ - type( - "Result", - (), - { - "id": "doc-1", - "text": "Password reset", - "metadata": {"category": "account"}, - "vector": None, - }, - )(), - type( - "Result", - (), - { - "id": "doc-2", - "text": "Home loan details", - "metadata": {"category": "loan"}, - "vector": None, - }, - )(), - ]) - - documents = store.filter_documents( - filters={"category": "account"} + client.list_vectors = lambda **kwargs: iter( + [ + type( + "Result", + (), + { + "id": "doc-1", + "text": "Password reset", + "metadata": {"category": "account"}, + "vector": None, + }, + )(), + type( + "Result", + (), + { + "id": "doc-2", + "text": "Home loan details", + "metadata": {"category": "loan"}, + "vector": None, + }, + )(), + ] ) + documents = store.filter_documents(filters={"category": "account"}) + assert len(documents) == 1 assert documents[0].id == "doc-1" assert documents[0].content == "Password reset" assert documents[0].meta == {"category": "account"} + def test_retriever(): client = _FakeClient() @@ -169,6 +171,7 @@ def test_retriever(): assert result["documents"][0].id == "doc-1" assert result["documents"][0].content == "Password reset instructions" + def test_retriever_passes_top_k_and_filters(): client = _FakeClient() client.search_calls = [] @@ -199,6 +202,7 @@ def search(**kwargs): assert call["namespace"] == "support" assert call["filter"] == {"category": "support"} + def test_retriever_overrides_default_top_k(): client = _FakeClient() client.search_calls = [] @@ -218,6 +222,7 @@ def search(**kwargs): assert client.search_calls[0]["top_k"] == 3 + def test_retriever_with_filters(): client = _FakeClient() client.search_calls = [] @@ -270,6 +275,7 @@ def search(**kwargs): assert client.search_calls[0]["filter"] == {"category": "support"} + def test_document_store_serialization(): client = _FakeClient() store = DynavecDocumentStore(client, namespace="kb") @@ -280,6 +286,7 @@ def test_document_store_serialization(): assert data["init_parameters"]["namespace"] == "kb" assert "config" in data["init_parameters"] + def test_document_store_from_dict(monkeypatch): client = _FakeClient() store = DynavecDocumentStore(client, namespace="kb") @@ -295,32 +302,35 @@ def test_document_store_from_dict(monkeypatch): assert restored.namespace == "kb" + def test_filter_documents_with_haystack_filter(): client = _FakeClient() store = DynavecDocumentStore(client, namespace="kb") - client.list_vectors = lambda **kwargs: iter([ - type( - "Result", - (), - { - "id": "doc-1", - "text": "Password reset", - "metadata": {"category": "account"}, - "vector": None, - }, - )(), - type( - "Result", - (), - { - "id": "doc-2", - "text": "Home loan details", - "metadata": {"category": "loan"}, - "vector": None, - }, - )(), - ]) + client.list_vectors = lambda **kwargs: iter( + [ + type( + "Result", + (), + { + "id": "doc-1", + "text": "Password reset", + "metadata": {"category": "account"}, + "vector": None, + }, + )(), + type( + "Result", + (), + { + "id": "doc-2", + "text": "Home loan details", + "metadata": {"category": "loan"}, + "vector": None, + }, + )(), + ] + ) documents = store.filter_documents( filters={ diff --git a/tests/test_hot_tier.py b/tests/test_hot_tier.py index 73019b0..27163bc 100644 --- a/tests/test_hot_tier.py +++ b/tests/test_hot_tier.py @@ -69,7 +69,8 @@ def query(self, query_vector, top_k, filter=None, return_metadata=True, return_d def get_vectors(self, keys, return_metadata=False): return { k: {"vector": self._store[k][0], "metadata": self._store[k][1]} - for k in keys if k in self._store + for k in keys + if k in self._store } def list_pages(self, return_data=False, return_metadata=False, page_size=None): @@ -117,6 +118,7 @@ def _make(monkeypatch, **cfg_kw): # --------------------------------------------------------------------- matcher + def test_matches_supported_operators(): m = {"cat": "food", "year": 2026, "score": 0.4} assert matches(m, {"cat": "food"}) @@ -135,6 +137,7 @@ def test_matches_rejects_unsupported_operator(): # ---------------------------------------------------------------- integration + def test_disabled_by_default_uses_s3(monkeypatch): db = _make(monkeypatch) assert db._hot is None @@ -161,10 +164,10 @@ def test_warm_serves_from_ram_without_touching_s3_or_ddb(monkeypatch): hits = db.search("apple", top_k=2) assert len(hits) == 2 - assert hits[0].text is not None # text served from RAM + assert hits[0].text is not None # text served from RAM assert hits[0].score >= hits[1].score - assert db._vectors.query_calls == s3_before # no S3 Vectors query - assert db._docs.get_calls == ddb_before # no DynamoDB hydration + assert db._vectors.query_calls == s3_before # no S3 Vectors query + assert db._docs.get_calls == ddb_before # no DynamoDB hydration def test_write_through_keeps_warmed_namespace_current(monkeypatch): @@ -176,7 +179,7 @@ def test_write_through_keeps_warmed_namespace_current(monkeypatch): db.upsert([Document(id="2", text="apple tart")]) hits = db.search("apple tart", top_k=2) assert {h.id for h in hits} == {"1", "2"} - assert db._vectors.query_calls == s3_before # still served from RAM + assert db._vectors.query_calls == s3_before # still served from RAM def test_filter_on_hot_path(monkeypatch): @@ -191,7 +194,7 @@ def test_filter_on_hot_path(monkeypatch): s3_before = db._vectors.query_calls hits = db.search("apple", top_k=5, filter={"cat": "space"}) assert {h.id for h in hits} == {"2"} - assert db._vectors.query_calls == s3_before # filtered in RAM + assert db._vectors.query_calls == s3_before # filtered in RAM def test_unsupported_filter_falls_back_to_s3(monkeypatch): @@ -204,7 +207,7 @@ def test_unsupported_filter_falls_back_to_s3(monkeypatch): assert db._hot.search("default", [0.0] * 8, 5, {"n": {"$regex": "3"}}) is None s3_before = db._vectors.query_calls db.search("apple", top_k=5, filter={"n": {"$regex": "3"}}) - assert db._vectors.query_calls == s3_before + 1 # fell back to S3 + assert db._vectors.query_calls == s3_before + 1 # fell back to S3 def test_delete_removes_from_hot_tier(monkeypatch): @@ -215,7 +218,7 @@ def test_delete_removes_from_hot_tier(monkeypatch): s3_before = db._vectors.query_calls hits = db.search("apple", top_k=5) assert {h.id for h in hits} == {"2"} - assert db._vectors.query_calls == s3_before # still hot, id 1 gone + assert db._vectors.query_calls == s3_before # still hot, id 1 gone def test_capacity_cap_falls_back_to_s3(monkeypatch): @@ -225,7 +228,7 @@ def test_capacity_cap_falls_back_to_s3(monkeypatch): assert loaded == 0 assert not db._hot.is_authoritative("default") hits = db.search("doc", top_k=3) - assert db._vectors.query_calls >= 1 # fell back to S3 + assert db._vectors.query_calls >= 1 # fell back to S3 assert hits @@ -266,7 +269,9 @@ def test_hot_stats(monkeypatch): def test_hot_tier_lru_eviction(monkeypatch): db = _make(monkeypatch, hot_tier=True, hot_tier_max_vectors=3, hot_tier_eviction="lru") - db.upsert([Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a") + db.upsert( + [Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a" + ) db.upsert([Document(id="b1", text="banana 1")], namespace="ns_b") db.upsert([Document(id="c1", text="cherry 1")], namespace="ns_c") @@ -292,7 +297,9 @@ def test_hot_tier_lru_eviction(monkeypatch): def test_hot_tier_fifo_eviction(monkeypatch): db = _make(monkeypatch, hot_tier=True, hot_tier_max_vectors=3, hot_tier_eviction="fifo") - db.upsert([Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a") + db.upsert( + [Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a" + ) db.upsert([Document(id="b1", text="banana 1")], namespace="ns_b") db.upsert([Document(id="c1", text="cherry 1")], namespace="ns_c") @@ -315,7 +322,9 @@ def test_hot_tier_fifo_eviction(monkeypatch): def test_hot_tier_none_eviction(monkeypatch): db = _make(monkeypatch, hot_tier=True, hot_tier_max_vectors=3, hot_tier_eviction="none") - db.upsert([Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a") + db.upsert( + [Document(id="a1", text="apple 1"), Document(id="a2", text="apple 2")], namespace="ns_a" + ) db.upsert([Document(id="b1", text="banana 1")], namespace="ns_b") db.upsert([Document(id="c1", text="cherry 1")], namespace="ns_c") @@ -356,7 +365,9 @@ def test_hot_tier_insert_many_evicts_cold_namespace(monkeypatch): # Now upsert 2 new docs into ns_b. Total vectors for ns_b will be 3. # ns_a (1 vector) + ns_b (3 vectors) = 4 > 3. # ns_a should be evicted, and ns_b should remain authoritative with 3 resident vectors. - db.upsert([Document(id="b2", text="banana 2"), Document(id="b3", text="banana 3")], namespace="ns_b") + db.upsert( + [Document(id="b2", text="banana 2"), Document(id="b3", text="banana 3")], namespace="ns_b" + ) assert not db._hot.is_authoritative("ns_a") assert db._hot.is_authoritative("ns_b") assert db.hot_stats()["resident_vectors"] == 3 diff --git a/tests/test_huggingface_embedder.py b/tests/test_huggingface_embedder.py index ac2f35d..49f68d6 100644 --- a/tests/test_huggingface_embedder.py +++ b/tests/test_huggingface_embedder.py @@ -1,6 +1,7 @@ """Unit tests for HFInferenceEmbedder.""" from unittest.mock import MagicMock, patch + import pytest from dynavec.embeddings import HFInferenceEmbedder @@ -44,4 +45,4 @@ def test_embed_documents_batching(mock_post): def test_empty_input(): emb = HFInferenceEmbedder(api_key="fake_token") - assert emb.embed_documents([]) == [] \ No newline at end of file + assert emb.embed_documents([]) == [] diff --git a/tests/test_list_vectors.py b/tests/test_list_vectors.py index 8ffdea4..9b2d7e2 100644 --- a/tests/test_list_vectors.py +++ b/tests/test_list_vectors.py @@ -258,9 +258,7 @@ def test_list_vectors_hydrates_text_and_metadata(): assert result.distance is None assert result.vector is None # hydrating means we do not pay for metadata on the S3 Vectors side - assert db._vectors.calls == [ - dict(return_data=False, return_metadata=False, page_size=None) - ] + assert db._vectors.calls == [dict(return_data=False, return_metadata=False, page_size=None)] def test_list_vectors_without_hydration_uses_s3_metadata(): @@ -278,9 +276,7 @@ def test_list_vectors_without_hydration_uses_s3_metadata(): assert docs.calls == [] # DynamoDB is never touched assert result.text == "mirrored" assert result.metadata == {"cat": "food"} # internal tags stripped - assert db._vectors.calls == [ - dict(return_data=False, return_metadata=True, page_size=None) - ] + assert db._vectors.calls == [dict(return_data=False, return_metadata=True, page_size=None)] def test_list_vectors_without_hydration_tolerates_missing_metadata(): diff --git a/tests/test_markdown_source.py b/tests/test_markdown_source.py index 304febf..7c1c77c 100644 --- a/tests/test_markdown_source.py +++ b/tests/test_markdown_source.py @@ -115,6 +115,7 @@ def test_root_must_be_an_existing_directory(tmp_path): def test_empty_directory_yields_no_records(tmp_path): assert list(MarkdownSource(tmp_path)) == [] + def test_front_matter_dates_normalize_to_iso_strings(tmp_path): (tmp_path / "notes.md").write_text( "---\ntitle: Research Notes\ndate: 2026-09-24\n---\nBody\n", @@ -188,7 +189,6 @@ def test_front_matter_dates_survive_dynamodb_serialization(tmp_path): assert serialized["M"]["date"] == {"S": "2026-09-24"} - def test_markdown_source_flows_through_ingest(tmp_path): (tmp_path / "guide.md").write_text("---\ntopic: aws\n---\nabcdefghij", encoding="utf-8") db = Mock() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 564f0b4..8957a2d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -111,7 +111,9 @@ def test_client_from_env_embedder_resolution(monkeypatch): monkeypatch.setattr(gemini_mod, "GeminiEmbedder", lambda **kw: MagicMock(dimension=768)) monkeypatch.setattr(voyage_mod, "VoyageEmbedder", lambda **kw: MagicMock(dimension=1024)) monkeypatch.setattr(mistral_mod, "MistralEmbedder", lambda **kw: MagicMock(dimension=1024)) - monkeypatch.setattr(st_mod, "SentenceTransformerEmbedder", lambda **kw: MagicMock(dimension=384)) + monkeypatch.setattr( + st_mod, "SentenceTransformerEmbedder", lambda **kw: MagicMock(dimension=384) + ) base_env = { "DYNAVEC_BUCKET": "b", @@ -175,7 +177,7 @@ def test_mcp_server_search_tool(fake_mcp): rescore="dot", rerank="mmr", ) - assert "[1] id: doc-1 (score: 0.9500 | metadata: {\"topic\": \"greeting\"})" in result_text + assert '[1] id: doc-1 (score: 0.9500 | metadata: {"topic": "greeting"})' in result_text assert "Hello world" in result_text assert "[2] id: doc-2 (score: 0.8200)" in result_text @@ -197,7 +199,9 @@ def test_mcp_server_search_tool_empty_and_error(fake_mcp): def test_mcp_server_graph_search_tool(fake_mcp): mock_db = MagicMock() mock_db.graph_search.return_value = [ - SearchResult(id="doc-graph", score=0.91, text="Graph entity content", metadata={"entity": "acme"}), + SearchResult( + id="doc-graph", score=0.91, text="Graph entity content", metadata={"entity": "acme"} + ), ] mcp = create_mcp_server(mock_db) @@ -225,7 +229,163 @@ def test_mcp_server_graph_search_tool(fake_mcp): def test_cli_mcp_subcommand_parser(): parser = _parser() - args = parser.parse_args(["mcp", "--transport", "sse", "--port", "9000"]) + + args = parser.parse_args( + [ + "mcp", + "--transport", + "sse", + "--port", + "9000", + "--enable-write", + ] + ) + assert args.command == "mcp" assert args.transport == "sse" assert args.port == 9000 + assert args.enable_write is True + + # Verify write access is disabled by default. + default_args = parser.parse_args(["mcp"]) + + assert default_args.enable_write is False + + +def test_write_tools_hidden_by_default(fake_mcp): + mock_db = MagicMock() + + mcp = create_mcp_server(db=mock_db) + + assert "dynavec_search" in mcp.tools + assert "dynavec_graph_search" in mcp.tools + + assert "dynavec_remember" not in mcp.tools + assert "dynavec_get" not in mcp.tools + assert "dynavec_forget" not in mcp.tools + + +def test_write_tools_registered_with_enable_write(fake_mcp): + mock_db = MagicMock() + + mcp = create_mcp_server( + db=mock_db, + enable_write=True, + ) + + assert "dynavec_search" in mcp.tools + assert "dynavec_graph_search" in mcp.tools + + assert "dynavec_remember" in mcp.tools + assert "dynavec_get" in mcp.tools + assert "dynavec_forget" in mcp.tools + + +def test_remember_calls_upsert_with_metadata_and_ttl(fake_mcp): + mock_db = MagicMock() + + mcp = create_mcp_server( + db=mock_db, + enable_write=True, + ) + + remember_fn = mcp.tools["dynavec_remember"].fn + + result = remember_fn( + text="Remember this important information", + doc_id="memory-001", + namespace="test-namespace", + metadata_json='{"category": "notes", "source": "test"}', + ttl_seconds=3600, + ) + + mock_db.upsert.assert_called_once() + + args, kwargs = mock_db.upsert.call_args + documents = args[0] + + assert len(documents) == 1 + + document = documents[0] + assert document.id == "memory-001" + assert document.text == "Remember this important information" + assert document.metadata == { + "category": "notes", + "source": "test", + } + assert document.ttl_seconds == 3600 + + assert kwargs["namespace"] == "test-namespace" + assert kwargs["ttl_seconds"] == 3600 + + assert "Successfully remembered" in result + + +def test_remember_rejects_bad_metadata_json(fake_mcp): + mock_db = MagicMock() + + mcp = create_mcp_server( + db=mock_db, + enable_write=True, + ) + + remember_fn = mcp.tools["dynavec_remember"].fn + + result = remember_fn( + text="Test memory", + metadata_json='{"category":', + ) + + assert "Error parsing metadata_json" in result + mock_db.upsert.assert_not_called() + + +def test_get_and_forget_forward_ids_and_namespace(fake_mcp): + mock_db = MagicMock() + + mock_db.get.return_value = [ + SearchResult( + id="doc-1", + score=1.0, + text="First document", + metadata={"type": "test"}, + ), + SearchResult( + id="doc-2", + score=0.9, + text="Second document", + metadata={"type": "test"}, + ), + ] + + mcp = create_mcp_server( + db=mock_db, + enable_write=True, + ) + + get_fn = mcp.tools["dynavec_get"].fn + forget_fn = mcp.tools["dynavec_forget"].fn + + ids = ["doc-1", "doc-2"] + namespace = "test-namespace" + + get_result = get_fn( + ids=ids, + namespace=namespace, + ) + + mock_db.get.assert_called_once_with(ids, namespace) + + assert "doc-1" in get_result + assert "First document" in get_result + assert "doc-2" in get_result + assert "Second document" in get_result + + forget_result = forget_fn( + ids=ids, + namespace=namespace, + ) + + mock_db.delete.assert_called_once_with(ids, namespace) + + assert "Successfully deleted 2 document(s)" in forget_result diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 82b46ca..79db0b0 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -166,14 +166,12 @@ def test_split_text_mirror_optional(): # None and empty filter (None, "default", {NS_METADATA_KEY: "default"}), ({}, "production", {NS_METADATA_KEY: "production"}), - # Bare equality ( {"genre": "scifi"}, "ns1", {"$and": [{"genre": "scifi"}, {NS_METADATA_KEY: "ns1"}]}, ), - # $eq operator ( {"status": {"$eq": "active"}}, @@ -185,7 +183,6 @@ def test_split_text_mirror_optional(): ] }, ), - # $ne operator ( {"archived": {"$ne": True}}, @@ -197,7 +194,6 @@ def test_split_text_mirror_optional(): ] }, ), - # Numeric comparison operators ($gte, $gt, $lte, $lt) ( {"rating": {"$gte": 4.5}}, @@ -229,7 +225,6 @@ def test_split_text_mirror_optional(): ] }, ), - # $in and $nin operators ( {"tag": {"$in": ["python", "aws", "vector"]}}, @@ -251,7 +246,6 @@ def test_split_text_mirror_optional(): ] }, ), - # $or operator combinations ( {"$or": [{"category": "news"}, {"category": "tech"}]}, @@ -295,11 +289,7 @@ def test_split_text_mirror_optional(): "$and": [ { "$or": [ - { - "category": { - "$in": ["electronics", "computers"] - } - }, + {"category": {"$in": ["electronics", "computers"]}}, {"discount": {"$gte": 0.2}}, ] }, @@ -307,7 +297,6 @@ def test_split_text_mirror_optional(): ] }, ), - # Top-level $and list extension (should not double wrap) ( {"$and": [{"a": 1}]}, @@ -330,7 +319,6 @@ def test_split_text_mirror_optional(): ] }, ), - # Complex nested $and + $or + $in + $gte combinations ( { diff --git a/tests/test_metric_properties.py b/tests/test_metric_properties.py index a5c26dc..9257573 100644 --- a/tests/test_metric_properties.py +++ b/tests/test_metric_properties.py @@ -197,7 +197,9 @@ def test_euclidean_and_manhattan_distance_monotonicity(vec_pair, scale_a, delta) for metric in ("euclidean", "manhattan"): scores = score(q, mat, metric) # Closer candidate (near) must have strictly higher similarity score than farther candidate - assert scores[0] > scores[1], f"Metric {metric} failed monotonicity: {scores[0]} <= {scores[1]}" + assert scores[0] > scores[1], ( + f"Metric {metric} failed monotonicity: {scores[0]} <= {scores[1]}" + ) @given( @@ -297,7 +299,9 @@ def test_normalize_scores_bounds_and_order_preservation(scores): scores=arrays( dtype=np.float32, shape=st.integers(min_value=2, max_value=20), - elements=st.floats(min_value=-100.0, max_value=100.0, allow_nan=False, allow_infinity=False), + elements=st.floats( + min_value=-100.0, max_value=100.0, allow_nan=False, allow_infinity=False + ), ), scale=st.floats(min_value=0.1, max_value=50.0, allow_nan=False, allow_infinity=False), shift=st.floats(min_value=-50.0, max_value=50.0, allow_nan=False, allow_infinity=False), diff --git a/tests/test_namespace.py b/tests/test_namespace.py index 12cfece..e3a1434 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -21,11 +21,15 @@ def embed_documents(self, texts): v[i % self.dimension] += (ord(ch) % 17) / 17.0 out.append(v) return out + + def _cosine_distance(a, b): dot = sum(x * y for x, y in zip(a, b)) na = math.sqrt(sum(x * x for x in a)) or 1e-12 nb = math.sqrt(sum(y * y for y in b)) or 1e-12 return 1.0 - dot / (na * nb) + + class FakeS3(client_mod.S3VectorsStore): def __init__(self, config, boto_session=None): self.config = config @@ -70,12 +74,17 @@ def query_pages( yield hits[i : i + chunk_size] def get_vectors(self, keys, return_metadata=False): - return {k: {"vector": self._store[k][0], "metadata": self._store[k][1]} for k in keys if k in self._store} + return { + k: {"vector": self._store[k][0], "metadata": self._store[k][1]} + for k in keys + if k in self._store + } def delete_vectors(self, keys): for k in keys: self._store.pop(k, None) + class FakeDDB(client_mod.DynamoDBStore): def __init__(self, config, boto_session=None): self.config = config @@ -91,6 +100,8 @@ def get_many(self, namespace, ids): def delete_many(self, namespace, ids): for i in ids: self._store.pop((namespace, i), None) + + class FakeGraph(client_mod.GraphStore): def __init__(self, config, boto_session=None): self.config = config @@ -166,6 +177,8 @@ def get_docs(self, ns, entity_ids): seen.add(d) out.append(d) return out + + @pytest.fixture def db(monkeypatch): monkeypatch.setattr(client_mod, "S3VectorsStore", FakeS3) @@ -174,11 +187,15 @@ def db(monkeypatch): cfg = DynavecConfig(vector_bucket="b", index="i", table="t", dimension=8) return Dynavec(cfg, embedder=HashEmbedder(8)) + + def test_search_many_forwards_multiple_queries_with_namespace_isolation(db): kb = db.namespace("kb") other = db.namespace("other") kb.upsert([Document(id="1", text="apple"), Document(id="2", text="rocket")]) - other.upsert([Document(id="other-apple", text="apple"), Document(id="other-rocket", text="rocket")]) + other.upsert( + [Document(id="other-apple", text="apple"), Document(id="other-rocket", text="rocket")] + ) results = kb.search_many(["apple", "rocket"], top_k=1) @@ -207,6 +224,7 @@ def test_search_many_empty_batch(db): assert kb.search_many([]) == [] + @pytest.mark.parametrize("explain", [False, True]) def test_search_many_dynamic_explain_preserves_namespace_and_order(db, explain): kb = db.namespace("kb") diff --git a/tests/test_nodes.py b/tests/test_nodes.py index f33aa3a..d9b2dde 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -397,9 +397,7 @@ def flaky_fn(inputs: dict[str, Any]) -> dict[str, Any]: return {"result": inputs["x"] * 10} policy = RetryPolicy(max_attempts=4, base_delay=0.001, max_delay=0.01) - flaky_node = CodeNode( - code_fn=flaky_fn, node_id="flaky_node", retry_policy=policy - ) + flaky_node = CodeNode(code_fn=flaky_fn, node_id="flaky_node", retry_policy=policy) graph = SimpleGraph() graph.add_node(flaky_node) @@ -420,9 +418,7 @@ def always_failing_fn(inputs: dict[str, Any]) -> dict[str, Any]: raise RuntimeError("Hard database failure") policy = RetryPolicy(max_attempts=3, base_delay=0.001, max_delay=0.01) - failing_node = CodeNode( - code_fn=always_failing_fn, node_id="fail_node", retry_policy=policy - ) + failing_node = CodeNode(code_fn=always_failing_fn, node_id="fail_node", retry_policy=policy) graph = SimpleGraph() graph.add_node(failing_node) diff --git a/tests/test_openai_embeddings.py b/tests/test_openai_embeddings.py index a3fc8dd..ea5ee0f 100644 --- a/tests/test_openai_embeddings.py +++ b/tests/test_openai_embeddings.py @@ -1,4 +1,5 @@ """Tests for the OpenAI embedder retry behavior.""" + from types import SimpleNamespace import pytest @@ -32,9 +33,7 @@ def __init__(self, error: Exception): @pytest.mark.parametrize("dimensions", [None, 2]) -def test_openai_embedder_retries_rate_limit_and_respects_retry_after( - monkeypatch, dimensions -): +def test_openai_embedder_retries_rate_limit_and_respects_retry_after(monkeypatch, dimensions): class RateLimitError(Exception): status_code = 429 @@ -64,6 +63,7 @@ def __init__(self): expected_request["dimensions"] = dimensions assert fake_client.embeddings.requests == [expected_request, expected_request] + def test_openai_embedder_retries_server_error(monkeypatch): class ServerError(Exception): status_code = 500 @@ -81,6 +81,7 @@ class ServerError(Exception): assert fake_client.embeddings.calls == 2 assert len(delays) == 1 + def test_openai_embedder_does_not_retry_client_error(monkeypatch): class ClientError(Exception): status_code = 400 @@ -99,6 +100,7 @@ class ClientError(Exception): assert fake_client.embeddings.calls == 1 assert delays == [] + def _make_embedder(error): fake_client = _FakeClient(error) diff --git a/tests/test_provisioning.py b/tests/test_provisioning.py index f5c908a..397cc7f 100644 --- a/tests/test_provisioning.py +++ b/tests/test_provisioning.py @@ -25,16 +25,10 @@ def test_ensure_table_creates_expected_key_schema(): ensure_table(config, session) - table = session.client("dynamodb").describe_table( - TableName="test-table" - )["Table"] + table = session.client("dynamodb").describe_table(TableName="test-table")["Table"] - assert table["KeySchema"] == [ - {"AttributeName": "pk", "KeyType": "HASH"} - ] - assert table["AttributeDefinitions"] == [ - {"AttributeName": "pk", "AttributeType": "S"} - ] + assert table["KeySchema"] == [{"AttributeName": "pk", "KeyType": "HASH"}] + assert table["AttributeDefinitions"] == [{"AttributeName": "pk", "AttributeType": "S"}] def test_ensure_index_marks_text_mirror_non_filterable(): @@ -60,9 +54,7 @@ def test_ensure_index_marks_text_mirror_non_filterable(): dataType="float32", dimension=4, distanceMetric="cosine", - metadataConfiguration={ - "nonFilterableMetadataKeys": [TEXT_METADATA_KEY] - }, + metadataConfiguration={"nonFilterableMetadataKeys": [TEXT_METADATA_KEY]}, ) diff --git a/tests/test_quantization.py b/tests/test_quantization.py index a7946a1..e460cc4 100644 --- a/tests/test_quantization.py +++ b/tests/test_quantization.py @@ -196,6 +196,7 @@ def test_load_corrupted_file_raises(tmp_path): with pytest.raises(ValueError): ProductQuantizer.load(corrupt) + def test_opq_rotation_fit(): rng = np.random.default_rng(42) diff --git a/tests/test_retrievers.py b/tests/test_retrievers.py index 8c30af3..553206e 100644 --- a/tests/test_retrievers.py +++ b/tests/test_retrievers.py @@ -365,7 +365,9 @@ def test_hyde_requires_embedder(monkeypatch): monkeypatch.setattr(client_mod, "S3VectorsStore", FakeS3) monkeypatch.setattr(client_mod, "DynamoDBStore", FakeDDB) monkeypatch.setattr(client_mod, "GraphStore", FakeGraph) - bare = Dynavec(DynavecConfig(vector_bucket="b", index="i", table="t", dimension=4, region="us-east-1")) + bare = Dynavec( + DynavecConfig(vector_bucket="b", index="i", table="t", dimension=4, region="us-east-1") + ) with pytest.raises(ConfigurationError): HyDERetriever(bare, lambda q: "x") diff --git a/tests/test_s3vectors.py b/tests/test_s3vectors.py index 32e1797..a320e1a 100644 --- a/tests/test_s3vectors.py +++ b/tests/test_s3vectors.py @@ -8,38 +8,37 @@ class FakeS3VectorsClient: - def __init__(self): - self.calls = [] + def __init__(self): + self.calls = [] - def get_vectors(self, **kwargs): - self.calls.append(kwargs) - return { - "vectors": [ - {"key": key, "data": {"float32": [1.0, 2.0]}} - for key in kwargs["keys"] - ] - } + def get_vectors(self, **kwargs): + self.calls.append(kwargs) + return { + "vectors": [{"key": key, "data": {"float32": [1.0, 2.0]}} for key in kwargs["keys"]] + } def test_get_vectors_batches_keys_and_merges_results(): - fake = FakeS3VectorsClient() - store = S3VectorsStore.__new__(S3VectorsStore) - store._config = DynavecConfig(vector_bucket="bucket", index="index", table="table", dimension=2) - store._client = fake + fake = FakeS3VectorsClient() + store = S3VectorsStore.__new__(S3VectorsStore) + store._config = DynavecConfig(vector_bucket="bucket", index="index", table="table", dimension=2) + store._client = fake - keys = [f"key-{i}" for i in range(250)] - result = store.get_vectors(keys) + keys = [f"key-{i}" for i in range(250)] + result = store.get_vectors(keys) - assert [len(call["keys"]) for call in fake.calls] == [100, 100, 50] - assert all(call["returnData"] is True for call in fake.calls) - assert len(result) == 250 - assert set(result) == set(keys) + assert [len(call["keys"]) for call in fake.calls] == [100, 100, 50] + assert all(call["returnData"] is True for call in fake.calls) + assert len(result) == 250 + assert set(result) == set(keys) def _config(**kwargs): - defaults = dict(vector_bucket="test-bucket", index="test-index", table="test-table", dimension=4) - defaults.update(kwargs) - return DynavecConfig(**defaults) + defaults = dict( + vector_bucket="test-bucket", index="test-index", table="test-table", dimension=4 + ) + defaults.update(kwargs) + return DynavecConfig(**defaults) def _store_with_pages(pages, config=None): @@ -56,17 +55,18 @@ def _store_with_pages(pages, config=None): def test_query_single_page(): - page = [{"key": f"k{i}", "distance": 0.01 * i} for i in range(50)] - store, paginator = _store_with_pages([{"vectors": page}]) + page = [{"key": f"k{i}", "distance": 0.01 * i} for i in range(50)] + store, paginator = _store_with_pages([{"vectors": page}]) - results = store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=50) + results = store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=50) + + assert len(results) == 50 + assert results[0]["key"] == "k0" + assert results[-1]["key"] == "k49" + store._client.get_paginator.assert_called_once_with("query_vectors") + assert paginator.paginate.call_args.kwargs["PaginationConfig"] == {"MaxItems": 50} + assert paginator.paginate.call_args.kwargs["topK"] == 50 - assert len(results) == 50 - assert results[0]["key"] == "k0" - assert results[-1]["key"] == "k49" - store._client.get_paginator.assert_called_once_with("query_vectors") - assert paginator.paginate.call_args.kwargs["PaginationConfig"] == {"MaxItems": 50} - assert paginator.paginate.call_args.kwargs["topK"] == 50 def test_query_multi_page_pagination(): pages = [ @@ -98,47 +98,46 @@ def test_query_multi_page_pagination(): "k249", ] + def test_query_fewer_results_than_top_k(): - store, _ = _store_with_pages([{"vectors": [{"key": "k0"}, {"key": "k1"}]}]) + store, _ = _store_with_pages([{"vectors": [{"key": "k0"}, {"key": "k1"}]}]) - results = store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=50) + results = store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=50) - assert len(results) == 2 + assert len(results) == 2 def test_query_zero_or_negative_top_k(): - store, _ = _store_with_pages([{"vectors": [{"key": "k0"}]}]) + store, _ = _store_with_pages([{"vectors": [{"key": "k0"}]}]) - assert store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=0) == [] - assert store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=-5) == [] - store._client.get_paginator.assert_not_called() + assert store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=0) == [] + assert store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=-5) == [] + store._client.get_paginator.assert_not_called() def test_query_exceeding_service_limit(): - store, _ = _store_with_pages([]) + store, _ = _store_with_pages([]) - with pytest.raises(ValueError, match="exceeds Amazon S3 Vectors maximum limit"): - store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=_MAX_TOP_K + 1) + with pytest.raises(ValueError, match="exceeds Amazon S3 Vectors maximum limit"): + store.query(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=_MAX_TOP_K + 1) def test_query_pages_invalid_page_size(): - store, _ = _store_with_pages([]) + store, _ = _store_with_pages([]) - with pytest.raises(ValueError, match="page_size must be a positive integer"): - list(store.query_pages(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=10, page_size=0)) + with pytest.raises(ValueError, match="page_size must be a positive integer"): + list(store.query_pages(query_vector=[0.1, 0.2, 0.3, 0.4], top_k=10, page_size=0)) def test_config_rejects_non_positive_top_k_page_size(): - with pytest.raises(ValueError, match="top_k_page_size must be a positive integer"): - _config(top_k_page_size=0) + with pytest.raises(ValueError, match="top_k_page_size must be a positive integer"): + _config(top_k_page_size=0) + def test_put_vectors_parallelization(): store, _ = _store_with_pages([]) - vectors = [ - (f"key_{i}", [0.1] * 128, {"tag": "test"}) - for i in range(1500) - ] + vectors = [(f"key_{i}", [0.1] * 128, {"tag": "test"}) for i in range(1500)] def mock_put_batch(payload): time.sleep(0.1) @@ -156,10 +155,7 @@ def mock_put_batch(payload): def test_put_vectors_error_propagation(): store, _ = _store_with_pages([]) - vectors = [ - (f"key_{i}", [0.1] * 128, {"tag": "test"}) - for i in range(1000) - ] + vectors = [(f"key_{i}", [0.1] * 128, {"tag": "test"}) for i in range(1000)] def mock_put_batch_with_error(payload): if payload[0]["key"] == "key_0": @@ -242,9 +238,7 @@ def test_query_pages_raw_chunks(): def test_query_pages_configurable_page_size(): - store, _ = _store_with_pages( - [{"vectors": [{"key": f"k{i}"} for i in range(25)]}] - ) + store, _ = _store_with_pages([{"vectors": [{"key": f"k{i}"} for i in range(25)]}]) result_pages = list( store.query_pages( @@ -324,4 +318,4 @@ def test_query_pages_does_not_rate_limit_after_paginator_exhaustion(): ) assert [len(page) for page in result_pages] == [1] - assert limiter.acquire.call_count == 1 \ No newline at end of file + assert limiter.acquire.call_count == 1 diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 8d52c9a..8880a8e 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -78,7 +78,7 @@ def test_aggregate_cache_hit_rate_and_errors(): for e in evs: e.ts = now - 1 agg = aggregate(evs, window_seconds=3600, now=now) - assert agg["cache_total"] == 3 # None excluded + assert agg["cache_total"] == 3 # None excluded assert agg["cache_hits"] == 2 assert agg["cache_hit_rate"] == round(100 * 2 / 3, 1) assert agg["error_rate"] == round(100 * 1 / 5, 2) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index bfac1ce..739924d 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -120,22 +120,22 @@ def test_python_code_tool() -> None: def test_mcp_adapter() -> None: """Test adapting an MCP session to dynaflow tools.""" mock_session = MagicMock() - + # Mock list_tools class MockTool: name = "mcp_math" description = "MCP math" inputSchema = {"type": "object", "properties": {"a": {"type": "integer"}}} - + mock_listing = MagicMock() mock_listing.tools = [MockTool()] mock_session.list_tools.return_value = mock_listing - + # Mock call_tool class MockResult: content = [{"text": "success: 42"}] isError = False - + mock_session.call_tool.return_value = MockResult() tools = mcp_tools_from_session(mock_session, tags=["mcp"]) @@ -143,7 +143,7 @@ class MockResult: assert tools[0].name == "mcp_math" assert tools[0].description == "MCP math" assert "mcp" in tools[0].tags - + # Execute res = tools[0].execute({"a": 42}) assert res == "success: 42" diff --git a/tests/test_utils_transforms.py b/tests/test_utils_transforms.py index b6931a1..567e664 100644 --- a/tests/test_utils_transforms.py +++ b/tests/test_utils_transforms.py @@ -76,6 +76,7 @@ def test_as_pipeline_coercions(): multi = as_pipeline([lambda c: c, lambda c: c]) assert len(multi) == 2 + def test_retry_uses_retry_delay(monkeypatch): calls = {"n": 0} delays = []