diff --git a/README.md b/README.md index cf11669..38df226 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ # Endee - Java Vector Database Client -Endee is a Java client for the Endee vector database, designed for maximum speed and efficiency. This package provides type-safe operations, modern Java features, and optimized code for rapid Approximate Nearest Neighbor (ANN) searches on vector data. +Java client for the [Endee](https://endee.io) vector database. Supports multi-field collections (dense, sparse, multi-vector), filtered search, client-side RRF reranking, backups, and admin operations. ## Key Features -- **Type Safe**: Full compile-time type checking with builder patterns -- **Fast ANN Searches**: Efficient similarity searches on vector data -- **Multiple Distance Metrics**: Cosine, L2, and inner product -- **Hybrid Indexes**: Dense + sparse (BM25 or default) vector search -- **Metadata & Filters**: Attach and query metadata with flexible filter operators -- **Typed Exceptions**: Specific exception types per HTTP error code -- **High Performance**: HTTP/2, MessagePack serialization, and DEFLATE compression -- **Modern Java**: Java 17+, uses modern APIs +- **Multi-field collections** — combine dense, sparse, and multi-vector fields in one collection +- **Client-side RRF reranking** — fuse results from multiple fields with weighted Reciprocal Rank Fusion +- **Flexible filters** — `$eq`, `$in`, `$range`, `$gt`, `$gte`, `$lt`, `$lte` +- **High performance** — HTTP/2, MessagePack wire format, DEFLATE-compressed metadata +- **Typed exceptions** — specific exception types for each HTTP error code +- **Admin & backup** — database CRUD, token management, backup/restore/download/upload +- **Java 17+** — modern APIs, builder patterns, compile-time type safety ## Requirements @@ -26,353 +25,526 @@ Endee is a Java client for the Endee vector database, designed for maximum speed io.endee endee-java-client - 1.0.0 + 2.0.0 ``` ### Gradle ```groovy -implementation 'io.endee:endee-java-client:1.0.0' +implementation 'io.endee:endee-java-client:2.0.0' ``` -## Quick Start +--- -### Initialize the Client +## Initialize the Client ```java import io.endee.client.Endee; -import io.endee.client.Index; +import io.endee.client.Collection; import io.endee.client.types.*; -// Local server (defaults to http://127.0.0.1:8080/api/v1) +// Local server (defaults to http://127.0.0.1:8080/api/v2) Endee client = new Endee(); // With an auth token -Endee client = new Endee("account:password"); +Endee client = new Endee("db_name:secret"); -// With a region (connects to https://{region}.endee.io/api/v1) -Endee client = new Endee("account:password:us-east-1"); +// With a region (connects to https://{region}.endee.io/api/v2) +Endee client = new Endee("db_name:secret:us-east-1"); // Custom base URL -client.setBaseUrl("http://0.0.0.0:8081/api/v1"); +client.setBaseUrl("http://0.0.0.0:8081/api/v2"); ``` --- -## Index Management - -### Create a Dense Index - -```java -CreateIndexOptions options = CreateIndexOptions.builder("my_vectors", 384) - .spaceType(SpaceType.COSINE) - .precision(Precision.INT8) - .m(16) - .efCon(128) - .build(); - -client.createIndex(options); -``` - -**Parameters:** +## Collection Management -| Parameter | Description | Default | Constraints | -|-------------|-----------------------------------------------------------------------|----------|----------------------| -| `name` | Unique index name (alphanumeric + underscore) | required | max 48 chars | -| `dimension` | Vector dimensionality (must match your embedding model) | required | 2 – 8,000 | -| `spaceType` | Distance metric — `COSINE`, `L2`, `IP` | `COSINE` | — | -| `m` | HNSW graph connectivity — higher = better recall, more memory | `16` | > 0 | -| `efCon` | HNSW construction quality — higher = better index, slower build | `128` | > 0 | -| `precision` | Quantization level | `INT8` | see Precision section | +### Create a Collection -### Create a Hybrid Index - -Hybrid indexes support both dense and sparse vectors. Set `sparseModel` to enable sparse search: +Collections hold one or more typed fields. Each field is either `vector` (dense), `sparse`, or `multi_vector`. ```java -// Standard sparse search -CreateIndexOptions options = CreateIndexOptions.builder("hybrid_index", 384) - .spaceType(SpaceType.COSINE) - .precision(Precision.INT8) - .sparseModel("default") // or "endee_bm25" for BM25 scoring - .build(); - -client.createIndex(options); +// Dense + sparse hybrid collection +Map result = client.createCollection("my_docs", List.of( + Map.of( + "name", "embedding", + "type", "vector", + "params", Map.of( + "dimension", 768, + "space_type", "cosine", // "cosine", "l2", or "ip" + "precision", "int8", // "binary", "int8", "int8e", "int16", "float16", "float32" + "M", 16, // HNSW connectivity + "ef_con", 128 // HNSW construction quality + ) + ), + Map.of( + "name", "keywords", + "type", "sparse", + "sparse_model", "default" // "default" or "endee_bm25" + ) +)); +// Output: {message=collection created} ``` -**`sparseModel` values:** +**Field types:** -| Value | Description | -|----------------|-------------------------------------------------| -| `"default"` | Standard sparse search without server-side IDF | -| `"endee_bm25"` | BM25 scoring with server-side IDF | -| `null` | Dense-only index (omit `sparseModel` entirely) | +| Type | Description | Query type | +|------|-------------|------------| +| `vector` | Dense embedding | `double[]` | +| `sparse` | Sparse term weights | `SparseData(int[] indices, double[] values)` | +| `multi_vector` | Multiple dense vectors per object | `double[][]` | -### List, Get, and Delete Indexes +### List, Get, Delete ```java -// List all indexes (returns raw JSON string) -String indexes = client.listIndexes(); +// List all collections +List> collections = client.listCollections(); +// Output: [{name=my_docs, fields=[...], count=1000}, ...] -// Get a reference to an existing index -Index index = client.getIndex("my_vectors"); +// Get a collection reference (for upsert, search, etc.) +Collection collection = client.getCollection("my_docs"); -// Delete an index (irreversible) -client.deleteIndex("my_vectors"); +// Describe a collection (refreshes metadata from server) +Map desc = collection.describe(); +// Output: {name=my_docs, fields=[{name=embedding, type=vector, params={...}}, ...], count=1000} + +// Delete a collection (irreversible) +client.deleteCollection("my_docs"); +// Output: {message=collection deleted} ``` --- -## Upserting Vectors +## Upserting Objects -### Dense Vectors +Use the `ObjectItem` builder to construct objects with any combination of field types. ```java -Index index = client.getIndex("my_index"); - -List vectors = List.of( - VectorItem.builder("vec1", new double[] {0.1, 0.2, 0.3 /* ... */}) - .meta(Map.of("title", "First document", "score", 95)) - .filter(Map.of("category", "tech", "group", 1)) +Collection collection = client.getCollection("my_docs"); + +List objects = List.of( + ObjectItem.builder("doc1") + .vector("embedding", new double[] {0.1, 0.2, 0.3, /* ... 768 dims */}) + .sparse("keywords", new SparseData( + new int[] {10, 500, 12000}, // term positions + new double[] {0.8, 0.5, 0.3} // term weights + )) + .meta(Map.of("title", "First Document", "author", "Alice")) + .filter(Map.of("category", "tech", "year", 2024)) .build(), - VectorItem.builder("vec2", new double[] {0.4, 0.5, 0.6 /* ... */}) - .meta(Map.of("title", "Second document", "score", 80)) - .filter(Map.of("category", "science", "group", 2)) - .build() -); - -index.upsert(vectors); -``` - -### Hybrid Vectors - -For hybrid indexes, every upserted vector must supply both sparse fields: - -```java -List vectors = List.of( - VectorItem.builder("doc1", new double[] {0.1, 0.2 /* ... */}) - .sparseIndices(new int[] {10, 50, 200}) // non-zero term positions - .sparseValues(new double[] {0.8, 0.5, 0.3}) // weight for each position - .meta(Map.of("title", "Document 1")) - .filter(Map.of("category", "tech")) + ObjectItem.builder("doc2") + .vector("embedding", new double[] {0.4, 0.5, 0.6, /* ... */}) + .sparse("keywords", new SparseData( + new int[] {25, 9000, 20000}, + new double[] {0.3, 0.7, 0.1} + )) + .meta(Map.of("title", "Second Document", "author", "Bob")) + .filter(Map.of("category", "science", "year", 2023)) .build() ); -index.upsert(vectors); +Map result = collection.upsert(objects); +// Output: {message=2 objects upserted} ``` -**`VectorItem` fields:** +**ObjectItem fields:** -| Field | Required | Description | -|-----------------|---------------|--------------------------------------------------------------| -| `id` | Yes | Unique non-empty string identifier | -| `vector` | Yes | Dense embedding (length must equal index `dimension`) | -| `meta` | No | Arbitrary metadata `Map` — stored compressed, not filterable | -| `filter` | No | Key-value pairs used for filtered queries | -| `sparseIndices` | Hybrid only | Non-zero term positions in the sparse vector | -| `sparseValues` | Hybrid only | Weight for each sparse index (same length as `sparseIndices`)| +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique string identifier | +| `.vector(fieldName, double[])` | Per field | Dense vector (length must match field dimension) | +| `.sparse(fieldName, SparseData)` | Per field | Sparse vector (indices + values) | +| `.multiVector(fieldName, double[][])` | Per field | Multiple dense vectors | +| `.meta(Map)` | No | Arbitrary metadata — stored compressed, returned on search | +| `.filter(Map)` | No | Key-value pairs for filtered queries | **Limits:** -- 1 – 1,000 vectors per `upsert` call +- Max 10,000 objects per `upsert` call - IDs must be unique within a batch - Vector values must be finite (no `NaN` or `Inf`) +- Max vector dimension: 8,000 --- -## Querying +## Searching -### Basic Dense Query +### Single-field Search ```java -List results = index.query( - QueryOptions.builder() - .vector(new double[] {0.15, 0.25 /* ... */}) - .topK(5) - .build() +Map> results = collection.search( + Map.of("embedding", Map.of( + "query", new double[] {0.15, 0.25, 0.35, /* ... */}, + "limit", 10 // results per field (default: 10, max: 4,096) + )) ); -for (QueryResult item : results) { - System.out.println("ID: " + item.getId()); - System.out.println("Similarity: " + item.getSimilarity()); - System.out.println("Distance: " + item.getDistance()); // 1 - similarity - System.out.println("Meta: " + item.getMeta()); - System.out.println("Vector: " + Arrays.toString(item.getVector())); // empty unless includeVectors=true +// Results are per-field +for (SearchHit hit : results.get("embedding")) { + System.out.printf("ID: %s Score: %.4f Meta: %s Filter: %s%n", + hit.getId(), hit.getSimilarity(), hit.getMeta(), hit.getFilter()); } +// Output: +// ID: doc1 Score: 0.9823 Meta: {title=First Document, author=Alice} Filter: {category=tech, year=2024} +// ID: doc2 Score: 0.9156 Meta: {title=Second Document, author=Bob} Filter: {category=science, year=2023} ``` -### Filtered Query +### Filtered Search All filter conditions are combined with **logical AND**: ```java -List results = index.query( - QueryOptions.builder() - .vector(new double[] {0.15, 0.25 /* ... */}) - .topK(10) - .filter(List.of( - Map.of("category", Map.of("$eq", "tech")), - Map.of("score", Map.of("$range", List.of(80, 100))) - )) - .build() +Map> results = collection.search( + Map.of("embedding", Map.of( + "query", new double[] {0.15, 0.25, 0.35, /* ... */}, + "limit", 5 + )), + List.of( + Map.of("category", Map.of("$eq", "tech")), + Map.of("year", Map.of("$gte", 2023)) + ) ); ``` **Filter operators:** -| Operator | Description | Example | -|-----------|---------------------------|-------------------------------------------------------| -| `$eq` | Exact match | `Map.of("status", Map.of("$eq", "published"))` | -| `$in` | Match any value in list | `Map.of("tags", Map.of("$in", List.of("ai", "ml")))` | -| `$range` | Numeric range (inclusive) | `Map.of("score", Map.of("$range", List.of(70, 95)))` | +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Exact match | `Map.of("status", Map.of("$eq", "published"))` | +| `$in` | Match any value in list | `Map.of("tags", Map.of("$in", List.of("ai", "ml")))` | +| `$range` | Numeric range (inclusive) | `Map.of("score", Map.of("$range", List.of(70, 95)))` | +| `$gt` | Greater than | `Map.of("year", Map.of("$gt", 2020))` | +| `$gte` | Greater than or equal | `Map.of("year", Map.of("$gte", 2020))` | +| `$lt` | Less than | `Map.of("score", Map.of("$lt", 50))` | +| `$lte` | Less than or equal | `Map.of("score", Map.of("$lte", 50))` | -> `$range` supports integer values in **[0, 999]**. Normalize larger values before upserting. +### Multi-field Search -### Hybrid Query +Search across multiple fields simultaneously: ```java -List results = index.query( - QueryOptions.builder() - .vector(new double[] {0.15, 0.25 /* ... */}) // dense component - .sparseIndices(new int[] {10, 100, 300}) // sparse query positions - .sparseValues(new double[] {0.7, 0.5, 0.4}) // sparse query weights - .topK(5) - .denseRrfWeight(0.7) // weight for the dense component in RRF fusion (0.0–1.0) - .rrfRankConstant(60) // RRF rank constant (default 60) - .build() -); +Map> queryFields = new LinkedHashMap<>(); +queryFields.put("embedding", Map.of( + "query", new double[] {0.5, 0.5, 0.5, /* ... */}, + "limit", 10 +)); +queryFields.put("keywords", Map.of( + "query", new SparseData(new int[] {42, 999}, new double[] {0.8, 0.6}), + "limit", 10 +)); + +Map> results = collection.search(queryFields); +// results.get("embedding") — dense search results +// results.get("keywords") — sparse search results ``` -You can also query with only dense (`vector`) or only sparse (`sparseIndices` + `sparseValues`). +### Client-side RRF Reranking + +Fuse per-field results into a single ranked list using Reciprocal Rank Fusion: + +```java +import io.endee.client.Reranker; + +// Fuse with weighted fields +List fused = Reranker.rerank( + results, // per-field results from search() + 10, // max results to return + Map.of("embedding", 0.6, "keywords", 0.4), // field weights (must sum to 1.0) + 60 // RRF rank constant k +); + +for (SearchHit hit : fused) { + System.out.printf("ID: %s RRF Score: %.6f%n", hit.getId(), hit.getSimilarity()); +} +// Output: +// ID: doc1 RRF Score: 0.016393 +// ID: doc2 RRF Score: 0.013115 +// ... + +// Convenience: uniform weights, default limit (10) and k (60) +List fused = Reranker.rerank(results, Map.of("embedding", 0.5, "keywords", 0.5)); +``` -### All Query Options +### Advanced Search Options ```java -QueryOptions.builder() - .vector(double[]) // dense query vector - .topK(int) // results to return (default: 10, max: 4,096) - .ef(int) // HNSW search depth (default: 128, max: 1,024) - .filter(List>) // filter conditions (AND-combined) - .includeVectors(boolean) // include vector data in results (default: false) - .sparseIndices(int[]) // sparse query positions (hybrid only) - .sparseValues(double[]) // sparse query weights (hybrid only) - .denseRrfWeight(double) // dense RRF weight 0.0–1.0 (default: 0.5) - .rrfRankConstant(int) // RRF rank constant ≥ 1 (default: 60) - .prefilterCardinalityThreshold(int) // switch to postfilter above this (default: 10,000, range: 1,000–1,000,000) - .filterBoostPercentage(int) // expand candidate pool toward filter matches (default: 0, range: 0–400) - .build() +Map> results = collection.search( + queryFields, + filter, // List> — filter conditions (null for none) + 128, // ef_search — HNSW search depth (default: 128, max: 1,024) + 10_000, // prefilter_cardinality_threshold (1,000–1,000,000) + 0 // filter_boost_percentage (0–100) +); ``` --- -## CRUD Operations +## Get Objects -### Get a Vector by ID +Fetch full objects by ID, including all vector data: ```java -VectorInfo info = index.getVector("vec1"); -System.out.println("ID: " + info.getId()); -System.out.println("Vector: " + Arrays.toString(info.getVector())); -System.out.println("Meta: " + info.getMeta()); -System.out.println("Filter: " + info.getFilter()); -System.out.println("Norm: " + info.getNorm()); - -// For hybrid indexes, sparse fields are also populated: -System.out.println("SparseIndices: " + Arrays.toString(info.getSparseIndices())); -System.out.println("SparseValues: " + Arrays.toString(info.getSparseValues())); +List objects = collection.getObjects(List.of("doc1", "doc2")); + +for (ObjectInfo obj : objects) { + System.out.println("ID: " + obj.getId()); + System.out.println("Meta: " + obj.getMeta()); + System.out.println("Filter: " + obj.getFilter()); + System.out.println("Dense fields: " + obj.getVectors().keySet()); + System.out.println("Sparse fields: " + obj.getSparses().keySet()); + System.out.println("Multi-vector fields: " + obj.getMultiVectors().keySet()); +} +// Output: +// ID: doc1 +// Meta: {title=First Document, author=Alice} +// Filter: {category=tech, year=2024} +// Dense fields: [embedding] +// Sparse fields: [keywords] +// Multi-vector fields: [] ``` -### Update Filters +**ObjectInfo fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `String` | Object ID | +| `meta` | `Map` | Metadata | +| `filter` | `Map` | Filter values | +| `vectors` | `Map` | Dense vectors by field name | +| `sparses` | `Map` | Sparse vectors by field name | +| `multiVectors` | `Map` | Multi-vectors by field name | -Updates filter fields on existing vectors without re-upserting. The entire filter object is replaced: +--- + +## Delete Objects ```java -index.updateFilters(List.of( - new UpdateFilterParams("vec1", Map.of("category", "ml", "score", 95)), - new UpdateFilterParams("vec2", Map.of("category", "science", "score", 80)) +// Delete by ID +Map result = collection.deleteObject("doc1"); +// Output: {message=1 rows deleted} + +// Delete by filter +Map result = collection.deleteByFilter( + List.of(Map.of("category", Map.of("$eq", "tech"))) +); +// Output: {message=5 rows deleted} +``` + +--- + +## Update Filters + +Update filter fields on existing objects without re-upserting. The entire filter object is replaced: + +```java +import io.endee.client.types.UpdateFilterParams; + +Map result = collection.updateFilters(List.of( + new UpdateFilterParams("doc1", Map.of("category", "ml", "year", 2025)), + new UpdateFilterParams("doc2", Map.of("category", "physics", "year", 2024)) )); +// Output: {message=2 filters updated} ``` -### Delete by ID +--- + +## Index Maintenance + +### Rebuild + +Rebuilds HNSW graphs with new parameters. Runs asynchronously — poll `rebuildStatus()` until complete: ```java -String result = index.deleteVector("vec1"); -// returns e.g. "1 rows deleted" +// Trigger rebuild +Map result = collection.rebuild( + List.of(Map.of("field", "embedding", "M", 20, "ef_con", 200)) +); +// Output: {message=rebuild started} + +// Poll until complete +while (true) { + Map status = collection.rebuildStatus(); + System.out.println(status); + // Output: {status=in_progress, vectors_processed=500, total_vectors=1000, percent_complete=50} + if ("completed".equals(status.get("status"))) break; + Thread.sleep(2000); +} ``` -### Delete by Filter +### Shrink + +Defragments the collection's storage after deletions: ```java -index.deleteWithFilter(List.of( - Map.of("category", Map.of("$eq", "tech")) -)); +Map result = collection.shrink(); +// Output: {message=shrink complete} ``` --- -## Index Maintenance +## Backups -### Describe Index +### Collection-level Backup -Returns stored metadata without a network call: +```java +// Create a backup (async — poll activeBackup() until done) +Map result = collection.createBackup("my_backup"); +// Output: {message=backup started} + +// Poll until complete +while (true) { + Map active = client.activeBackup(); + if (!Boolean.TRUE.equals(active.get("active"))) break; + Thread.sleep(2000); +} +``` + +### Backup Management + +```java +// List all backups +Object backups = client.listBackups(); + +// Get backup info +Map info = client.backupInfo("my_backup"); + +// Active backup status +Map active = client.activeBackup(); + +// Restore a backup into a new collection +Map result = client.restoreBackup("my_backup", "restored_collection"); + +// Delete a backup +client.deleteBackup("my_backup"); +``` + +### Download & Upload Backups ```java -IndexDescription desc = index.describe(); -System.out.println(desc); -// {name='my_index', spaceType=COSINE, dimension=384, precision=INT8, -// count=1000, isHybrid=true, sparseModel='default', M=16, efCon=128} +// Download a backup as a .tar file +String path = client.downloadBackup("my_backup", "/tmp/my_backup.tar"); +// Output: "/tmp/my_backup.tar" + +// Download with db_name (for root-token multi-database targeting) +client.downloadBackup("my_backup", "/tmp/my_backup.tar", "my_database"); + +// Upload a .tar backup file +Map result = client.uploadBackup("/tmp/my_backup.tar"); +// Output: {message=backup uploaded} ``` -### Refresh Metadata +--- -Fetches the latest metadata from the server and updates the local Index object: +## Server Info ```java -Map meta = index.refreshMetadata(); -// returns: {count, space_type, dimension, precision, M, ef_con, sparse_model, is_hybrid} +// Health check +Map health = client.health(); +// Output: {status=ok, timestamp=1234567890} + +// Server stats +Map stats = client.stats(); +// Output: {version=2.0.0, uptime=3600, total_requests=15000} ``` -### Rebuild Index +--- -Rebuilds the HNSW graph with new parameters. Useful after bulk inserts or to tune recall: +## Admin Features + +Admin operations require a root token. + +### Database Management ```java -Map result = index.rebuild(16, 200); -// result: {status, previous_config, new_config, total_vectors} +Endee admin = new Endee("root_token"); + +// Create a database (returns the new db token) +String dbToken = admin.createDatabase("my_db", "enterprise"); +// db_type options: "starter", "pro", "scale", "enterprise" + +// List all databases +List> dbs = admin.listDatabases(); + +// Get database info +Map info = admin.getDatabase("my_db"); + +// Activate / deactivate +admin.activateDatabase("my_db"); +admin.deactivateDatabase("my_db"); + +// Change database tier +admin.setDatabaseType("my_db", "pro"); + +// Delete a database +admin.deleteDatabase("my_db"); ``` -> `rebuild()` first calls `refreshMetadata()` to verify the index is non-empty, then sends a `POST /rebuild` request. The server responds `202 Accepted` while the rebuild runs asynchronously. +### Admin Collection Views -### Rebuild Status +```java +// List collections in a specific database +List> cols = admin.listDbCollections("my_db"); + +// List all collections across all databases +List> allCols = admin.listAllCollections(); + +// Delete a collection in a specific database +admin.deleteDbCollection("my_db", "my_collection"); +``` -Poll the rebuild progress: +### Token Management (Admin) ```java -Map status = index.rebuildStatus(); -// status: {status: "in_progress"|"completed"|"failed"|"idle", -// vectors_processed, total_vectors, percent_complete} +// Create a token for a database +String token = admin.createToken("my_db", "analytics_token", "r"); +// token_type: "rw" (read-write) or "r" (read-only) + +// List tokens +List> tokens = admin.listTokens("my_db"); + +// Delete a token +admin.deleteToken("my_db", "analytics_token"); +``` + +### Self-service Token Management + +Available to any authenticated user for their own database: + +```java +Endee client = new Endee("my_db:my_secret"); + +// Create a token +String token = client.createMyToken("my_token", "rw"); + +// List my tokens +List> tokens = client.listMyTokens(); + +// Delete a token +client.deleteMyToken("my_token"); ``` --- ## Precision Options -| Value | Wire | Use Case | -|-------------|----------|------------------------------------------------------------------| -| `BINARY` | `binary` | Maximum compression — 1 bit/dim, fastest search | -| `INT8` | `int8` | Default — best balance of accuracy and performance | -| `INT16` | `int16` | Higher accuracy than INT8 | -| `FLOAT16` | `float16`| Good compromise for embeddings | -| `FLOAT32` | `float32`| Maximum precision | +| Value | Wire | Description | +|-------|------|-------------| +| `binary` | `binary` | 1 bit/dim — maximum compression, fastest search | +| `int8` | `int8` | Default — best balance of accuracy and performance | +| `int8e` | `int8e` | Enhanced INT8 with error correction | +| `int16` | `int16` | Higher accuracy than INT8 | +| `float16` | `float16` | Good compromise for embeddings | +| `float32` | `float32` | Maximum precision | ## Space Types -| Value | Wire | Best For | -|----------|----------|----------------------------------------| -| `COSINE` | `cosine` | Normalized embeddings (default) | -| `L2` | `l2` | Spatial / Euclidean distance | -| `IP` | `ip` | Unnormalized embeddings (dot product) | +| Value | Wire | Best For | +|-------|------|----------| +| `cosine` | `cosine` | Normalized embeddings (default) | +| `l2` | `l2` | Spatial / Euclidean distance | +| `ip` | `ip` | Unnormalized embeddings (dot product) | --- @@ -384,36 +556,36 @@ The client uses a typed exception hierarchy. All exceptions extend `EndeeExcepti import io.endee.client.exception.*; try { - index.getVector("missing_id"); + collection.getObjects(List.of("missing_id")); } catch (NotFoundException e) { + // 404 — object or collection not found System.err.println("Not found: " + e.getMessage()); } catch (AuthenticationException e) { + // 401 — invalid or expired token System.err.println("Auth failed: " + e.getMessage()); } catch (EndeeApiException e) { - // catch-all for any API error — provides status code and raw body + // Catch-all for any API error System.err.println("HTTP " + e.getStatusCode() + ": " + e.getErrorBody()); } catch (EndeeException e) { - // network / serialization errors + // Network or serialization errors System.err.println("Client error: " + e.getMessage()); } catch (IllegalArgumentException e) { - // validation errors (invalid params, dimension mismatch, etc.) + // Validation errors (invalid params, dimension mismatch, etc.) System.err.println("Validation: " + e.getMessage()); } ``` -**Exception hierarchy:** - -| Exception | HTTP Status | Trigger | -|--------------------------|-------------|----------------------------------------| -| `EndeeApiException` | 400 | Bad request / validation error (base) | -| `AuthenticationException`| 401 | Invalid or expired token | -| `SubscriptionException` | 402 | Quota exceeded / tier limit | -| `ForbiddenException` | 403 | Insufficient permissions | -| `NotFoundException` | 404 | Index or vector not found | -| `ConflictException` | 409 | Resource already exists | -| `ServerException` | 5xx | Server busy / internal error | +**Exception types:** -All typed exceptions also extend `EndeeApiException`, so catching `EndeeApiException` handles every API error if you only need the status code. +| Exception | HTTP Status | Trigger | +|-----------|-------------|---------| +| `EndeeApiException` | 400 | Bad request (base for all API errors) | +| `AuthenticationException` | 401 | Invalid or expired token | +| `SubscriptionException` | 402 | Quota exceeded or tier limit | +| `ForbiddenException` | 403 | Insufficient permissions | +| `NotFoundException` | 404 | Collection or object not found | +| `ConflictException` | 409 | Resource already exists | +| `ServerException` | 5xx | Server error | --- @@ -421,77 +593,88 @@ All typed exceptions also extend `EndeeApiException`, so catching `EndeeApiExcep ```java import io.endee.client.Endee; -import io.endee.client.Index; -import io.endee.client.exception.*; +import io.endee.client.Collection; +import io.endee.client.Reranker; import io.endee.client.types.*; -import java.util.Arrays; -import java.util.List; -import java.util.Map; +import java.util.*; public class Example { - public static void main(String[] args) { - Endee client = new Endee(); - - // 1. Create a hybrid index - client.createIndex( - CreateIndexOptions.builder("docs", 384) - .spaceType(SpaceType.COSINE) - .precision(Precision.INT8) - .sparseModel("default") - .build() - ); + public static void main(String[] args) throws Exception { + Endee client = new Endee("db_name:secret:region"); + + // 1. Create a hybrid collection + client.createCollection("docs", List.of( + Map.of("name", "embedding", "type", "vector", + "params", Map.of("dimension", 768, "space_type", "cosine", + "precision", "int8", "M", 16, "ef_con", 128)), + Map.of("name", "keywords", "type", "sparse", + "sparse_model", "default") + )); - // 2. Get index reference - Index index = client.getIndex("docs"); - System.out.println("isHybrid: " + index.isHybrid()); // true + // 2. Get collection reference + Collection collection = client.getCollection("docs"); - // 3. Upsert vectors - index.upsert(List.of( - VectorItem.builder("doc1", new double[384]) - .sparseIndices(new int[] {10, 500, 1200}) - .sparseValues( new double[]{0.8, 0.5, 0.3}) + // 3. Upsert objects + collection.upsert(List.of( + ObjectItem.builder("doc1") + .vector("embedding", new double[768]) + .sparse("keywords", new SparseData( + new int[] {10, 500, 1200}, + new double[] {0.8, 0.5, 0.3})) .meta(Map.of("title", "Hello World")) .filter(Map.of("category", "tech", "score", 90)) .build() )); - // 4. Query - List results = index.query( - QueryOptions.builder() - .vector(new double[384]) - .sparseIndices(new int[] {10, 500}) - .sparseValues( new double[]{0.9, 0.4}) - .topK(5) - .denseRrfWeight(0.6) - .filter(List.of(Map.of("category", Map.of("$eq", "tech")))) - .includeVectors(true) - .build() + // 4. Multi-field search + Map> query = new LinkedHashMap<>(); + query.put("embedding", Map.of( + "query", new double[768], "limit", 5)); + query.put("keywords", Map.of( + "query", new SparseData(new int[] {10, 500}, new double[] {0.9, 0.4}), + "limit", 5)); + + Map> results = collection.search( + query, + List.of(Map.of("category", Map.of("$eq", "tech"))) ); - for (QueryResult r : results) { - System.out.printf("ID: %s Similarity: %.4f Meta: %s%n", - r.getId(), r.getSimilarity(), r.getMeta()); + // 5. Fuse results with RRF + List fused = Reranker.rerank(results, 10, + Map.of("embedding", 0.6, "keywords", 0.4), 60); + + for (SearchHit hit : fused) { + System.out.printf("ID: %s Score: %.6f Meta: %s%n", + hit.getId(), hit.getSimilarity(), hit.getMeta()); } - // 5. Get a vector (hybrid returns sparse fields too) - VectorInfo info = index.getVector("doc1"); - System.out.println("SparseIndices: " + Arrays.toString(info.getSparseIndices())); + // 6. Get full objects + List objects = collection.getObjects(List.of("doc1")); + System.out.println("Vectors: " + objects.get(0).getVectors().keySet()); - // 6. Update filter - index.updateFilters(List.of( + // 7. Update filters + collection.updateFilters(List.of( new UpdateFilterParams("doc1", Map.of("category", "ml", "score", 95)) )); - // 7. Rebuild index after bulk inserts - Map rebuildResult = index.rebuild(16, 200); - System.out.println("Rebuild: " + rebuildResult.get("status")); + // 8. Rebuild and wait + collection.rebuild(List.of(Map.of("field", "embedding", "M", 20, "ef_con", 200))); + while (!"completed".equals(collection.rebuildStatus().get("status"))) { + Thread.sleep(2000); + } - // 8. Poll rebuild status - Map status = index.rebuildStatus(); - System.out.println("Status: " + status); + // 9. Backup, download, restore + collection.createBackup("my_backup"); + while (Boolean.TRUE.equals(client.activeBackup().get("active"))) { + Thread.sleep(2000); + } + client.downloadBackup("my_backup", "/tmp/my_backup.tar"); + client.restoreBackup("my_backup", "docs_restored"); - // 9. Cleanup - client.deleteIndex("docs"); + // 10. Cleanup + client.deleteCollection("docs"); + client.deleteCollection("docs_restored"); + client.deleteBackup("my_backup"); } } ``` @@ -500,118 +683,70 @@ public class Example { ## API Reference -### `Endee` - -| Method | Returns | Description | -|-------------------------------------|----------|------------------------------------| -| `Endee()` | — | Connect to local server | -| `Endee(String token)` | — | Connect with auth token | -| `setBaseUrl(String url)` | `String` | Override the base URL | -| `createIndex(CreateIndexOptions)` | `String` | Create a new index | -| `listIndexes()` | `String` | List all indexes (raw JSON) | -| `getIndex(String name)` | `Index` | Get an Index object | -| `deleteIndex(String name)` | `String` | Delete an index | - -### `Index` - -| Method | Returns | Description | -|-------------------------------------------|---------------------|------------------------------------------| -| `upsert(List)` | `String` | Insert or update vectors | -| `query(QueryOptions)` | `List` | Similarity search | -| `getVector(String id)` | `VectorInfo` | Fetch a vector by ID | -| `updateFilters(List)` | `String` | Update filter fields without re-upserting| -| `deleteVector(String id)` | `String` | Delete a vector by ID | -| `deleteWithFilter(List)` | `String` | Delete vectors matching a filter | -| `describe()` | `IndexDescription` | Return index metadata (no network call) | -| `refreshMetadata()` | `Map`| Fetch + update metadata from server | -| `rebuild(int m, int efCon)` | `Map`| Trigger HNSW graph rebuild | -| `rebuildStatus()` | `Map`| Poll rebuild progress | -| `isHybrid()` | `boolean` | True when sparse_model ≠ "None" | -| `getLibToken()` | `String` | Library token from the server | - -### `CreateIndexOptions.Builder` - -```java -CreateIndexOptions.builder(String name, int dimension) - .spaceType(SpaceType) // default: COSINE - .m(int) // default: 16 - .efCon(int) // default: 128 - .precision(Precision) // default: INT8 - .sparseModel(String) // "default" | "endee_bm25" | null (dense-only) - .version(Integer) // optional API version - .build() -``` - -### `QueryOptions.Builder` - -```java -QueryOptions.builder() - .vector(double[]) // dense query vector - .topK(int) // default: 10, range: 1–4,096 - .ef(int) // default: 128, max: 1,024 - .filter(List>) // AND-combined filter conditions - .includeVectors(boolean) // default: false - .sparseIndices(int[]) // hybrid only - .sparseValues(double[]) // hybrid only - .denseRrfWeight(double) // default: 0.5, range: 0.0–1.0 - .rrfRankConstant(int) // default: 60, min: 1 - .prefilterCardinalityThreshold(int) // default: 10,000, range: 1,000–1,000,000 - .filterBoostPercentage(int) // default: 0, range: 0–400 - .build() -``` - -### `VectorItem.Builder` - -```java -VectorItem.builder(String id, double[] vector) - .meta(Map) // arbitrary metadata - .filter(Map) // filterable key-value fields - .sparseIndices(int[]) // hybrid only - .sparseValues(double[]) // hybrid only - .build() -``` - ---- - -## Data Types - -### `QueryResult` - -| Field | Type | Description | -|--------------|-----------------------|--------------------------------------------------| -| `id` | `String` | Vector ID | -| `similarity` | `double` | Similarity score | -| `distance` | `double` | Distance (`1 - similarity`) | -| `meta` | `Map` | Metadata | -| `filter` | `Map` | Filter values (omitted when empty) | -| `norm` | `double` | L2 norm of the original vector | -| `vector` | `double[]` | Vector data — empty `[]` unless `includeVectors` | - -### `VectorInfo` - -| Field | Type | Description | -|-----------------|-----------------------|--------------------------------------| -| `id` | `String` | Vector ID | -| `vector` | `double[]` | Dense vector data | -| `meta` | `Map` | Metadata | -| `filter` | `Map` | Filter values | -| `norm` | `double` | L2 norm | -| `sparseIndices` | `int[]` | Sparse positions (hybrid only) | -| `sparseValues` | `double[]` | Sparse weights (hybrid only) | - -### `IndexDescription` - -| Field | Type | Description | -|---------------|-------------|------------------------------------------| -| `name` | `String` | Index name | -| `spaceType` | `SpaceType` | Distance metric | -| `dimension` | `int` | Dense vector dimension | -| `sparseModel` | `String` | `"default"`, `"endee_bm25"`, or `"None"` | -| `isHybrid` | `boolean` | True when sparse_model ≠ `"None"` | -| `count` | `long` | Number of vectors in the index | -| `precision` | `Precision` | Quantization precision | -| `m` | `int` | HNSW M parameter | -| `efCon` | `int` | HNSW ef_construction | +### `Endee` (Client) + +| Method | Returns | Description | +|--------|---------|-------------| +| `Endee()` | — | Connect to local server | +| `Endee(String token)` | — | Connect with auth token | +| `setBaseUrl(String url)` | `void` | Override the base URL | +| `setToken(String token)` | `void` | Set the auth token | +| `createCollection(name, fields)` | `Map` | Create a new collection | +| `listCollections()` | `List` | List all collections | +| `getCollection(name)` | `Collection` | Get a Collection reference | +| `deleteCollection(name)` | `Map` | Delete a collection | +| `health()` | `Map` | Server health check | +| `stats()` | `Map` | Server stats | +| `listBackups()` | `Object` | List backups | +| `backupInfo(name)` | `Map` | Get backup metadata | +| `activeBackup()` | `Map` | Get active backup status | +| `restoreBackup(name, target)` | `Map` | Restore backup to new collection | +| `deleteBackup(name)` | `Map` | Delete a backup | +| `downloadBackup(name, destPath)` | `String` | Download backup as .tar | +| `downloadBackup(name, destPath, dbName)` | `String` | Download backup (multi-db) | +| `uploadBackup(filePath)` | `Map` | Upload a .tar backup | +| `createDatabase(name, type)` | `String` | Create database (admin) | +| `listDatabases()` | `List` | List databases (admin) | +| `getDatabase(name)` | `Map` | Get database info (admin) | +| `deleteDatabase(name)` | `Map` | Delete database (admin) | +| `activateDatabase(name)` | `Map` | Activate database (admin) | +| `deactivateDatabase(name)` | `Map` | Deactivate database (admin) | +| `setDatabaseType(name, type)` | `Map` | Change database tier (admin) | +| `listDbCollections(dbName)` | `List` | List collections in db (admin) | +| `listAllCollections()` | `List` | List all collections (admin) | +| `deleteDbCollection(db, col)` | `Map` | Delete collection in db (admin) | +| `createToken(db, name, type)` | `String` | Create db token (admin) | +| `listTokens(db)` | `List` | List db tokens (admin) | +| `deleteToken(db, name)` | `Map` | Delete db token (admin) | +| `createMyToken(name, type)` | `String` | Create own token | +| `listMyTokens()` | `List` | List own tokens | +| `deleteMyToken(name)` | `Map` | Delete own token | + +### `Collection` + +| Method | Returns | Description | +|--------|---------|-------------| +| `upsert(List)` | `Map` | Insert or update objects (max 10,000) | +| `search(queryFields)` | `Map>` | Search (no filter) | +| `search(queryFields, filter)` | `Map>` | Search with filter | +| `search(queryFields, filter, efSearch, prefilterThreshold, boostPct)` | `Map>` | Search with all options | +| `getObjects(List ids)` | `List` | Fetch full objects by ID | +| `deleteObject(String id)` | `Map` | Delete object by ID | +| `deleteByFilter(List)` | `Map` | Delete objects matching filter | +| `updateFilters(List)` | `Map` | Update filter fields | +| `describe()` | `Map` | Get collection metadata | +| `rebuild(List fieldSpecs)` | `Map` | Trigger HNSW rebuild | +| `rebuildStatus()` | `Map` | Poll rebuild progress | +| `shrink()` | `Map` | Defragment storage | +| `createBackup(String name)` | `Map` | Create a backup | + +### `Reranker` + +| Method | Returns | Description | +|--------|---------|-------------| +| `rerank(results, limit, fieldWeights, rrfK)` | `List` | RRF fusion with all options | +| `rerank(results, fieldWeights)` | `List` | RRF with default limit (10) and k (60) | +| `rerank(results, limit)` | `List` | RRF with uniform weights | --- diff --git a/pom.xml b/pom.xml index ba1dc76..e592ac5 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.endee endee-java-client - 1.0.2-SNAPSHOT + 2.0.0-SNAPSHOT jar Endee Java Client diff --git a/src/main/java/io/endee/client/Collection.java b/src/main/java/io/endee/client/Collection.java new file mode 100644 index 0000000..a0651a2 --- /dev/null +++ b/src/main/java/io/endee/client/Collection.java @@ -0,0 +1,842 @@ +package io.endee.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.endee.client.exception.EndeeApiException; +import io.endee.client.exception.EndeeException; +import io.endee.client.types.*; +import io.endee.client.util.CryptoUtils; +import io.endee.client.util.JsonUtils; +import io.endee.client.util.MessagePackUtils; +import io.endee.client.util.ValidationUtils; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Collection client for Endee-DB vector operations (v2 API). + * + *

Obtain via {@link Endee#getCollection(String)}. + */ +public class Collection { + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + private static final int MAX_BATCH_SIZE = 10_000; + private static final int MAX_TOP_K = 4096; + private static final int MAX_EF_SEARCH = 1024; + private static final int MAX_FILTER_BOOST_PERCENTAGE = 100; + private static final int MIN_PREFILTER_THRESHOLD = 1_000; + private static final int MAX_PREFILTER_THRESHOLD = 1_000_000; + private static final String NORMS_KEY = "internal_"; + + private final String name; + private final String token; + private final String baseUrl; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private List> fields; + + public Collection(String name, String token, String baseUrl, Map metadata) { + this.name = name; + this.token = token; + this.baseUrl = baseUrl; + this.objectMapper = new ObjectMapper(); + + @SuppressWarnings("unchecked") + List> fieldsList = + (List>) metadata.getOrDefault("fields", List.of()); + this.fields = fieldsList; + + this.httpClient = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(DEFAULT_TIMEOUT) + .build(); + } + + @Override + public String toString() { + return name; + } + + /** Returns field metadata: name → {type, space_type, dimension}. */ + private Map> fieldMap() { + Map> idx = new LinkedHashMap<>(); + for (Map f : fields) { + @SuppressWarnings("unchecked") + Map params = + (Map) f.getOrDefault("params", Map.of()); + Map entry = new HashMap<>(); + entry.put("type", f.getOrDefault("type", "vector")); + entry.put("space_type", params.getOrDefault("space_type", "cosine")); + entry.put("dimension", params.getOrDefault("dimension", 0)); + idx.put((String) f.get("name"), entry); + } + return idx; + } + + // ── upsert ────────────────────────────────────────────────────────────────── + + /** + * Upserts objects into the collection. + * + * @param objects list of objects to upsert (max 10,000) + * @return server response + */ + public Map upsert(List objects) { + if (objects == null || objects.isEmpty()) { + throw new IllegalArgumentException("Must provide at least one object to upsert"); + } + if (objects.size() > MAX_BATCH_SIZE) { + throw new IllegalArgumentException( + "Cannot upsert more than " + MAX_BATCH_SIZE + " objects at a time"); + } + + List ids = + objects.stream().map(ObjectItem::getId).collect(Collectors.toList()); + ValidationUtils.validateObjectIds(ids); + + Map> fMap = fieldMap(); + List wireObjects = new ArrayList<>(); + + for (ObjectItem item : objects) { + String filterStr = ""; + if (item.getFilter() != null && !item.getFilter().isEmpty()) { + filterStr = JsonUtils.toJson(item.getFilter()); + } + + Map vectors = new LinkedHashMap<>(); + Map sparses = new LinkedHashMap<>(); + Map multiVectors = new LinkedHashMap<>(); + Map norms = new LinkedHashMap<>(); + + if (item.getFields() != null) { + for (Map.Entry fe : item.getFields().entrySet()) { + String fname = fe.getKey(); + Object fdata = fe.getValue(); + Map cfg = fMap.get(fname); + if (cfg == null) { + throw new IllegalArgumentException( + "Unknown field '" + fname + "'. Collection fields: " + fMap.keySet()); + } + + String ftype = (String) cfg.get("type"); + String space = (String) cfg.getOrDefault("space_type", "cosine"); + int dim = cfg.get("dimension") instanceof Number ? ((Number) cfg.get("dimension")).intValue() : 0; + + if ("vector".equals(ftype)) { + double[] vec = (double[]) fdata; + validateVectorValues(vec, item.getId()); + double[] normalized = normalizeDense(vec, space); + double norm = computeNorm(vec); + if (dim > 0 && vec.length != dim) { + throw new IllegalArgumentException( + "Field '" + fname + "': expected dimension " + dim + ", got " + vec.length); + } + vectors.put(fname, normalized); + if ("cosine".equals(space)) { + norms.put(fname, norm); + } + } else if ("sparse".equals(ftype)) { + SparseData sd = (SparseData) fdata; + sparses.put(fname, new Object[] {sd.getIndices(), sd.getValues()}); + } else if ("multi_vector".equals(ftype)) { + double[][] vecs = (double[][]) fdata; + double[][] normalizedVecs = new double[vecs.length][]; + List vecNorms = new ArrayList<>(); + for (int i = 0; i < vecs.length; i++) { + validateVectorValues(vecs[i], item.getId()); + if (dim > 0 && vecs[i].length != dim) { + throw new IllegalArgumentException( + "Field '" + + fname + + "': every multi_vector must have dimension " + + dim); + } + normalizedVecs[i] = normalizeDense(vecs[i], space); + if ("cosine".equals(space)) { + vecNorms.add(computeNorm(vecs[i])); + } + } + multiVectors.put(fname, normalizedVecs); + if ("cosine".equals(space) && !vecNorms.isEmpty()) { + norms.put(fname, vecNorms); + } + } else { + throw new IllegalArgumentException( + "Field '" + fname + "' has unknown type '" + ftype + "'"); + } + } + } + + // Build meta with norms + Map rawMeta = + item.getMeta() != null ? new HashMap<>(item.getMeta()) : new HashMap<>(); + if (!norms.isEmpty()) { + rawMeta.put(NORMS_KEY, norms); + } + byte[] metaBytes = CryptoUtils.jsonZip(rawMeta); + + wireObjects.add( + new Object[] {item.getId(), metaBytes, filterStr, vectors, sparses, multiVectors}); + } + + byte[] payload = MessagePackUtils.packObjects(wireObjects); + + try { + HttpRequest request = + buildPostMsgpackRequest("/collection/" + name + "/objects", payload); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to upsert objects", e); + } + } + + // ── search ────────────────────────────────────────────────────────────────── + + /** + * Searches the collection across one or more fields. + * + * @param queryFields field_name → {query, limit?, ef_search?} + * @param filter optional filter conditions + * @param efSearch default ef_search (max 1024) + * @param prefilterThreshold optional prefilter cardinality threshold (1000-1000000) + * @param boostPercentage optional filter boost percentage (0-100) + * @return per-field results: field_name → list of hits + */ + public Map> search( + Map> queryFields, + List> filter, + int efSearch, + Integer prefilterThreshold, + Integer boostPercentage) { + + if (queryFields == null || queryFields.isEmpty()) { + throw new IllegalArgumentException("search requires at least one field"); + } + if (efSearch < 1 || efSearch > MAX_EF_SEARCH) { + throw new IllegalArgumentException("ef_search must be between 1 and " + MAX_EF_SEARCH); + } + if (prefilterThreshold != null + && (prefilterThreshold < MIN_PREFILTER_THRESHOLD + || prefilterThreshold > MAX_PREFILTER_THRESHOLD)) { + throw new IllegalArgumentException( + "prefilter_cardinality_threshold must be between " + + MIN_PREFILTER_THRESHOLD + + " and " + + MAX_PREFILTER_THRESHOLD); + } + if (boostPercentage != null + && (boostPercentage < 0 || boostPercentage > MAX_FILTER_BOOST_PERCENTAGE)) { + throw new IllegalArgumentException( + "filter_boost_percentage must be between 0 and " + MAX_FILTER_BOOST_PERCENTAGE); + } + + Map> fMap = fieldMap(); + List> fieldsArray = new ArrayList<>(); + Map fieldLimits = new LinkedHashMap<>(); + + for (Map.Entry> entry : queryFields.entrySet()) { + String fname = entry.getKey(); + Map fdata = new LinkedHashMap<>(entry.getValue()); + + // Normalize query vectors for cosine fields + Object query = fdata.get("query"); + Map fld = fMap.get(fname); + if (fld != null) { + String ftype = (String) fld.get("type"); + String space = (String) fld.getOrDefault("space_type", "cosine"); + if ("vector".equals(ftype) && "cosine".equals(space) && query instanceof double[]) { + fdata.put("query", normalizeDense((double[]) query, space)); + } else if ("multi_vector".equals(ftype) + && "cosine".equals(space) + && query instanceof double[][]) { + double[][] qVecs = (double[][]) query; + double[][] normalized = new double[qVecs.length][]; + for (int i = 0; i < qVecs.length; i++) { + normalized[i] = normalizeDense(qVecs[i], space); + } + fdata.put("query", normalized); + } + } + + // Resolve limit + int limit = 10; + if (fdata.containsKey("limit") && fdata.get("limit") instanceof Number) { + limit = ((Number) fdata.get("limit")).intValue(); + if (limit < 1 || limit > MAX_TOP_K) { + throw new IllegalArgumentException( + "Search field '" + fname + "': limit must be between 1 and " + MAX_TOP_K); + } + } + fieldLimits.put(fname, limit); + + // Build entry + Map fieldEntry = new LinkedHashMap<>(fdata); + fieldEntry.put("limit", limit); + if (!fieldEntry.containsKey("ef_search")) { + fieldEntry.put("ef_search", efSearch); + } + + // Convert SparseData query to map format for JSON + Object q = fieldEntry.get("query"); + if (q instanceof SparseData sd) { + Map sparseQuery = new LinkedHashMap<>(); + sparseQuery.put("indices", sd.getIndices()); + sparseQuery.put("values", sd.getValues()); + fieldEntry.put("query", sparseQuery); + } + + fieldsArray.add(Map.of(fname, fieldEntry)); + } + + Map payload = new LinkedHashMap<>(); + payload.put("fields", fieldsArray); + if (filter != null) { + payload.put("filter", filter); + } + if (prefilterThreshold != null || boostPercentage != null) { + Map filterParams = new LinkedHashMap<>(); + filterParams.put( + "prefilter_threshold", prefilterThreshold != null ? prefilterThreshold : 10_000); + filterParams.put("boost_percentage", boostPercentage != null ? boostPercentage : 0); + payload.put("filter_params", filterParams); + } + + try { + String jsonBody = JsonUtils.toJson(payload); + HttpRequest request = buildPostJsonRequest("/collection/" + name + "/search", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), new String(response.body())); + } + + Object[] decoded = MessagePackUtils.unpackSearchResponse(response.body()); + @SuppressWarnings("unchecked") + Map objectsMap = (Map) decoded[0]; + @SuppressWarnings("unchecked") + Map> resultsMap = (Map>) decoded[1]; + + Map> perField = new LinkedHashMap<>(); + for (String fname : queryFields.keySet()) { + List hits = resultsMap.getOrDefault(fname, List.of()); + int limit = fieldLimits.getOrDefault(fname, 10); + List fieldHits = new ArrayList<>(); + for (Object[] hit : hits) { + if (fieldHits.size() >= limit) break; + int intId = (Integer) hit[0]; + double score = (Double) hit[1]; + Object[] objMeta = objectsMap.get(intId); + SearchHit sh = new SearchHit(); + if (objMeta != null) { + sh.setId((String) objMeta[0]); + Map meta = CryptoUtils.jsonUnzip((byte[]) objMeta[1]); + if (meta != null) { + meta.remove(NORMS_KEY); + } + sh.setMeta(meta); + String filterStr = (String) objMeta[2]; + if (filterStr != null && !filterStr.isEmpty()) { + @SuppressWarnings("unchecked") + Map parsedFilter = JsonUtils.fromJson(filterStr, Map.class); + sh.setFilter(parsedFilter); + } + } else { + sh.setId(String.valueOf(intId)); + sh.setMeta(Map.of()); + } + sh.setSimilarity(score); + fieldHits.add(sh); + } + perField.put(fname, fieldHits); + } + + return perField; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to search collection", e); + } + } + + /** Convenience overload with defaults: efSearch=128, no filter tuning. */ + public Map> search( + Map> queryFields, + List> filter) { + return search(queryFields, filter, 128, null, null); + } + + /** Convenience overload: no filter. */ + public Map> search( + Map> queryFields) { + return search(queryFields, null, 128, null, null); + } + + // ── get objects ────────────────────────────────────────────────────────────── + + /** + * Fetches full objects by ID. + * + * @param ids list of object IDs + * @return list of objects with vectors, sparses, multi_vectors + */ + public List getObjects(List ids) { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException("getObjects requires a non-empty list of ids"); + } + + try { + String jsonBody = JsonUtils.toJson(Map.of("ids", ids)); + HttpRequest request = + buildPostJsonRequest("/collection/" + name + "/objects/query", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), new String(response.body())); + } + + List batch = MessagePackUtils.unpackObjectBatch(response.body()); + List results = new ArrayList<>(); + + for (Object[] obj : batch) { + ObjectInfo info = new ObjectInfo(); + info.setId((String) obj[0]); + + Map meta = CryptoUtils.jsonUnzip((byte[]) obj[1]); + @SuppressWarnings("unchecked") + Map normsMap = + meta != null ? (Map) meta.remove(NORMS_KEY) : null; + info.setMeta(meta != null ? meta : Map.of()); + + String filterStr = (String) obj[2]; + if (filterStr != null && !filterStr.isEmpty()) { + @SuppressWarnings("unchecked") + Map parsedFilter = JsonUtils.fromJson(filterStr, Map.class); + info.setFilter(parsedFilter); + } else { + info.setFilter(Map.of()); + } + + // Vectors — reconstruct originals using norms + @SuppressWarnings("unchecked") + Map vectors = (Map) obj[3]; + if (normsMap != null && vectors != null) { + for (Map.Entry ve : vectors.entrySet()) { + Object normVal = normsMap.get(ve.getKey()); + if (normVal instanceof Number n) { + double norm = n.doubleValue(); + double[] vec = ve.getValue(); + for (int i = 0; i < vec.length; i++) { + vec[i] *= norm; + } + } + } + } + info.setVectors(vectors != null ? vectors : Map.of()); + + // Sparses + @SuppressWarnings("unchecked") + Map sparsesRaw = (Map) obj[4]; + Map sparses = new LinkedHashMap<>(); + if (sparsesRaw != null) { + for (Map.Entry se : sparsesRaw.entrySet()) { + sparses.put( + se.getKey(), + new SparseData((int[]) se.getValue()[0], (double[]) se.getValue()[1])); + } + } + info.setSparses(sparses); + + // Multi-vectors — reconstruct originals using norms + @SuppressWarnings("unchecked") + Map multiVecs = (Map) obj[5]; + if (normsMap != null && multiVecs != null) { + for (Map.Entry mve : multiVecs.entrySet()) { + Object normVal = normsMap.get(mve.getKey()); + if (normVal instanceof List normsList) { + double[][] vecs = mve.getValue(); + for (int i = 0; i < vecs.length && i < normsList.size(); i++) { + double n = ((Number) normsList.get(i)).doubleValue(); + for (int j = 0; j < vecs[i].length; j++) { + vecs[i][j] *= n; + } + } + } + } + } + info.setMultiVectors(multiVecs != null ? multiVecs : Map.of()); + + results.add(info); + } + + return results; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to get objects", e); + } + } + + // ── delete ────────────────────────────────────────────────────────────────── + + /** Deletes a single object by ID. */ + public Map deleteObject(String id) { + try { + HttpRequest request = + buildDeleteRequest("/collection/" + name + "/objects/" + id); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to delete object", e); + } + } + + /** Deletes objects matching a filter. */ + public Map deleteByFilter(List> filter) { + if (filter == null || filter.isEmpty()) { + throw new IllegalArgumentException( + "filter must be a non-empty array, e.g. [{'field': {'$op': value}}]"); + } + + try { + String jsonBody = JsonUtils.toJson(Map.of("filter", filter)); + HttpRequest request = + buildDeleteJsonRequest("/collection/" + name + "/objects", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to delete by filter", e); + } + } + + // ── update filters ────────────────────────────────────────────────────────── + + /** Updates filter tags on existing objects. */ + public Map updateFilters(List updates) { + if (updates == null || updates.isEmpty()) { + throw new IllegalArgumentException("updates must be a non-empty list"); + } + + List> payload = new ArrayList<>(); + for (UpdateFilterParams update : updates) { + Map entry = new HashMap<>(); + entry.put("id", update.getId()); + entry.put("filter", update.getFilter() != null ? update.getFilter() : Map.of()); + payload.add(entry); + } + + try { + String jsonBody = JsonUtils.toJson(Map.of("updates", payload)); + HttpRequest request = + buildPostJsonRequest("/collection/" + name + "/filters", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to update filters", e); + } + } + + // ── describe ──────────────────────────────────────────────────────────────── + + /** Fetches collection metadata from the server. */ + public Map describe() { + try { + HttpRequest request = buildGetRequest("/collection/" + name); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + + // Update local fields metadata + @SuppressWarnings("unchecked") + List> updatedFields = + (List>) result.getOrDefault("fields", List.of()); + this.fields = updatedFields; + + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to describe collection", e); + } + } + + // ── rebuild ───────────────────────────────────────────────────────────────── + + /** + * Rebuilds HNSW graphs for one or more fields (async). + * + * @param fieldSpecs list of field specs, e.g. [{"field": "embedding", "M": 20, "ef_con": 200}] + */ + public Map rebuild(List> fieldSpecs) { + if (fieldSpecs == null || fieldSpecs.isEmpty()) { + throw new IllegalArgumentException("rebuild requires a non-empty list of field specs"); + } + for (Map spec : fieldSpecs) { + if (!spec.containsKey("field") || spec.get("field") == null) { + throw new IllegalArgumentException("Each field spec must include a 'field' name"); + } + } + + try { + String jsonBody = JsonUtils.toJson(Map.of("fields", fieldSpecs)); + HttpRequest request = buildPostJsonRequest("/collection/" + name + "/rebuild", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 && response.statusCode() != 202) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to rebuild collection", e); + } + } + + /** Returns the current rebuild status. */ + public Map rebuildStatus() { + try { + HttpRequest request = buildGetRequest("/collection/" + name + "/rebuild/status"); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to get rebuild status", e); + } + } + + // ── maintenance ───────────────────────────────────────────────────────────── + + /** Defragments the collection's storage in place. */ + public Map shrink() { + try { + HttpRequest request = + buildPostJsonRequest("/collection/" + name + "/shrink", "{}"); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to shrink collection", e); + } + } + + /** Creates a backup of this collection (async). */ + public Map createBackup(String backupName) { + if (backupName == null || backupName.isEmpty()) { + throw new IllegalArgumentException("backup name is required"); + } + + try { + String jsonBody = JsonUtils.toJson(Map.of("name", backupName)); + HttpRequest request = + buildPostJsonRequest("/collection/" + name + "/backup", jsonBody); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 + && response.statusCode() != 201 + && response.statusCode() != 202) { + EndeeApiException.raiseException(response.statusCode(), response.body()); + } + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(response.body(), Map.class); + return result; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new EndeeException("Failed to create backup", e); + } + } + + // ── vector normalization helpers ──────────────────────────────────────────── + + private static double[] normalizeDense(double[] vector, String spaceType) { + if (!"cosine".equals(spaceType)) { + return vector; + } + double norm = computeNorm(vector); + if (norm < 1e-10) { + return vector; + } + double[] normalized = new double[vector.length]; + for (int i = 0; i < vector.length; i++) { + normalized[i] = vector[i] / norm; + } + return normalized; + } + + private static double computeNorm(double[] vector) { + double sumSquares = 0; + for (double v : vector) { + sumSquares += v * v; + } + return Math.sqrt(sumSquares); + } + + private static void validateVectorValues(double[] vector, String objectId) { + for (double v : vector) { + if (Double.isNaN(v) || Double.isInfinite(v)) { + throw new IllegalArgumentException( + "Object '" + objectId + "' contains non-finite vector value (NaN or Inf)"); + } + } + } + + // ── HTTP helpers ──────────────────────────────────────────────────────────── + + private HttpRequest buildGetRequest(String path) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .timeout(DEFAULT_TIMEOUT) + .GET(); + + if (token != null && !token.isBlank()) { + builder.header("Authorization", token); + } + return builder.build(); + } + + private HttpRequest buildPostJsonRequest(String path, String jsonBody) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .timeout(DEFAULT_TIMEOUT) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + + if (token != null && !token.isBlank()) { + builder.header("Authorization", token); + } + return builder.build(); + } + + private HttpRequest buildPostMsgpackRequest(String path, byte[] body) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/msgpack") + .timeout(DEFAULT_TIMEOUT) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)); + + if (token != null && !token.isBlank()) { + builder.header("Authorization", token); + } + return builder.build(); + } + + private HttpRequest buildDeleteRequest(String path) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(DEFAULT_TIMEOUT) + .DELETE(); + + if (token != null && !token.isBlank()) { + builder.header("Authorization", token); + } + return builder.build(); + } + + private HttpRequest buildDeleteJsonRequest(String path, String jsonBody) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .timeout(DEFAULT_TIMEOUT) + .method("DELETE", HttpRequest.BodyPublishers.ofString(jsonBody)); + + if (token != null && !token.isBlank()) { + builder.header("Authorization", token); + } + return builder.build(); + } +} diff --git a/src/main/java/io/endee/client/Endee.java b/src/main/java/io/endee/client/Endee.java index 9129141..9a78ba6 100644 --- a/src/main/java/io/endee/client/Endee.java +++ b/src/main/java/io/endee/client/Endee.java @@ -1,61 +1,55 @@ package io.endee.client; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.endee.client.exception.EndeeApiException; import io.endee.client.exception.EndeeException; -import io.endee.client.types.CreateIndexOptions; -import io.endee.client.types.IndexInfo; -import io.endee.client.types.Precision; -import io.endee.client.types.SpaceType; -import io.endee.client.util.JsonUtils; import io.endee.client.util.ValidationUtils; import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Main Endee client for Endee-DB. + * Main Endee client for the Endee vector database (v2 Collections API). * *

Example usage: * *

{@code
- * Endee client = new Endee("auth-token");
+ * Endee client = new Endee("db_name:secret:region");
  *
- * // Create an index
- * CreateIndexOptions options = CreateIndexOptions.builder("my_index", 128)
- *         .spaceType(SpaceType.COSINE)
- *         .precision(Precision.INT8)
- *         .build();
- * client.createIndex(options);
+ * // Create a collection
+ * client.createCollection("my_docs", List.of(
+ *     Map.of("name", "embedding", "type", "vector",
+ *            "params", Map.of("dimension", 768, "space_type", "cosine", "precision", "int8")),
+ *     Map.of("name", "keywords", "type", "sparse", "sparse_model", "default")
+ * ));
  *
- * // Get an index and perform operations
- * Index index = client.getIndex("my_index");
+ * // Get a collection
+ * Collection collection = client.getCollection("my_docs");
  * }
*/ public class Endee { private static final Logger logger = LoggerFactory.getLogger(Endee.class); private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); - private static final int MAX_DIMENSION = 8000; - private static final int MIN_DIMENSION = 2; - private static final List VALID_SPARSE_MODELS = List.of("default", "endee_bm25"); + private static final Set VALID_DB_TYPES = + Set.of("starter", "pro", "scale", "enterprise"); + private static final Set VALID_TOKEN_TYPES = Set.of("rw", "r"); private String token; private String baseUrl; - private final int version; private final HttpClient httpClient; private final ObjectMapper objectMapper; - /** - * Creates a new Endee client without authentication. Uses local server at - * http://127.0.0.1:8080/api/v1 - */ + /** Creates a new Endee client without authentication. Uses local server. */ public Endee() { this(null); } @@ -63,19 +57,18 @@ public Endee() { /** * Creates a new Endee client. * - * @param token the Auth token (optional). Format: {@code "account:password"} or {@code - * "account:password:region"} + * @param token the auth token. Format: {@code "db_name:secret"} or {@code + * "db_name:secret:region"} */ public Endee(String token) { this.token = token; - this.baseUrl = "http://127.0.0.1:8080/api/v1"; - this.version = 1; + this.baseUrl = "http://127.0.0.1:8080/api/v2"; this.objectMapper = new ObjectMapper(); if (token != null && !token.isEmpty()) { String[] tokenParts = token.split(":"); if (tokenParts.length > 2) { - this.baseUrl = "https://" + tokenParts[2] + ".endee.io/api/v1"; + this.baseUrl = "https://" + tokenParts[2] + ".endee.io/api/v2"; this.token = tokenParts[0] + ":" + tokenParts[1]; } } @@ -87,220 +80,455 @@ public Endee(String token) { .build(); } - /** - * Sets a custom base URL for the API. - * - * @param url the base URL - * @return the URL that was set - */ - public String setBaseUrl(String url) { + /** Sets a custom base URL for the API. */ + public void setBaseUrl(String url) { this.baseUrl = url; - return url; } + /** Sets the authentication token. */ + public void setToken(String token) { + this.token = token; + } + + // ── Collection API ────────────────────────────────────────────────────────── + /** - * Creates a new index. + * Creates a new collection with typed fields. * - * @param options the index creation options - * @return success message - * @throws EndeeException if the operation fails + * @param name collection name + * @param fields list of field definitions as maps + * @return server response */ - public String createIndex(CreateIndexOptions options) { - if (!ValidationUtils.isValidIndexName(options.getName())) { + public Map createCollection(String name, List> fields) { + if (!ValidationUtils.isValidCollectionName(name)) { throw new IllegalArgumentException( - "Invalid index name. Must be alphanumeric with underscores, max 48 characters."); + "Invalid collection name. Must be alphanumeric with underscores, max 48 chars, no '__' prefix."); } - if (options.getDimension() < MIN_DIMENSION || options.getDimension() > MAX_DIMENSION) { - throw new IllegalArgumentException( - "Dimension must be between " + MIN_DIMENSION + " and " + MAX_DIMENSION); + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException("At least one field is required"); } - String normalizedSpaceType = options.getSpaceType().getValue().toLowerCase(); - if (!List.of("cosine", "l2", "ip").contains(normalizedSpaceType)) { - throw new IllegalArgumentException("Invalid space type: " + options.getSpaceType()); - } + Map data = new LinkedHashMap<>(); + data.put("name", name); + data.put("fields", fields); - String sparseModel = options.getSparseModel(); - if (sparseModel != null) { - String normalized = sparseModel.toLowerCase(); - if (!VALID_SPARSE_MODELS.contains(normalized)) { - throw new IllegalArgumentException( - "Invalid sparseModel. Must be one of: " + VALID_SPARSE_MODELS); - } - sparseModel = normalized; - } + return call("POST", "/collection", data, Set.of(200, 201)); + } - Map data = new HashMap<>(); - data.put("index_name", options.getName()); - data.put("dim", options.getDimension()); - data.put("space_type", normalizedSpaceType); - data.put("M", options.getM()); - data.put("ef_con", options.getEfCon()); - data.put("checksum", -1); - data.put("precision", options.getPrecision().getValue()); - - if (sparseModel != null) { - data.put("sparse_model", sparseModel); - } - if (options.getVersion() != null) { - data.put("version", options.getVersion()); - } + /** Lists all collections. */ + @SuppressWarnings("unchecked") + public List> listCollections() { + Map result = call("GET", "/collection", null, Set.of(200)); + Object collections = result.get("collections"); + return collections instanceof List ? (List>) collections : List.of(); + } - try { - HttpRequest request = buildPostRequest("/index/create", data); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + /** + * Gets a Collection object for performing operations. + * + * @param name collection name + * @return Collection object + */ + public Collection getCollection(String name) { + Map metadata = call("GET", "/collection/" + name, null, Set.of(200)); + return new Collection(name, token, baseUrl, metadata); + } - if (response.statusCode() != 200) { - logger.error("Error: {}", response.body()); - EndeeApiException.raiseException(response.statusCode(), response.body()); - } + /** Deletes a collection and all its data. */ + public Map deleteCollection(String name) { + return call("DELETE", "/collection/" + name, null, Set.of(200)); + } - return "Index created successfully"; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to create index", e); - } + // ── Database Admin (root token) ───────────────────────────────────────────── + + /** Creates a database. Returns the new db token string. */ + public String createDatabase(String dbName, String dbType) { + requireNonEmpty(dbName, "db_name"); + String dt = dbType != null ? dbType.toLowerCase() : "enterprise"; + validateIn(dt, VALID_DB_TYPES, "db_type"); + Map result = + call("POST", "/admin/dbs", Map.of("db_name", dbName, "db_type", dt), Set.of(200, 201)); + return (String) result.get("db_token"); + } + + /** Creates a database with default type "enterprise". */ + public String createDatabase(String dbName) { + return createDatabase(dbName, "enterprise"); + } + + /** Lists all databases. */ + @SuppressWarnings("unchecked") + public List> listDatabases() { + Map result = call("GET", "/admin/dbs", null, Set.of(200)); + Object dbs = result.get("dbs"); + return dbs instanceof List ? (List>) dbs : List.of(); + } + + /** Gets a single database's info. */ + public Map getDatabase(String dbName) { + requireNonEmpty(dbName, "db_name"); + return call("GET", "/dbs/" + dbName + "/info", null, Set.of(200)); + } + + /** Deletes a database and all its data. */ + public Map deleteDatabase(String dbName) { + requireNonEmpty(dbName, "db_name"); + return call("DELETE", "/admin/dbs/" + dbName, null, Set.of(200)); + } + + /** Activates a previously deactivated database. */ + public Map activateDatabase(String dbName) { + return call("POST", "/admin/dbs/" + dbName + "/activate", null, Set.of(200)); + } + + /** Deactivates a database. */ + public Map deactivateDatabase(String dbName) { + return call("POST", "/admin/dbs/" + dbName + "/deactivate", null, Set.of(200)); + } + + /** Changes a database's tier. */ + public Map setDatabaseType(String dbName, String dbType) { + String dt = dbType.toLowerCase(); + validateIn(dt, VALID_DB_TYPES, "db_type"); + return call("PUT", "/admin/dbs/" + dbName + "/type", Map.of("db_type", dt), Set.of(200)); + } + + // ── Admin collection views ────────────────────────────────────────────────── + + /** Lists collections in a specific database. */ + @SuppressWarnings("unchecked") + public List> listDbCollections(String dbName) { + requireNonEmpty(dbName, "db_name"); + Map result = + call("GET", "/admin/dbs/" + dbName + "/collection", null, Set.of(200)); + Object c = result.get("collections"); + return c instanceof List ? (List>) c : List.of(); + } + + /** Lists all collections across all databases. */ + @SuppressWarnings("unchecked") + public List> listAllCollections() { + Map result = call("GET", "/admin/collection", null, Set.of(200)); + Object c = result.get("collections"); + return c instanceof List ? (List>) c : List.of(); + } + + /** Deletes a collection inside a specific database. */ + public Map deleteDbCollection(String dbName, String collectionName) { + requireNonEmpty(dbName, "db_name"); + requireNonEmpty(collectionName, "collection_name"); + return call( + "DELETE", "/admin/dbs/" + dbName + "/collection/" + collectionName, null, Set.of(200)); + } + + // ── Token management (admin) ──────────────────────────────────────────────── + + /** Creates a token for a database. Returns the new db token string. */ + public String createToken(String dbName, String name, String tokenType) { + requireNonEmpty(dbName, "db_name"); + requireNonEmpty(name, "name"); + String tt = tokenType != null ? tokenType.toLowerCase() : "rw"; + validateIn(tt, VALID_TOKEN_TYPES, "token_type"); + Map result = + call( + "POST", + "/admin/dbs/" + dbName + "/tokens", + Map.of("name", name, "token_type", tt), + Set.of(200, 201)); + return (String) result.get("db_token"); + } + + /** Creates a read-write token. */ + public String createToken(String dbName, String name) { + return createToken(dbName, name, "rw"); + } + + /** Lists a database's tokens. */ + @SuppressWarnings("unchecked") + public List> listTokens(String dbName) { + requireNonEmpty(dbName, "db_name"); + Map result = + call("GET", "/admin/dbs/" + dbName + "/tokens", null, Set.of(200)); + Object t = result.get("tokens"); + return t instanceof List ? (List>) t : List.of(); + } + + /** Deletes a database token by name. */ + public Map deleteToken(String dbName, String name) { + requireNonEmpty(dbName, "db_name"); + requireNonEmpty(name, "name"); + return call("DELETE", "/admin/dbs/" + dbName + "/tokens/" + name, null, Set.of(200)); + } + + // ── Self-service token management ─────────────────────────────────────────── + + /** Creates a token for your own database. Returns the new db token string. */ + public String createMyToken(String name, String tokenType) { + requireNonEmpty(name, "name"); + String tt = tokenType != null ? tokenType.toLowerCase() : "rw"; + validateIn(tt, VALID_TOKEN_TYPES, "token_type"); + Map result = + call("POST", "/tokens", Map.of("name", name, "token_type", tt), Set.of(200, 201)); + return (String) result.get("db_token"); + } + + /** Creates a read-write token for your own database. */ + public String createMyToken(String name) { + return createMyToken(name, "rw"); + } + + /** Lists your own database's tokens. */ + @SuppressWarnings("unchecked") + public List> listMyTokens() { + Map result = call("GET", "/tokens", null, Set.of(200)); + Object t = result.get("tokens"); + return t instanceof List ? (List>) t : List.of(); + } + + /** Deletes one of your own database's tokens by name. */ + public Map deleteMyToken(String name) { + requireNonEmpty(name, "name"); + return call("DELETE", "/tokens/" + name, null, Set.of(200)); + } + + // ── Server info ───────────────────────────────────────────────────────────── + + /** Server health check. Returns {status, timestamp}. */ + public Map health() { + return call("GET", "/health", null, Set.of(200)); + } + + /** Server stats. Returns {version, uptime, total_requests}. */ + public Map stats() { + return call("GET", "/stats", null, Set.of(200)); + } + + // ── Backups ───────────────────────────────────────────────────────────────── + + /** Lists this database's backups. */ + public Object listBackups() { + return call("GET", "/backup", null, Set.of(200)); + } + + /** Gets metadata for one backup. */ + public Map backupInfo(String backupName) { + requireNonEmpty(backupName, "backup_name"); + return call("GET", "/backup/" + backupName + "/info", null, Set.of(200)); + } + + /** Gets the in-progress backup status. */ + public Map activeBackup() { + return call("GET", "/backup/active", null, Set.of(200)); + } + + /** Restores a backup into a new collection. */ + public Map restoreBackup(String backupName, String targetCollectionName) { + requireNonEmpty(backupName, "backup_name"); + requireNonEmpty(targetCollectionName, "target_collection_name"); + return call( + "POST", + "/backup/" + backupName + "/restore", + Map.of("target_collection_name", targetCollectionName), + Set.of(200, 201)); + } + + /** Deletes a backup. */ + public Map deleteBackup(String backupName) { + requireNonEmpty(backupName, "backup_name"); + return call("DELETE", "/backup/" + backupName, null, Set.of(200, 204)); } /** - * Lists all indexes. + * Downloads a backup as a .tar file. * - * @return raw JSON string of index information - * @throws EndeeException if the operation fails + * @param backupName name of the backup + * @param destPath local file path to write the .tar to + * @param dbName optional database name (for root-token multi-db targeting) + * @return the destination path */ - public String listIndexes() { - try { - HttpRequest request = buildGetRequest("/index/list"); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + public String downloadBackup(String backupName, String destPath, String dbName) { + requireNonEmpty(backupName, "backup_name"); + requireNonEmpty(destPath, "dest_path"); + + StringBuilder url = new StringBuilder(baseUrl) + .append("/backup/") + .append(backupName) + .append("/download?token=") + .append(URLEncoder.encode(token != null ? token : "", StandardCharsets.UTF_8)); + if (dbName != null && !dbName.isEmpty()) { + url.append("&db=").append(URLEncoder.encode(dbName, StandardCharsets.UTF_8)); + } - return response.body(); - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url.toString())) + .timeout(DEFAULT_TIMEOUT) + .GET() + .build(); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + EndeeApiException.raiseException(response.statusCode(), new String(response.body())); } - throw new EndeeException("Failed to list indexes", e); + Files.write(Path.of(destPath), response.body()); + return destPath; + } catch (EndeeException e) { + throw e; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + throw new EndeeException("Download backup failed", e); } } + /** Downloads a backup (no db_name). */ + public String downloadBackup(String backupName, String destPath) { + return downloadBackup(backupName, destPath, null); + } + /** - * Deletes an index. + * Uploads a backup .tar file via multipart. * - * @param name the index name to delete - * @return success message - * @throws EndeeException if the operation fails + * @param filePath path to a .tar backup file + * @return server response */ - public String deleteIndex(String name) { + @SuppressWarnings("unchecked") + public Map uploadBackup(String filePath) { + requireNonEmpty(filePath, "file_path"); + Path path = Path.of(filePath); + String fileName = path.getFileName().toString(); + if (!fileName.endsWith(".tar")) { + throw new IllegalArgumentException("backup file must be a .tar"); + } + try { - HttpRequest request = buildDeleteRequest("/index/" + name + "/delete"); + byte[] fileBytes = Files.readAllBytes(path); + String boundary = "----EndeeBackupBoundary" + System.nanoTime(); + + byte[] multipartBody = buildMultipartBody(boundary, "backup", fileName, fileBytes); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/backup/upload")) + .timeout(DEFAULT_TIMEOUT) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody)); + + if (token != null && !token.isEmpty()) { + builder.header("Authorization", token); + } + HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - logger.error("Error: {}", response.body()); + if (response.statusCode() != 200 && response.statusCode() != 201) { EndeeApiException.raiseException(response.statusCode(), response.body()); } - return "Index " + name + " deleted successfully"; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); + String body = response.body(); + if (body == null || body.isBlank()) return Map.of(); + try { + return objectMapper.readValue(body, Map.class); + } catch (Exception e) { + return Map.of("message", body); } - throw new EndeeException("Failed to delete index", e); + } catch (EndeeException e) { + throw e; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + throw new EndeeException("Upload backup failed", e); } } - /** - * Gets an index by name. - * - * @param name the index name - * @return the Index object for performing vector operations - * @throws EndeeException if the operation fails - */ - public Index getIndex(String name) { + private static byte[] buildMultipartBody( + String boundary, String fieldName, String fileName, byte[] fileBytes) throws IOException { + String CRLF = "\r\n"; + var baos = new java.io.ByteArrayOutputStream(); + baos.write(("--" + boundary + CRLF).getBytes(StandardCharsets.UTF_8)); + baos.write(("Content-Disposition: form-data; name=\"" + fieldName + "\"; filename=\"" + + fileName + "\"" + CRLF).getBytes(StandardCharsets.UTF_8)); + baos.write(("Content-Type: application/x-tar" + CRLF).getBytes(StandardCharsets.UTF_8)); + baos.write(CRLF.getBytes(StandardCharsets.UTF_8)); + baos.write(fileBytes); + baos.write((CRLF + "--" + boundary + "--" + CRLF).getBytes(StandardCharsets.UTF_8)); + return baos.toByteArray(); + } + + // ── Internal HTTP helpers ─────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private Map call( + String method, String path, Map json, Set okStatuses) { try { - HttpRequest request = buildGetRequest("/index/" + name + "/info"); + HttpRequest request = buildRequest(method, path, json); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { + if (!okStatuses.contains(response.statusCode())) { + logger.error("Error: {}", response.body()); EndeeApiException.raiseException(response.statusCode(), response.body()); } - JsonNode data = objectMapper.readTree(response.body()); - - IndexInfo indexInfo = new IndexInfo(); - indexInfo.setSpaceType(SpaceType.fromValue(data.get("space_type").asText())); - indexInfo.setDimension(data.get("dimension").asInt()); - indexInfo.setTotalElements(data.get("total_elements").asLong()); - indexInfo.setPrecision(Precision.fromValue(data.get("precision").asText())); - indexInfo.setM(data.get("M").asInt()); - indexInfo.setEfCon(data.get("ef_con").asInt()); - - if (data.has("checksum") && !data.get("checksum").isNull()) { - indexInfo.setChecksum(data.get("checksum").asLong()); + String body = response.body(); + if (body == null || body.isBlank()) { + return Map.of(); } - if (data.has("version") && !data.get("version").isNull()) { - indexInfo.setVersion(data.get("version").asInt()); - } - if (data.has("sparse_model") && !data.get("sparse_model").isNull()) { - indexInfo.setSparseModel(data.get("sparse_model").asText()); - } - if (data.has("lib_token") && !data.get("lib_token").isNull()) { - indexInfo.setLibToken(data.get("lib_token").asText()); - } - - return new Index(name, token, baseUrl, version, indexInfo); + return objectMapper.readValue(body, Map.class); + } catch (EndeeException e) { + throw e; } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - throw new EndeeException("Failed to get index", e); + throw new EndeeException("Request failed: " + method + " " + path, e); } } - private HttpRequest buildGetRequest(String path) { + private HttpRequest buildRequest(String method, String path, Map json) { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(baseUrl + path)) - .header("Content-Type", "application/json") - .timeout(DEFAULT_TIMEOUT) - .GET(); + .timeout(DEFAULT_TIMEOUT); if (token != null && !token.isEmpty()) { builder.header("Authorization", token); } - return builder.build(); - } - - private HttpRequest buildPostRequest(String path, Map data) { - String json = JsonUtils.toJson(data); - HttpRequest.Builder builder = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl + path)) - .header("Content-Type", "application/json") - .timeout(DEFAULT_TIMEOUT) - .POST(HttpRequest.BodyPublishers.ofString(json)); - - if (token != null && !token.isEmpty()) { - builder.header("Authorization", token); + if (json != null) { + String jsonBody; + try { + jsonBody = objectMapper.writeValueAsString(json); + } catch (Exception e) { + throw new EndeeException("Failed to serialize request body", e); + } + builder.header("Content-Type", "application/json"); + builder.method(method, HttpRequest.BodyPublishers.ofString(jsonBody)); + } else { + switch (method) { + case "GET" -> builder.GET(); + case "DELETE" -> builder.DELETE(); + case "POST" -> + builder + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.noBody()); + case "PUT" -> + builder + .header("Content-Type", "application/json") + .PUT(HttpRequest.BodyPublishers.noBody()); + default -> builder.method(method, HttpRequest.BodyPublishers.noBody()); + } } return builder.build(); } - private HttpRequest buildDeleteRequest(String path) { - HttpRequest.Builder builder = - HttpRequest.newBuilder().uri(URI.create(baseUrl + path)).timeout(DEFAULT_TIMEOUT).DELETE(); - - if (token != null && !token.isEmpty()) { - builder.header("Authorization", token); + private static void requireNonEmpty(String value, String name) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " is required"); } + } - return builder.build(); + private static void validateIn(String value, Set valid, String name) { + if (!valid.contains(value)) { + throw new IllegalArgumentException(name + " must be one of " + valid); + } } } diff --git a/src/main/java/io/endee/client/Index.java b/src/main/java/io/endee/client/Index.java deleted file mode 100644 index e74c7fd..0000000 --- a/src/main/java/io/endee/client/Index.java +++ /dev/null @@ -1,711 +0,0 @@ -package io.endee.client; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.endee.client.exception.EndeeApiException; -import io.endee.client.exception.EndeeException; -import io.endee.client.types.*; -import io.endee.client.util.CryptoUtils; -import io.endee.client.util.JsonUtils; -import io.endee.client.util.MessagePackUtils; -import io.endee.client.util.ValidationUtils; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.*; -import java.util.stream.Collectors; - -/** - * Index client for Endee-DB vector operations. - * - *

Example usage: - * - *

{@code
- * Index index = client.getIndex("my_index");
- *
- * // Upsert vectors
- * List vectors = List.of(
- *         VectorItem.builder("vec1", new double[] { 0.1, 0.2, 0.3 })
- *                 .meta(Map.of("label", "example"))
- *                 .build());
- * index.upsert(vectors);
- *
- * // Query
- * List results = index.query(
- *         QueryOptions.builder()
- *                 .vector(new double[] { 0.1, 0.2, 0.3 })
- *                 .topK(10)
- *                 .build());
- * }
- */ -public class Index { - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); - private static final int MAX_BATCH_SIZE = 1000; - private static final int MAX_TOP_K = 4096; - private static final int MAX_EF = 1024; - private static final int MAX_FILTER_BOOST_PERCENTAGE = 400; - - private final String name; - private final String token; - private final String url; - private final HttpClient httpClient; - private final ObjectMapper objectMapper; - - private long count; - private SpaceType spaceType; - private int dimension; - private Precision precision; - private int m; - private String sparseModel; - private String libToken; - private int efCon; - - /** Creates a new Index instance. */ - public Index(String name, String token, String url, int version, IndexInfo params) { - this.name = name; - this.token = token; - this.url = url; - this.objectMapper = new ObjectMapper(); - - this.count = params != null ? params.getTotalElements() : 0; - this.spaceType = - params != null && params.getSpaceType() != null ? params.getSpaceType() : SpaceType.COSINE; - this.dimension = params != null ? params.getDimension() : 0; - this.precision = - params != null && params.getPrecision() != null ? params.getPrecision() : Precision.INT8; - this.m = params != null ? params.getM() : 16; - this.sparseModel = params != null ? params.getSparseModel() : "None"; - this.libToken = params != null ? params.getLibToken() : null; - this.efCon = params != null ? params.getEfCon() : 128; - - this.httpClient = - HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .connectTimeout(DEFAULT_TIMEOUT) - .build(); - } - - @Override - public String toString() { - return name; - } - - public String getLibToken() { - return libToken; - } - - /** - * Returns {@code true} when this index supports hybrid (sparse + dense) vectors. Determined by - * {@code sparse_model != "None"} from the server response. - */ - public boolean isHybrid() { - return sparseModel != null && !"None".equals(sparseModel); - } - - /** Normalizes a vector for cosine similarity. Returns [normalizedVector, norm]. */ - private double[][] normalizeVector(double[] vector) { - if (vector.length != dimension) { - throw new IllegalArgumentException( - "Vector dimension mismatch: expected " + dimension + ", got " + vector.length); - } - - if (spaceType != SpaceType.COSINE) { - return new double[][] {vector, {1.0}}; - } - - double sumSquares = 0; - for (double v : vector) { - sumSquares += v * v; - } - double norm = Math.sqrt(sumSquares); - - if (norm == 0) { - return new double[][] {vector, {1.0}}; - } - - double[] normalized = new double[vector.length]; - for (int i = 0; i < vector.length; i++) { - normalized[i] = vector[i] / norm; - } - - return new double[][] {normalized, {norm}}; - } - - /** Validates that a vector contains only finite values (no NaN or Inf). */ - private static void validateVectorValues(double[] vector, String vectorId) { - for (double v : vector) { - if (Double.isNaN(v) || Double.isInfinite(v)) { - throw new IllegalArgumentException( - "Vector '" + vectorId + "' contains non-finite value (NaN or Inf)"); - } - } - } - - /** - * Upserts vectors into the index. - * - * @param inputArray list of vector items to upsert (1 – 1,000 items) - * @return success message - */ - public String upsert(List inputArray) { - if (inputArray.isEmpty()) { - throw new IllegalArgumentException("Must provide at least one vector to upsert"); - } - if (inputArray.size() > MAX_BATCH_SIZE) { - throw new IllegalArgumentException( - "Cannot insert more than " + MAX_BATCH_SIZE + " vectors at a time"); - } - - List ids = - inputArray.stream() - .map(item -> item.getId() != null ? item.getId() : "") - .collect(Collectors.toList()); - ValidationUtils.validateVectorIds(ids); - - List vectorBatch = new ArrayList<>(); - - for (VectorItem item : inputArray) { - validateVectorValues(item.getVector(), item.getId()); - double[][] result = normalizeVector(item.getVector()); - double[] normalizedVector = result[0]; - double norm = result[1][0]; - - byte[] metaData = CryptoUtils.jsonZip(item.getMeta() != null ? item.getMeta() : Map.of()); - - int[] sparseIndices = item.getSparseIndices() != null ? item.getSparseIndices() : new int[0]; - double[] sparseValues = - item.getSparseValues() != null ? item.getSparseValues() : new double[0]; - - if (!isHybrid() && (sparseIndices.length > 0 || sparseValues.length > 0)) { - throw new IllegalArgumentException( - "Cannot insert sparse data into a dense-only index. Use sparseModel(\"default\") when creating the index."); - } - - if (isHybrid()) { - if (sparseIndices.length == 0 || sparseValues.length == 0) { - throw new IllegalArgumentException( - "Both sparse_indices and sparse_values must be provided for hybrid vectors."); - } - if (sparseIndices.length != sparseValues.length) { - throw new IllegalArgumentException( - "sparseIndices and sparseValues must have the same length. Got " - + sparseIndices.length - + " indices and " - + sparseValues.length - + " values."); - } - } - - String filterJson = JsonUtils.toJson(item.getFilter() != null ? item.getFilter() : Map.of()); - - if (isHybrid()) { - vectorBatch.add( - new Object[] { - item.getId(), - metaData, - filterJson, - norm, - normalizedVector, - sparseIndices, - sparseValues - }); - } else { - vectorBatch.add(new Object[] {item.getId(), metaData, filterJson, norm, normalizedVector}); - } - } - - byte[] serializedData = MessagePackUtils.packVectors(vectorBatch); - - try { - HttpRequest request = - buildPostMsgpackRequest("/index/" + name + "/vector/insert", serializedData); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), new String(response.body())); - } - - return "Vectors inserted successfully"; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to upsert vectors", e); - } - } - - /** - * Queries the index for similar vectors. - * - * @param options the query options - * @return list of query results - */ - public List query(QueryOptions options) { - if (options.getTopK() < 1 || options.getTopK() > MAX_TOP_K) { - throw new IllegalArgumentException("top_k must be between 1 and " + MAX_TOP_K); - } - if (options.getEf() > MAX_EF) { - throw new IllegalArgumentException("ef cannot be greater than " + MAX_EF); - } - if (options.getPrefilterCardinalityThreshold() < 1_000 - || options.getPrefilterCardinalityThreshold() > 1_000_000) { - throw new IllegalArgumentException( - "prefilterCardinalityThreshold must be between 1,000 and 1,000,000"); - } - if (options.getFilterBoostPercentage() < 0 - || options.getFilterBoostPercentage() > MAX_FILTER_BOOST_PERCENTAGE) { - throw new IllegalArgumentException( - "filterBoostPercentage must be between 0 and " + MAX_FILTER_BOOST_PERCENTAGE); - } - if (options.getDenseRrfWeight() < 0.0 || options.getDenseRrfWeight() > 1.0) { - throw new IllegalArgumentException("denseRrfWeight must be between 0.0 and 1.0"); - } - if (options.getRrfRankConstant() < 1) { - throw new IllegalArgumentException("rrfRankConstant must be at least 1"); - } - - boolean hasSparse = - options.getSparseIndices() != null - && options.getSparseIndices().length > 0 - && options.getSparseValues() != null - && options.getSparseValues().length > 0; - boolean hasDense = options.getVector() != null; - - if (!hasDense && !hasSparse) { - throw new IllegalArgumentException( - "At least one of 'vector' or 'sparseIndices'/'sparseValues' must be provided."); - } - - if (hasSparse && !isHybrid()) { - throw new IllegalArgumentException("Cannot perform sparse search on a dense-only index."); - } - - if (hasSparse && options.getSparseIndices().length != options.getSparseValues().length) { - throw new IllegalArgumentException( - "sparseIndices and sparseValues must have the same length."); - } - - Map data = new HashMap<>(); - data.put("k", options.getTopK()); - data.put("ef", options.getEf()); - data.put("include_vectors", options.isIncludeVectors()); - - if (hasDense) { - double[][] result = normalizeVector(options.getVector()); - data.put("vector", result[0]); - } - - if (hasSparse) { - data.put("sparse_indices", options.getSparseIndices()); - data.put("sparse_values", options.getSparseValues()); - } - - if (options.getFilter() != null) { - data.put("filter", JsonUtils.toJson(options.getFilter())); - } - - Map filterParams = new HashMap<>(); - filterParams.put("prefilter_threshold", options.getPrefilterCardinalityThreshold()); - filterParams.put("boost_percentage", options.getFilterBoostPercentage()); - data.put("filter_params", filterParams); - - data.put("dense_rrf_weight", options.getDenseRrfWeight()); - data.put("rrf_rank_constant", options.getRrfRankConstant()); - - try { - String jsonBody = JsonUtils.toJson(data); - HttpRequest request = buildPostJsonRequest("/index/" + name + "/search", jsonBody); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), new String(response.body())); - } - - List decoded = MessagePackUtils.unpackQueryResults(response.body()); - List results = new ArrayList<>(); - - for (Object[] tuple : decoded) { - double similarity = (Double) tuple[0]; - String vectorId = (String) tuple[1]; - byte[] metaData = (byte[]) tuple[2]; - String filterStr = (String) tuple[3]; - double normValue = (Double) tuple[4]; - - Map meta = CryptoUtils.jsonUnzip(metaData); - - QueryResult result = new QueryResult(); - result.setId(vectorId); - result.setSimilarity(similarity); - result.setDistance(1 - similarity); - result.setMeta(meta); - result.setNorm(normValue); - result.setVector(new double[0]); - - if (filterStr != null && !filterStr.isEmpty() && !filterStr.equals("{}")) { - @SuppressWarnings("unchecked") - Map parsedFilter = JsonUtils.fromJson(filterStr, Map.class); - result.setFilter(parsedFilter); - } - - if (options.isIncludeVectors() && tuple.length > 5) { - result.setVector((double[]) tuple[5]); - } - - results.add(result); - } - - return results; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to query index", e); - } - } - - /** - * Updates the filter fields of existing vectors without re-upserting them. - * - * @param updates list of filter updates, each containing an id and the new filter object - * @return server response text - */ - public String updateFilters(List updates) { - List ids = updates.stream().map(UpdateFilterParams::getId).collect(Collectors.toList()); - ValidationUtils.validateVectorIds(ids); - - List> payload = new ArrayList<>(); - for (UpdateFilterParams update : updates) { - Map entry = new HashMap<>(); - entry.put("id", update.getId()); - entry.put("filter", update.getFilter() != null ? update.getFilter() : Map.of()); - payload.add(entry); - } - - try { - String jsonBody = JsonUtils.toJson(Map.of("updates", payload)); - HttpRequest request = buildPostJsonRequest("/index/" + name + "/filters/update", jsonBody); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - return response.body(); - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to update filters", e); - } - } - - /** - * Deletes a vector by ID. - * - * @param id the vector ID to delete - * @return deletion count message (e.g. {@code "1 rows deleted"}) - */ - public String deleteVector(String id) { - try { - HttpRequest request = buildDeleteRequest("/index/" + name + "/vector/" + id + "/delete"); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - return response.body() + " rows deleted"; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to delete vector", e); - } - } - - /** - * Deletes vectors matching a filter. - * - * @param filter the filter criteria - * @return the API response - */ - public String deleteWithFilter(List> filter) { - try { - Map data = Map.of("filter", filter); - String jsonBody = JsonUtils.toJson(data); - - HttpRequest request = buildDeleteJsonRequest("/index/" + name + "/vectors/delete", jsonBody); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - return response.body(); - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to delete vectors with filter", e); - } - } - - /** - * Gets a vector by ID. - * - * @param id the vector ID - * @return the vector information including sparse fields for hybrid indexes - */ - public VectorInfo getVector(String id) { - try { - Map data = Map.of("id", id); - String jsonBody = JsonUtils.toJson(data); - - HttpRequest request = buildPostJsonRequest("/index/" + name + "/vector/get", jsonBody); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), new String(response.body())); - } - - Object[] vectorObj = MessagePackUtils.unpackVector(response.body()); - - VectorInfo info = new VectorInfo(); - info.setId((String) vectorObj[0]); - info.setMeta(CryptoUtils.jsonUnzip((byte[]) vectorObj[1])); - - String filterStr = (String) vectorObj[2]; - if (filterStr != null && !filterStr.isEmpty() && !filterStr.equals("{}")) { - @SuppressWarnings("unchecked") - Map parsedFilter = JsonUtils.fromJson(filterStr, Map.class); - info.setFilter(parsedFilter); - } - - info.setNorm((Double) vectorObj[3]); - info.setVector((double[]) vectorObj[4]); - - if (vectorObj.length > 5) { - info.setSparseIndices((int[]) vectorObj[5]); - info.setSparseValues((double[]) vectorObj[6]); - } - - return info; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to get vector", e); - } - } - - /** - * Returns a description of this index without making a network call. - * - * @return the index description - */ - public IndexDescription describe() { - return new IndexDescription( - name, spaceType, dimension, sparseModel, isHybrid(), count, precision, m, efCon); - } - - /** - * Triggers an index rebuild with new HNSW parameters. - * - * @param m HNSW M parameter (bi-directional links per node), must be > 0 - * @param efCon HNSW ef_construction parameter, must be > 0 - * @return rebuild status dict with {@code status}, {@code previous_config}, {@code new_config}, - * {@code total_vectors} - */ - public Map rebuild(int m, int efCon) { - if (m <= 0) { - throw new IllegalArgumentException("M must be greater than 0"); - } - if (efCon <= 0) { - throw new IllegalArgumentException("ef_con must be greater than 0"); - } - - refreshMetadata(); - if (count == 0) { - throw new IllegalStateException("Cannot rebuild an empty index"); - } - - try { - String jsonBody = JsonUtils.toJson(Map.of("M", m, "ef_con", efCon)); - HttpRequest request = buildPostJsonRequest("/index/" + name + "/rebuild", jsonBody); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 202) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - @SuppressWarnings("unchecked") - Map result = objectMapper.readValue(response.body(), Map.class); - return result; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to rebuild index", e); - } - } - - /** - * Returns the current rebuild status of this index. - * - * @return dict with {@code status}, and optionally {@code vectors_processed}, {@code - * total_vectors}, {@code percent_complete} - */ - public Map rebuildStatus() { - try { - HttpRequest request = buildGetRequest("/index/" + name + "/rebuild/status"); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - @SuppressWarnings("unchecked") - Map result = objectMapper.readValue(response.body(), Map.class); - return result; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to get rebuild status", e); - } - } - - /** - * Fetches the latest metadata from the server and updates this Index object's fields. - * - * @return dict with current {@code count}, {@code space_type}, {@code dimension}, {@code - * precision}, {@code M}, {@code ef_con}, {@code sparse_model}, {@code is_hybrid} - */ - public Map refreshMetadata() { - try { - HttpRequest request = buildGetRequest("/index/" + name + "/info"); - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - EndeeApiException.raiseException(response.statusCode(), response.body()); - } - - JsonNode data = objectMapper.readTree(response.body()); - - this.count = data.get("total_elements").asLong(); - this.spaceType = SpaceType.fromValue(data.get("space_type").asText()); - this.dimension = data.get("dimension").asInt(); - this.precision = Precision.fromValue(data.get("precision").asText()); - this.m = data.get("M").asInt(); - this.efCon = data.get("ef_con").asInt(); - - if (data.has("sparse_model") && !data.get("sparse_model").isNull()) { - this.sparseModel = data.get("sparse_model").asText(); - } - if (data.has("lib_token") && !data.get("lib_token").isNull()) { - this.libToken = data.get("lib_token").asText(); - } - - Map result = new HashMap<>(); - result.put("count", this.count); - result.put("space_type", this.spaceType.getValue()); - result.put("dimension", this.dimension); - result.put("precision", this.precision.getValue()); - result.put("M", this.m); - result.put("ef_con", this.efCon); - result.put("sparse_model", this.sparseModel); - result.put("is_hybrid", isHybrid()); - return result; - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - throw new EndeeException("Failed to refresh metadata", e); - } - } - - // ==================== HTTP Request Helpers ==================== - - private HttpRequest buildGetRequest(String path) { - HttpRequest.Builder builder = - HttpRequest.newBuilder() - .uri(URI.create(url + path)) - .header("Content-Type", "application/json") - .timeout(DEFAULT_TIMEOUT) - .GET(); - - if (token != null && !token.isBlank()) { - builder.header("Authorization", token); - } - - return builder.build(); - } - - private HttpRequest buildPostJsonRequest(String path, String jsonBody) { - HttpRequest.Builder builder = - HttpRequest.newBuilder() - .uri(URI.create(url + path)) - .header("Content-Type", "application/json") - .timeout(DEFAULT_TIMEOUT) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); - - if (token != null && !token.isBlank()) { - builder.header("Authorization", token); - } - - return builder.build(); - } - - private HttpRequest buildPostMsgpackRequest(String path, byte[] body) { - HttpRequest.Builder builder = - HttpRequest.newBuilder() - .uri(URI.create(url + path)) - .header("Content-Type", "application/msgpack") - .timeout(DEFAULT_TIMEOUT) - .POST(HttpRequest.BodyPublishers.ofByteArray(body)); - - if (token != null && !token.isBlank()) { - builder.header("Authorization", token); - } - - return builder.build(); - } - - private HttpRequest buildDeleteRequest(String path) { - HttpRequest.Builder builder = - HttpRequest.newBuilder().uri(URI.create(url + path)).timeout(DEFAULT_TIMEOUT).DELETE(); - - if (token != null && !token.isBlank()) { - builder.header("Authorization", token); - } - - return builder.build(); - } - - private HttpRequest buildDeleteJsonRequest(String path, String jsonBody) { - HttpRequest.Builder builder = - HttpRequest.newBuilder() - .uri(URI.create(url + path)) - .header("Content-Type", "application/json") - .timeout(DEFAULT_TIMEOUT) - .method("DELETE", HttpRequest.BodyPublishers.ofString(jsonBody)); - - if (token != null && !token.isBlank()) { - builder.header("Authorization", token); - } - - return builder.build(); - } -} diff --git a/src/main/java/io/endee/client/Reranker.java b/src/main/java/io/endee/client/Reranker.java new file mode 100644 index 0000000..fc32c03 --- /dev/null +++ b/src/main/java/io/endee/client/Reranker.java @@ -0,0 +1,114 @@ +package io.endee.client; + +import io.endee.client.types.SearchHit; +import java.util.*; + +/** + * Client-side reranking utilities for fusing per-field search results. + * + *

Example usage: + * + *

{@code
+ * Map> results = collection.search(queryFields);
+ * List fused = Reranker.rerank(results, 10,
+ *     Map.of("embedding", 0.6, "keywords", 0.4), 60);
+ * }
+ */ +public final class Reranker { + + private static final int DEFAULT_LIMIT = 10; + private static final int DEFAULT_RRF_K = 60; + + private Reranker() {} + + /** + * Fuses per-field search results using Reciprocal Rank Fusion (RRF). + * + * @param searchResults per-field results from {@link Collection#search} + * @param limit max number of fused hits to return + * @param fieldWeights per-field weights (must sum to 1.0); null for uniform + * @param rrfK RRF rank constant (default 60) + * @return fused and sorted list of hits + */ + public static List rerank( + Map> searchResults, + int limit, + Map fieldWeights, + int rrfK) { + + if (searchResults == null || searchResults.isEmpty()) { + throw new IllegalArgumentException("searchResults must be a non-empty per-field map"); + } + + List fieldNames = new ArrayList<>(searchResults.keySet()); + + // Resolve weights + Map weights; + if (fieldWeights == null) { + weights = new LinkedHashMap<>(); + double uniform = 1.0 / fieldNames.size(); + for (String f : fieldNames) { + weights.put(f, uniform); + } + } else { + for (String f : fieldNames) { + if (!fieldWeights.containsKey(f)) { + throw new IllegalArgumentException("field_weights missing entry for: " + f); + } + } + double total = 0; + for (String f : fieldNames) { + total += fieldWeights.get(f); + } + if (Math.abs(total - 1.0) > 1e-6) { + throw new IllegalArgumentException( + "field_weights must sum to 1.0 (got " + String.format("%.8f", total) + ")"); + } + weights = fieldWeights; + } + + // Compute RRF scores + Map scores = new LinkedHashMap<>(); + Map hitById = new LinkedHashMap<>(); + + for (String fname : fieldNames) { + double weight = weights.getOrDefault(fname, 0.0); + List hits = searchResults.getOrDefault(fname, List.of()); + int rank = 1; + for (SearchHit hit : hits) { + String hid = hit.getId(); + scores.merge(hid, weight / (rrfK + rank), Double::sum); + hitById.putIfAbsent(hid, hit); + rank++; + } + } + + // Sort by score descending, take top limit + List> ranked = new ArrayList<>(scores.entrySet()); + ranked.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); + + List results = new ArrayList<>(); + for (int i = 0; i < Math.min(limit, ranked.size()); i++) { + Map.Entry entry = ranked.get(i); + SearchHit original = hitById.get(entry.getKey()); + SearchHit fused = + new SearchHit( + original.getId(), entry.getValue(), original.getMeta(), original.getFilter()); + results.add(fused); + } + + return results; + } + + /** Convenience: rerank with default limit (10) and rrfK (60). */ + public static List rerank( + Map> searchResults, Map fieldWeights) { + return rerank(searchResults, DEFAULT_LIMIT, fieldWeights, DEFAULT_RRF_K); + } + + /** Convenience: rerank with uniform weights. */ + public static List rerank( + Map> searchResults, int limit) { + return rerank(searchResults, limit, null, DEFAULT_RRF_K); + } +} diff --git a/src/main/java/io/endee/client/types/CreateIndexOptions.java b/src/main/java/io/endee/client/types/CreateIndexOptions.java deleted file mode 100644 index 311651d..0000000 --- a/src/main/java/io/endee/client/types/CreateIndexOptions.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.endee.client.types; - -/** Options for creating an Endee index. */ -public class CreateIndexOptions { - private final String name; - private final int dimension; - private SpaceType spaceType = SpaceType.COSINE; - private int m = 16; - private int efCon = 128; - private Precision precision = Precision.INT8; - private Integer version = null; - private String sparseModel = null; - - private CreateIndexOptions(String name, int dimension) { - this.name = name; - this.dimension = dimension; - } - - public static Builder builder(String name, int dimension) { - return new Builder(name, dimension); - } - - public String getName() { - return name; - } - - public int getDimension() { - return dimension; - } - - public SpaceType getSpaceType() { - return spaceType; - } - - public int getM() { - return m; - } - - public int getEfCon() { - return efCon; - } - - public Precision getPrecision() { - return precision; - } - - public Integer getVersion() { - return version; - } - - public String getSparseModel() { - return sparseModel; - } - - public static class Builder { - private final CreateIndexOptions options; - - private Builder(String name, int dimension) { - this.options = new CreateIndexOptions(name, dimension); - } - - public Builder spaceType(SpaceType spaceType) { - options.spaceType = spaceType; - return this; - } - - public Builder m(int m) { - options.m = m; - return this; - } - - public Builder efCon(int efCon) { - options.efCon = efCon; - return this; - } - - public Builder precision(Precision precision) { - options.precision = precision; - return this; - } - - public Builder version(Integer version) { - options.version = version; - return this; - } - - /** - * Sets the sparse model for hybrid indexing. - * - * @param sparseModel {@code "default"} for standard sparse search, {@code "endee_bm25"} for - * BM25 scoring. Pass {@code null} for a dense-only index. - */ - public Builder sparseModel(String sparseModel) { - options.sparseModel = sparseModel; - return this; - } - - public CreateIndexOptions build() { - return options; - } - } -} diff --git a/src/main/java/io/endee/client/types/IndexDescription.java b/src/main/java/io/endee/client/types/IndexDescription.java deleted file mode 100644 index 94732de..0000000 --- a/src/main/java/io/endee/client/types/IndexDescription.java +++ /dev/null @@ -1,94 +0,0 @@ -package io.endee.client.types; - -/** Description of an Endee index, returned by {@link io.endee.client.Index#describe()}. */ -public class IndexDescription { - private final String name; - private final SpaceType spaceType; - private final int dimension; - private final String sparseModel; - private final boolean isHybrid; - private final long count; - private final Precision precision; - private final int m; - private final int efCon; - - public IndexDescription( - String name, - SpaceType spaceType, - int dimension, - String sparseModel, - boolean isHybrid, - long count, - Precision precision, - int m, - int efCon) { - this.name = name; - this.spaceType = spaceType; - this.dimension = dimension; - this.sparseModel = sparseModel; - this.isHybrid = isHybrid; - this.count = count; - this.precision = precision; - this.m = m; - this.efCon = efCon; - } - - public String getName() { - return name; - } - - public SpaceType getSpaceType() { - return spaceType; - } - - public int getDimension() { - return dimension; - } - - public String getSparseModel() { - return sparseModel; - } - - public boolean isHybrid() { - return isHybrid; - } - - public long getCount() { - return count; - } - - public Precision getPrecision() { - return precision; - } - - public int getM() { - return m; - } - - public int getEfCon() { - return efCon; - } - - @Override - public String toString() { - return "{name='" - + name - + "', spaceType=" - + spaceType - + ", dimension=" - + dimension - + ", precision=" - + precision - + ", count=" - + count - + ", isHybrid=" - + isHybrid - + ", sparseModel='" - + sparseModel - + "', M=" - + m - + ", efCon=" - + efCon - + "}"; - } -} diff --git a/src/main/java/io/endee/client/types/IndexInfo.java b/src/main/java/io/endee/client/types/IndexInfo.java deleted file mode 100644 index 54c9eaf..0000000 --- a/src/main/java/io/endee/client/types/IndexInfo.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.endee.client.types; - -/** Information about an Endee index from the server. */ -public class IndexInfo { - private String name; - private SpaceType spaceType; - private int dimension; - private long totalElements; - private Precision precision; - private int m; - private long checksum; - private int version; - private String sparseModel; - private String libToken; - private int efCon; - - public IndexInfo() {} - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public SpaceType getSpaceType() { - return spaceType; - } - - public void setSpaceType(SpaceType spaceType) { - this.spaceType = spaceType; - } - - public int getDimension() { - return dimension; - } - - public void setDimension(int dimension) { - this.dimension = dimension; - } - - public long getTotalElements() { - return totalElements; - } - - public void setTotalElements(long totalElements) { - this.totalElements = totalElements; - } - - public Precision getPrecision() { - return precision; - } - - public void setPrecision(Precision precision) { - this.precision = precision; - } - - public int getM() { - return m; - } - - public void setM(int m) { - this.m = m; - } - - public long getChecksum() { - return checksum; - } - - public void setChecksum(long checksum) { - this.checksum = checksum; - } - - public int getVersion() { - return version; - } - - public void setVersion(int version) { - this.version = version; - } - - public String getSparseModel() { - return sparseModel; - } - - public void setSparseModel(String sparseModel) { - this.sparseModel = sparseModel; - } - - public String getLibToken() { - return libToken; - } - - public void setLibToken(String libToken) { - this.libToken = libToken; - } - - public int getEfCon() { - return efCon; - } - - public void setEfCon(int efCon) { - this.efCon = efCon; - } -} diff --git a/src/main/java/io/endee/client/types/ObjectInfo.java b/src/main/java/io/endee/client/types/ObjectInfo.java new file mode 100644 index 0000000..458e9c6 --- /dev/null +++ b/src/main/java/io/endee/client/types/ObjectInfo.java @@ -0,0 +1,76 @@ +package io.endee.client.types; + +import java.util.Map; + +/** Full object retrieved from a collection via {@code getObjects}. */ +public class ObjectInfo { + private String id; + private Map meta; + private Map filter; + private Map vectors; + private Map sparses; + private Map multiVectors; + + public ObjectInfo() {} + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getMeta() { + return meta; + } + + public void setMeta(Map meta) { + this.meta = meta; + } + + public Map getFilter() { + return filter; + } + + public void setFilter(Map filter) { + this.filter = filter; + } + + public Map getVectors() { + return vectors; + } + + public void setVectors(Map vectors) { + this.vectors = vectors; + } + + public Map getSparses() { + return sparses; + } + + public void setSparses(Map sparses) { + this.sparses = sparses; + } + + public Map getMultiVectors() { + return multiVectors; + } + + public void setMultiVectors(Map multiVectors) { + this.multiVectors = multiVectors; + } + + @Override + public String toString() { + return "ObjectInfo{id='" + + id + + "', vectors=" + + (vectors != null ? vectors.keySet() : "[]") + + ", sparses=" + + (sparses != null ? sparses.keySet() : "[]") + + ", multiVectors=" + + (multiVectors != null ? multiVectors.keySet() : "[]") + + "}"; + } +} diff --git a/src/main/java/io/endee/client/types/ObjectItem.java b/src/main/java/io/endee/client/types/ObjectItem.java new file mode 100644 index 0000000..1863337 --- /dev/null +++ b/src/main/java/io/endee/client/types/ObjectItem.java @@ -0,0 +1,87 @@ +package io.endee.client.types; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * An object to upsert into a collection. + * + *

Field values in the {@code fields} map must be one of: + * + *

    + *
  • {@code double[]} — dense vector field + *
  • {@link SparseData} — sparse vector field + *
  • {@code double[][]} — multi-vector field + *
+ */ +public class ObjectItem { + private final String id; + private Map meta; + private Map filter; + private Map fields; + + private ObjectItem(String id) { + this.id = id; + } + + public static Builder builder(String id) { + return new Builder(id); + } + + public String getId() { + return id; + } + + public Map getMeta() { + return meta; + } + + public Map getFilter() { + return filter; + } + + public Map getFields() { + return fields; + } + + public static class Builder { + private final ObjectItem item; + + private Builder(String id) { + this.item = new ObjectItem(id); + this.item.fields = new LinkedHashMap<>(); + } + + public Builder meta(Map meta) { + item.meta = meta; + return this; + } + + public Builder filter(Map filter) { + item.filter = filter; + return this; + } + + /** Set a dense vector field. */ + public Builder vector(String fieldName, double[] vector) { + item.fields.put(fieldName, vector); + return this; + } + + /** Set a sparse vector field. */ + public Builder sparse(String fieldName, SparseData sparse) { + item.fields.put(fieldName, sparse); + return this; + } + + /** Set a multi-vector field. */ + public Builder multiVector(String fieldName, double[][] vectors) { + item.fields.put(fieldName, vectors); + return this; + } + + public ObjectItem build() { + return item; + } + } +} diff --git a/src/main/java/io/endee/client/types/Precision.java b/src/main/java/io/endee/client/types/Precision.java index 7178b71..f6f7aa1 100644 --- a/src/main/java/io/endee/client/types/Precision.java +++ b/src/main/java/io/endee/client/types/Precision.java @@ -6,7 +6,8 @@ public enum Precision { INT8("int8"), INT16("int16"), FLOAT32("float32"), - FLOAT16("float16"); + FLOAT16("float16"), + INT8E("int8e"); private final String value; diff --git a/src/main/java/io/endee/client/types/QueryOptions.java b/src/main/java/io/endee/client/types/QueryOptions.java deleted file mode 100644 index c79e5c8..0000000 --- a/src/main/java/io/endee/client/types/QueryOptions.java +++ /dev/null @@ -1,163 +0,0 @@ -package io.endee.client.types; - -import java.util.List; -import java.util.Map; - -/** - * Options for querying an Endee index. - * - *

Example usage with filters: - * - *

{@code
- * QueryOptions options = QueryOptions.builder()
- *     .vector(new double[]{0.1, 0.2, 0.3})
- *     .topK(10)
- *     .filter(List.of(
- *         Map.of("category", Map.of("$eq", "tech")),
- *         Map.of("score", Map.of("$range", List.of(80, 100)))
- *     ))
- *     .build();
- * }
- */ -public class QueryOptions { - private double[] vector; - private int topK = 10; - private List> filter; - private int ef = 128; - private boolean includeVectors = false; - private int[] sparseIndices; - private double[] sparseValues; - private int prefilterCardinalityThreshold = 10_000; - private int filterBoostPercentage = 0; - private double denseRrfWeight = 0.5; - private int rrfRankConstant = 60; - - private QueryOptions() {} - - public static Builder builder() { - return new Builder(); - } - - public double[] getVector() { - return vector; - } - - public int getTopK() { - return topK; - } - - public List> getFilter() { - return filter; - } - - public int getEf() { - return ef; - } - - public boolean isIncludeVectors() { - return includeVectors; - } - - public int[] getSparseIndices() { - return sparseIndices; - } - - public double[] getSparseValues() { - return sparseValues; - } - - public int getPrefilterCardinalityThreshold() { - return prefilterCardinalityThreshold; - } - - public int getFilterBoostPercentage() { - return filterBoostPercentage; - } - - public double getDenseRrfWeight() { - return denseRrfWeight; - } - - public int getRrfRankConstant() { - return rrfRankConstant; - } - - public static class Builder { - private final QueryOptions options = new QueryOptions(); - - public Builder vector(double[] vector) { - options.vector = vector; - return this; - } - - public Builder topK(int topK) { - options.topK = topK; - return this; - } - - /** - * Sets the filter conditions as an array of filter objects. - * - * @param filter list of filter conditions, e.g.: [{"category": {"$eq": "tech"}}, {"score": - * {"$range": [80, 100]}}] - */ - public Builder filter(List> filter) { - options.filter = filter; - return this; - } - - public Builder ef(int ef) { - options.ef = ef; - return this; - } - - public Builder includeVectors(boolean includeVectors) { - options.includeVectors = includeVectors; - return this; - } - - public Builder sparseIndices(int[] sparseIndices) { - options.sparseIndices = sparseIndices; - return this; - } - - public Builder sparseValues(double[] sparseValues) { - options.sparseValues = sparseValues; - return this; - } - - /** - * Switches from HNSW to brute-force when estimated matching vectors exceeds this value. Range: - * 1,000 – 1,000,000. Default: 10,000. - */ - public Builder prefilterCardinalityThreshold(int prefilterCardinalityThreshold) { - options.prefilterCardinalityThreshold = prefilterCardinalityThreshold; - return this; - } - - /** - * Expands the HNSW candidate pool by this percentage to bias results toward filter matches. - * Range: 0 – 400. Default: 0. - */ - public Builder filterBoostPercentage(int filterBoostPercentage) { - options.filterBoostPercentage = filterBoostPercentage; - return this; - } - - /** RRF weight for the dense component in hybrid search. Range: 0.0 – 1.0. Default: 0.5. */ - public Builder denseRrfWeight(double denseRrfWeight) { - options.denseRrfWeight = denseRrfWeight; - return this; - } - - /** RRF rank constant used in hybrid search scoring. Minimum: 1. Default: 60. */ - public Builder rrfRankConstant(int rrfRankConstant) { - options.rrfRankConstant = rrfRankConstant; - return this; - } - - public QueryOptions build() { - return options; - } - } -} diff --git a/src/main/java/io/endee/client/types/QueryResult.java b/src/main/java/io/endee/client/types/QueryResult.java deleted file mode 100644 index 4f5bcab..0000000 --- a/src/main/java/io/endee/client/types/QueryResult.java +++ /dev/null @@ -1,83 +0,0 @@ -package io.endee.client.types; - -import java.util.Arrays; -import java.util.Map; - -/** Result from a query operation. */ -public class QueryResult { - private String id; - private double similarity; - private double distance; - private Map meta; - private double norm; - private Map filter; - private double[] vector; - - public QueryResult() {} - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public double getSimilarity() { - return similarity; - } - - public void setSimilarity(double similarity) { - this.similarity = similarity; - } - - public double getDistance() { - return distance; - } - - public void setDistance(double distance) { - this.distance = distance; - } - - public Map getMeta() { - return meta; - } - - public void setMeta(Map meta) { - this.meta = meta; - } - - public double getNorm() { - return norm; - } - - public void setNorm(double norm) { - this.norm = norm; - } - - public Map getFilter() { - return filter; - } - - public void setFilter(Map filter) { - this.filter = filter; - } - - public double[] getVector() { - return vector; - } - - public void setVector(double[] vector) { - this.vector = vector; - } - - @Override - public String toString() { - String result = - "QueryResult{id='" + id + "', similarity=" + similarity + ", distance=" + distance; - if (vector != null) { - result += ", vector=" + Arrays.toString(vector); - } - return result + "}"; - } -} diff --git a/src/main/java/io/endee/client/types/SearchHit.java b/src/main/java/io/endee/client/types/SearchHit.java new file mode 100644 index 0000000..2d68179 --- /dev/null +++ b/src/main/java/io/endee/client/types/SearchHit.java @@ -0,0 +1,57 @@ +package io.endee.client.types; + +import java.util.Map; + +/** A single hit from a search result. */ +public class SearchHit { + private String id; + private double similarity; + private Map meta; + private Map filter; + + public SearchHit() {} + + public SearchHit(String id, double similarity, Map meta, Map filter) { + this.id = id; + this.similarity = similarity; + this.meta = meta; + this.filter = filter; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public double getSimilarity() { + return similarity; + } + + public void setSimilarity(double similarity) { + this.similarity = similarity; + } + + public Map getMeta() { + return meta; + } + + public void setMeta(Map meta) { + this.meta = meta; + } + + public Map getFilter() { + return filter; + } + + public void setFilter(Map filter) { + this.filter = filter; + } + + @Override + public String toString() { + return "SearchHit{id='" + id + "', similarity=" + similarity + "}"; + } +} diff --git a/src/main/java/io/endee/client/types/SparseData.java b/src/main/java/io/endee/client/types/SparseData.java new file mode 100644 index 0000000..6cc2312 --- /dev/null +++ b/src/main/java/io/endee/client/types/SparseData.java @@ -0,0 +1,31 @@ +package io.endee.client.types; + +/** Holder for sparse vector data (indices + values). */ +public class SparseData { + private final int[] indices; + private final double[] values; + + public SparseData(int[] indices, double[] values) { + if (indices == null || values == null) { + throw new IllegalArgumentException("indices and values must not be null"); + } + if (indices.length != values.length) { + throw new IllegalArgumentException( + "indices and values must have the same length (" + + indices.length + + " vs " + + values.length + + ")"); + } + this.indices = indices; + this.values = values; + } + + public int[] getIndices() { + return indices; + } + + public double[] getValues() { + return values; + } +} diff --git a/src/main/java/io/endee/client/types/VectorInfo.java b/src/main/java/io/endee/client/types/VectorInfo.java deleted file mode 100644 index bafad1a..0000000 --- a/src/main/java/io/endee/client/types/VectorInfo.java +++ /dev/null @@ -1,85 +0,0 @@ -package io.endee.client.types; - -import java.util.Map; - -/** Information about a vector retrieved from an index. */ -public class VectorInfo { - private String id; - private Map meta; - private Map filter; - private double norm; - private double[] vector; - private int[] sparseIndices; - private double[] sparseValues; - - public VectorInfo() {} - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public Map getMeta() { - return meta; - } - - public void setMeta(Map meta) { - this.meta = meta; - } - - public Map getFilter() { - return filter; - } - - public void setFilter(Map filter) { - this.filter = filter; - } - - public double getNorm() { - return norm; - } - - public void setNorm(double norm) { - this.norm = norm; - } - - public double[] getVector() { - return vector; - } - - public void setVector(double[] vector) { - this.vector = vector; - } - - public int[] getSparseIndices() { - return sparseIndices; - } - - public void setSparseIndices(int[] sparseIndices) { - this.sparseIndices = sparseIndices; - } - - public double[] getSparseValues() { - return sparseValues; - } - - public void setSparseValues(double[] sparseValues) { - this.sparseValues = sparseValues; - } - - @Override - public String toString() { - return "VectorInfo{id='" - + id - + "', norm=" - + norm - + ", vectorLength=" - + (vector != null ? vector.length : 0) - + ", isHybrid=" - + (sparseIndices != null) - + "}"; - } -} diff --git a/src/main/java/io/endee/client/types/VectorItem.java b/src/main/java/io/endee/client/types/VectorItem.java deleted file mode 100644 index e5a9734..0000000 --- a/src/main/java/io/endee/client/types/VectorItem.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.endee.client.types; - -import java.util.Map; - -/** A vector item for upsert operations. */ -public class VectorItem { - private final String id; - private final double[] vector; - private Map meta; - private Map filter; - private int[] sparseIndices; - private double[] sparseValues; - - private VectorItem(String id, double[] vector) { - this.id = id; - this.vector = vector; - } - - public static Builder builder(String id, double[] vector) { - return new Builder(id, vector); - } - - public String getId() { - return id; - } - - public double[] getVector() { - return vector; - } - - public Map getMeta() { - return meta; - } - - public Map getFilter() { - return filter; - } - - public int[] getSparseIndices() { - return sparseIndices; - } - - public double[] getSparseValues() { - return sparseValues; - } - - public static class Builder { - private final VectorItem item; - - private Builder(String id, double[] vector) { - this.item = new VectorItem(id, vector); - } - - public Builder meta(Map meta) { - item.meta = meta; - return this; - } - - public Builder filter(Map filter) { - item.filter = filter; - return this; - } - - public Builder sparseIndices(int[] sparseIndices) { - item.sparseIndices = sparseIndices; - return this; - } - - public Builder sparseValues(double[] sparseValues) { - item.sparseValues = sparseValues; - return this; - } - - public VectorItem build() { - return item; - } - } -} diff --git a/src/main/java/io/endee/client/util/MessagePackUtils.java b/src/main/java/io/endee/client/util/MessagePackUtils.java index fb8febf..f942b20 100644 --- a/src/main/java/io/endee/client/util/MessagePackUtils.java +++ b/src/main/java/io/endee/client/util/MessagePackUtils.java @@ -2,166 +2,315 @@ import io.endee.client.exception.EndeeException; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import org.msgpack.core.MessageBufferPacker; import org.msgpack.core.MessagePack; import org.msgpack.core.MessageUnpacker; import org.msgpack.value.Value; +import org.msgpack.value.ValueType; -/** MessagePack serialization utilities. */ +/** + * MessagePack serialization utilities for the v2 wire format. + * + *

Upsert wire: {@code ObjectBatch = [objects]} where each object is {@code [id, meta, filter, + * vectors_map, sparses_map, multi_vectors_map]}. + * + *

Search response: {@code [objects_map, results_map]} where objects_map is {@code {int_id: + * [str_id, meta_bytes, filter_str]}} and results_map is {@code {field_name: [[int_id, score], + * ...]}}. + * + *

Get objects response: {@code ObjectBatch = [objects]} where each object is {@code [id, meta, + * filter, vectors_map, sparses_map, multi_vectors_map]}. + */ public final class MessagePackUtils { private MessagePackUtils() {} - /** Packs vector data for upsert operations using single-precision floats. */ - public static byte[] packVectors(List vectors) { + // ── Upsert packing ────────────────────────────────────────────────────────── + + /** + * Packs objects for upsert. Each tuple: [id, meta_bytes, filter_str, vectors_map, sparses_map, + * multi_vectors_map]. + * + * @param objects list of 6-element arrays + */ + @SuppressWarnings("unchecked") + public static byte[] packObjects(List objects) { try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) { - packer.packArrayHeader(vectors.size()); + // ObjectBatch = [objects] — single-element outer array + packer.packArrayHeader(1); + packer.packArrayHeader(objects.size()); + + for (Object[] obj : objects) { + packer.packArrayHeader(6); + + // [0] id (string) + packer.packString((String) obj[0]); + + // [1] meta (bytes) + byte[] meta = (byte[]) obj[1]; + packer.packBinaryHeader(meta.length); + packer.writePayload(meta); + + // [2] filter (string) + packer.packString((String) obj[2]); + + // [3] vectors map {field_name: [float, ...]} + Map vectors = (Map) obj[3]; + packDenseVectorsMap(packer, vectors); + + // [4] sparses map {field_name: [indices, values]} + Map sparses = (Map) obj[4]; + packSparsesMap(packer, sparses); - for (Object[] vector : vectors) { - packVectorTuple(packer, vector); + // [5] multi_vectors map {field_name: [[float, ...], ...]} + Map multiVectors = (Map) obj[5]; + packMultiVectorsMap(packer, multiVectors); } return packer.toByteArray(); } catch (IOException e) { - throw new EndeeException("Failed to pack vectors", e); + throw new EndeeException("Failed to pack objects", e); } } - private static void packVectorTuple(MessageBufferPacker packer, Object[] vector) + private static void packDenseVectorsMap(MessageBufferPacker packer, Map vectors) throws IOException { - packer.packArrayHeader(vector.length); - - // id (string) - packer.packString((String) vector[0]); - - // metadata (bytes) - byte[] meta = (byte[]) vector[1]; - packer.packBinaryHeader(meta.length); - packer.writePayload(meta); - - // filter (string) - packer.packString((String) vector[2]); - - // norm (single-precision float) - packer.packFloat((float) (double) (Double) vector[3]); - - // vector (single-precision floats) - double[] vec = (double[]) vector[4]; - packer.packArrayHeader(vec.length); - for (double v : vec) { - packer.packFloat((float) v); + if (vectors == null || vectors.isEmpty()) { + packer.packMapHeader(0); + return; } + packer.packMapHeader(vectors.size()); + for (Map.Entry entry : vectors.entrySet()) { + packer.packString(entry.getKey()); + double[] vec = entry.getValue(); + packer.packArrayHeader(vec.length); + for (double v : vec) { + packer.packFloat((float) v); + } + } + } - // sparse data (hybrid only) - if (vector.length > 5) { - int[] sparseIndices = (int[]) vector[5]; - packer.packArrayHeader(sparseIndices.length); - for (int idx : sparseIndices) { + private static void packSparsesMap(MessageBufferPacker packer, Map sparses) + throws IOException { + if (sparses == null || sparses.isEmpty()) { + packer.packMapHeader(0); + return; + } + packer.packMapHeader(sparses.size()); + for (Map.Entry entry : sparses.entrySet()) { + packer.packString(entry.getKey()); + // Sparse = [indices, values] + int[] indices = (int[]) entry.getValue()[0]; + double[] values = (double[]) entry.getValue()[1]; + packer.packArrayHeader(2); + packer.packArrayHeader(indices.length); + for (int idx : indices) { packer.packInt(idx); } - - double[] sparseValues = (double[]) vector[6]; - packer.packArrayHeader(sparseValues.length); - for (double val : sparseValues) { + packer.packArrayHeader(values.length); + for (double val : values) { packer.packFloat((float) val); } } } - /** Unpacks query results from MessagePack bytes. */ - public static List unpackQueryResults(byte[] data) { - List results = new ArrayList<>(); + private static void packMultiVectorsMap( + MessageBufferPacker packer, Map multiVectors) throws IOException { + if (multiVectors == null || multiVectors.isEmpty()) { + packer.packMapHeader(0); + return; + } + packer.packMapHeader(multiVectors.size()); + for (Map.Entry entry : multiVectors.entrySet()) { + packer.packString(entry.getKey()); + double[][] vecs = entry.getValue(); + packer.packArrayHeader(vecs.length); + for (double[] vec : vecs) { + packer.packArrayHeader(vec.length); + for (double v : vec) { + packer.packFloat((float) v); + } + } + } + } - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data)) { - int arraySize = unpacker.unpackArrayHeader(); + // ── Search response unpacking ──────────────────────────────────────────────── - for (int i = 0; i < arraySize; i++) { - int tupleSize = unpacker.unpackArrayHeader(); - Object[] tuple = new Object[tupleSize]; + /** + * Unpacks a search response: [objects_map, results_map]. + * + * @return [objectsMap, resultsMap] where objectsMap = Map<Integer, Object[]> (int_id → + * [str_id, meta_bytes, filter_str]) and resultsMap = Map<String, List<Object[]>> + * (field_name → [[int_id, score], ...]) + */ + public static Object[] unpackSearchResponse(byte[] data) { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data)) { + int outerSize = unpacker.unpackArrayHeader(); - tuple[0] = unpackNumberAsDouble(unpacker); // similarity - tuple[1] = unpacker.unpackString(); // vectorId + // [0] objects_map: {int_id: [str_id, meta_bytes, filter_str]} + int objectsMapSize = unpacker.unpackMapHeader(); + Map objectsMap = new LinkedHashMap<>(); + for (int i = 0; i < objectsMapSize; i++) { + int intId = unpacker.unpackInt(); + int arrSize = unpacker.unpackArrayHeader(); + String strId = unpacker.unpackString(); int metaLen = unpacker.unpackBinaryHeader(); - tuple[2] = unpacker.readPayload(metaLen); // metadata - tuple[3] = unpacker.unpackString(); // filter - tuple[4] = unpackNumberAsDouble(unpacker); // norm - - if (tupleSize > 5) { - int vecLen = unpacker.unpackArrayHeader(); - double[] vec = new double[vecLen]; - for (int j = 0; j < vecLen; j++) { - vec[j] = unpackNumberAsDouble(unpacker); - } - tuple[5] = vec; + byte[] metaBytes = unpacker.readPayload(metaLen); + String filterStr = arrSize > 2 ? unpacker.unpackString() : ""; + // skip any extra fields + for (int j = 3; j < arrSize; j++) { + unpacker.skipValue(); } + objectsMap.put(intId, new Object[] {strId, metaBytes, filterStr}); + } - results.add(tuple); + // [1] results_map: {field_name: [[int_id, score], ...]} + Map> resultsMap = new LinkedHashMap<>(); + if (outerSize > 1) { + int resultsMapSize = unpacker.unpackMapHeader(); + for (int i = 0; i < resultsMapSize; i++) { + String fieldName = unpacker.unpackString(); + int hitsSize = unpacker.unpackArrayHeader(); + List hits = new ArrayList<>(); + for (int j = 0; j < hitsSize; j++) { + unpacker.unpackArrayHeader(); // 2 + int intId = unpacker.unpackInt(); + double score = unpackNumberAsDouble(unpacker); + hits.add(new Object[] {intId, score}); + } + resultsMap.put(fieldName, hits); + } } + + return new Object[] {objectsMap, resultsMap}; } catch (IOException e) { - throw new EndeeException("Failed to unpack query results", e); + throw new EndeeException("Failed to unpack search response", e); } - - return results; } + // ── Get objects response unpacking ─────────────────────────────────────────── + /** - * Unpacks a single vector from MessagePack bytes. Handles both dense (5-element) and hybrid - * (7-element) tuples. + * Unpacks an ObjectBatch response: [[objects]] where each object is [id, meta, filter, vectors, + * sparses, multi_vectors]. + * + * @return list of 6-element arrays: [id, meta_bytes, filter_str, vectors_map, sparses_map, + * multi_vectors_map] */ - public static Object[] unpackVector(byte[] data) { + public static List unpackObjectBatch(byte[] data) { try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data)) { - int tupleSize = unpacker.unpackArrayHeader(); - Object[] tuple = new Object[tupleSize]; + // ObjectBatch = [objects] + int batchSize = unpacker.unpackArrayHeader(); + if (batchSize == 0) { + return List.of(); + } + int objectsSize = unpacker.unpackArrayHeader(); + List results = new ArrayList<>(); + + for (int i = 0; i < objectsSize; i++) { + int tupleSize = unpacker.unpackArrayHeader(); + Object[] tuple = new Object[6]; + + // [0] id + tuple[0] = unpacker.unpackString(); + + // [1] meta bytes + if (tupleSize > 1) { + int metaLen = unpacker.unpackBinaryHeader(); + tuple[1] = unpacker.readPayload(metaLen); + } else { + tuple[1] = new byte[0]; + } - tuple[0] = unpacker.unpackString(); // id - int metaLen = unpacker.unpackBinaryHeader(); - tuple[1] = unpacker.readPayload(metaLen); // metadata - tuple[2] = unpacker.unpackString(); // filter - tuple[3] = unpackNumberAsDouble(unpacker); // norm + // [2] filter string + tuple[2] = tupleSize > 2 ? unpacker.unpackString() : ""; + // [3] vectors map {field_name: [float, ...]} + tuple[3] = tupleSize > 3 ? unpackDenseVectorsMap(unpacker) : Map.of(); + + // [4] sparses map {field_name: [indices, values]} + tuple[4] = tupleSize > 4 ? unpackSparsesMap(unpacker) : Map.of(); + + // [5] multi_vectors map {field_name: [[float, ...], ...]} + tuple[5] = tupleSize > 5 ? unpackMultiVectorsMap(unpacker) : Map.of(); + + results.add(tuple); + } + + return results; + } catch (IOException e) { + throw new EndeeException("Failed to unpack object batch", e); + } + } + + @SuppressWarnings("unchecked") + private static Map unpackDenseVectorsMap(MessageUnpacker unpacker) + throws IOException { + int mapSize = unpacker.unpackMapHeader(); + Map vectors = new LinkedHashMap<>(); + for (int i = 0; i < mapSize; i++) { + String name = unpacker.unpackString(); int vecLen = unpacker.unpackArrayHeader(); double[] vec = new double[vecLen]; - for (int i = 0; i < vecLen; i++) { - vec[i] = unpackNumberAsDouble(unpacker); + for (int j = 0; j < vecLen; j++) { + vec[j] = unpackNumberAsDouble(unpacker); } - tuple[4] = vec; - - // hybrid: sparse_indices + sparse_values - if (tupleSize > 5) { - int sparseLen = unpacker.unpackArrayHeader(); - int[] sparseIndices = new int[sparseLen]; - for (int i = 0; i < sparseLen; i++) { - sparseIndices[i] = unpacker.unpackInt(); - } - tuple[5] = sparseIndices; + vectors.put(name, vec); + } + return vectors; + } - int sparseValLen = unpacker.unpackArrayHeader(); - double[] sparseValues = new double[sparseValLen]; - for (int i = 0; i < sparseValLen; i++) { - sparseValues[i] = unpackNumberAsDouble(unpacker); - } - tuple[6] = sparseValues; + private static Map unpackSparsesMap(MessageUnpacker unpacker) + throws IOException { + int mapSize = unpacker.unpackMapHeader(); + Map sparses = new LinkedHashMap<>(); + for (int i = 0; i < mapSize; i++) { + String name = unpacker.unpackString(); + unpacker.unpackArrayHeader(); // 2 + int indicesLen = unpacker.unpackArrayHeader(); + int[] indices = new int[indicesLen]; + for (int j = 0; j < indicesLen; j++) { + indices[j] = unpacker.unpackInt(); } + int valuesLen = unpacker.unpackArrayHeader(); + double[] values = new double[valuesLen]; + for (int j = 0; j < valuesLen; j++) { + values[j] = unpackNumberAsDouble(unpacker); + } + sparses.put(name, new Object[] {indices, values}); + } + return sparses; + } - return tuple; - } catch (IOException e) { - throw new EndeeException("Failed to unpack vector", e); + private static Map unpackMultiVectorsMap(MessageUnpacker unpacker) + throws IOException { + int mapSize = unpacker.unpackMapHeader(); + Map multiVectors = new LinkedHashMap<>(); + for (int i = 0; i < mapSize; i++) { + String name = unpacker.unpackString(); + int numVecs = unpacker.unpackArrayHeader(); + double[][] vecs = new double[numVecs][]; + for (int j = 0; j < numVecs; j++) { + int vecLen = unpacker.unpackArrayHeader(); + vecs[j] = new double[vecLen]; + for (int k = 0; k < vecLen; k++) { + vecs[j][k] = unpackNumberAsDouble(unpacker); + } + } + multiVectors.put(name, vecs); } + return multiVectors; } private static double unpackNumberAsDouble(MessageUnpacker unpacker) throws IOException { Value value = unpacker.unpackValue(); - if (value.isFloatValue()) { return value.asFloatValue().toDouble(); } - if (value.isIntegerValue()) { return value.asIntegerValue().toDouble(); } - throw new IllegalStateException( "Expected numeric value (int/float), got " + value.getValueType()); } diff --git a/src/main/java/io/endee/client/util/ValidationUtils.java b/src/main/java/io/endee/client/util/ValidationUtils.java index 7fac7ed..bad4989 100644 --- a/src/main/java/io/endee/client/util/ValidationUtils.java +++ b/src/main/java/io/endee/client/util/ValidationUtils.java @@ -8,30 +8,36 @@ /** Validation utilities. */ public final class ValidationUtils { - private static final Pattern INDEX_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); - private static final int MAX_INDEX_NAME_LENGTH = 48; + private static final Pattern COLLECTION_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); + private static final int MAX_COLLECTION_NAME_LENGTH = 48; private ValidationUtils() {} - /** Validates an index name. Must be alphanumeric with underscores, less than 48 characters. */ - public static boolean isValidIndexName(String name) { + /** + * Validates a collection name. Must be alphanumeric with underscores, max 48 characters, and must + * not start with "__". + */ + public static boolean isValidCollectionName(String name) { if (name == null || name.isEmpty()) { return false; } - if (name.length() > MAX_INDEX_NAME_LENGTH) { + if (name.length() > MAX_COLLECTION_NAME_LENGTH) { return false; } - return INDEX_NAME_PATTERN.matcher(name).matches(); + if (name.startsWith("__")) { + return false; + } + return COLLECTION_NAME_PATTERN.matcher(name).matches(); } - /** Validates that all vector IDs are non-empty and unique. */ - public static void validateVectorIds(List ids) { + /** Validates that all object IDs are non-empty and unique. */ + public static void validateObjectIds(List ids) { Set seenIds = new HashSet<>(); Set duplicateIds = new HashSet<>(); for (String id : ids) { if (id == null || id.isEmpty()) { - throw new IllegalArgumentException("All vectors must have a non-empty ID"); + throw new IllegalArgumentException("All objects must have a non-empty ID"); } if (seenIds.contains(id)) { duplicateIds.add(id);