diff --git a/.gitignore b/.gitignore index ab47174..7140dd1 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,7 @@ coverage/ jacoco.exec # AI Tools -.claude \ No newline at end of file +.claude + +# specification file +SPEC.md \ No newline at end of file diff --git a/README.md b/README.md index dfb0a92..cf11669 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # Endee - Java Vector Database Client -Endee is a Java client for a local 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. +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. ## Key Features - **Type Safe**: Full compile-time type checking with builder patterns - **Fast ANN Searches**: Efficient similarity searches on vector data -- **Multiple Distance Metrics**: Support for cosine, L2, and inner product distance metrics -- **Hybrid Indexes**: Support for dense vectors, sparse vectors, and hybrid (dense + sparse) searches -- **Metadata Support**: Attach and search with metadata and filters +- **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**: Requires Java 17+, uses modern APIs +- **Modern Java**: Java 17+, uses modern APIs ## Requirements - Java 17 or higher -- Endee Local server running (see [Quick Start](https://docs.endee.io/quick-start)) +- Endee server running (see [Quick Start](https://docs.endee.io/quick-start)) ## Installation @@ -25,636 +26,609 @@ Endee is a Java client for a local vector database designed for maximum speed an io.endee endee-java-client - 0.1.1 + 1.0.0 ``` ### Gradle ```groovy -implementation 'io.endee:endee-java-client:0.1.1' +implementation 'io.endee:endee-java-client:1.0.0' ``` ## Quick Start ### Initialize the Client -The Endee client connects to your local server (defaults to `http://127.0.0.1:8080/api/v1`): - ```java import io.endee.client.Endee; import io.endee.client.Index; import io.endee.client.types.*; -// Connect to local Endee server (defaults to localhost:8080) +// Local server (defaults to http://127.0.0.1:8080/api/v1) Endee client = new Endee(); -``` - -**Using Authentication?** If your server has `NDD_AUTH_TOKEN` set, pass the token when initializing: - -```java -// With Auth Token -Endee client = new Endee("auth-token"); -``` - -### Setting a Custom Base URL -If your server runs on a different port, use `setBaseUrl()`: +// With an auth token +Endee client = new Endee("account:password"); -```java -Endee client = new Endee(); +// With a region (connects to https://{region}.endee.io/api/v1) +Endee client = new Endee("account:password:us-east-1"); -// Set custom base URL for non-default port +// Custom base URL client.setBaseUrl("http://0.0.0.0:8081/api/v1"); ``` +--- + +## Index Management + ### Create a Dense Index ```java -import io.endee.client.types.CreateIndexOptions; -import io.endee.client.types.Precision; -import io.endee.client.types.SpaceType; - CreateIndexOptions options = CreateIndexOptions.builder("my_vectors", 384) .spaceType(SpaceType.COSINE) - .precision(Precision.INT16) + .precision(Precision.INT8) + .m(16) + .efCon(128) .build(); client.createIndex(options); ``` -**Dense Index Parameters:** +**Parameters:** -| Parameter | Description | Default | -| ----------- | ---------------------------------------------------------------------------- | -------- | -| `name` | Unique name for your index (alphanumeric + underscore, max 48 chars) | Required | -| `dimension` | Vector dimensionality (must match your embedding model's output, max 10,000) | Required | -| `spaceType` | Distance metric - `COSINE`, `L2`, or `IP` (inner product) | `COSINE` | -| `m` | Graph connectivity - higher values increase recall but use more memory | 16 | -| `efCon` | Construction-time parameter - higher values improve index quality | 128 | -| `precision` | Quantization precision | `INT16` | +| 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 Hybrid Index -Hybrid indexes combine dense vector search with sparse vector search. Add the `sparseDimension` parameter: +Hybrid indexes support both dense and sparse vectors. Set `sparseModel` to enable sparse search: ```java +// Standard sparse search CreateIndexOptions options = CreateIndexOptions.builder("hybrid_index", 384) - .sparseDimension(30000) // Sparse vector dimension (vocabulary size) .spaceType(SpaceType.COSINE) - .precision(Precision.INT16) + .precision(Precision.INT8) + .sparseModel("default") // or "endee_bm25" for BM25 scoring .build(); client.createIndex(options); ``` -### List and Access Indexes +**`sparseModel` values:** + +| 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) | + +### List, Get, and Delete Indexes ```java -// List all indexes (returns JSON string) +// List all indexes (returns raw JSON string) String indexes = client.listIndexes(); -// Get reference to an existing index +// Get a reference to an existing index Index index = client.getIndex("my_vectors"); -// Delete an index +// Delete an index (irreversible) client.deleteIndex("my_vectors"); ``` +--- + ## Upserting Vectors -The `index.upsert()` method adds or updates vectors in an existing index. +### Dense Vectors ```java -import io.endee.client.types.VectorItem; -import java.util.List; -import java.util.Map; - 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", "group" , 10)) // meta : {"title" : "First Document", "label" : 10 } - .filter(Map.of("category", "tech", "group" , 10)) // filter : {"category" : "tech" , "group" : 10} + .meta(Map.of("title", "First document", "score", 95)) + .filter(Map.of("category", "tech", "group", 1)) .build(), - VectorItem.builder("vec2", new double[] {0.3, 0.4, 0.5 /* ... */}) - .meta(Map.of("title", "Second document")) - .filter(Map.of("category", "science")) + 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")) .build() ); index.upsert(vectors); ``` -**VectorItem Fields:** +**`VectorItem` fields:** -| Field | Required | Description | -| -------- | -------- | --------------------------------------------------- | -| `id` | Yes | Unique identifier for the vector (non-empty string) | -| `vector` | Yes | Array of doubles representing the embedding | -| `meta` | No | Arbitrary metadata map | -| `filter` | No | Key-value pairs for filtering during queries | +| 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`)| **Limits:** +- 1 – 1,000 vectors per `upsert` call +- IDs must be unique within a batch +- Vector values must be finite (no `NaN` or `Inf`) -- Maximum 1,000 vectors per upsert call -- Vector dimension must match index dimension -- IDs must be unique within a single upsert batch +--- -## Querying the Index +## Querying -The `index.query()` method performs a similarity search. +### Basic Dense Query ```java -import io.endee.client.types.QueryOptions; -import io.endee.client.types.QueryResult; - List results = index.query( QueryOptions.builder() .vector(new double[] {0.15, 0.25 /* ... */}) .topK(5) - .ef(128) - .includeVectors(true) .build() ); for (QueryResult item : results) { System.out.println("ID: " + item.getId()); System.out.println("Similarity: " + item.getSimilarity()); - System.out.println("Distance: " + item.getDistance()); + 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 } ``` -**Query Parameters:** - -| Parameter | Description | Default | Max | -| -------------------------------- | ------------------------------------------------------- | ------- | --------- | -| `vector` | Query vector (must match index dimension) | Required | - | -| `topK` | Number of results to return | 10 | 512 | -| `ef` | Search quality parameter - higher values improve recall | 128 | 1024 | -| `includeVectors` | Include vector data in results | false | - | -| `prefilterCardinalityThreshold` | Switch to postfiltering when estimated matches exceed this value | 10,000 | 1,000,000 | -| `filterBoostPercentage` | Bias results toward filter matches (0 = disabled) | 0 | 100 | +### Filtered Query -## Filtered Querying - -Use the `filter` parameter to restrict results. All filters are combined with **logical AND**. +All filter conditions are combined with **logical AND**: ```java List results = index.query( QueryOptions.builder() .vector(new double[] {0.15, 0.25 /* ... */}) - .topK(5) + .topK(10) .filter(List.of( Map.of("category", Map.of("$eq", "tech")), - Map.of("score", Map.of("$range", List.of(80, 100))) + Map.of("score", Map.of("$range", List.of(80, 100))) )) .build() ); ``` -### Filtering Operators - -| Operator | Description | Example | -| -------- | ------------------------- | ---------------------------------------------------- | -| `$eq` | Exact match | `Map.of("status", Map.of("$eq", "published"))` | -| `$in` | Match any 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)))` | +**Filter operators:** -> **Note:** The `$range` operator supports values within **[0 - 999]**. Normalize larger values before upserting. +| 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)))` | -### Filter Params +> `$range` supports integer values in **[0, 999]**. Normalize larger values before upserting. -Use `prefilterCardinalityThreshold` and `filterBoostPercentage` to fine-tune how filtering interacts with the ANN search: +### Hybrid Query ```java List results = index.query( QueryOptions.builder() - .vector(new double[] {0.15, 0.25 /* ... */}) + .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) - .filter(List.of( - Map.of("category", Map.of("$eq", "tech")) - )) - .prefilterCardinalityThreshold(50_000) // Use postfilter when >50k vectors match - .filterBoostPercentage(20) // Bias 20% toward filter-matching vectors + .denseRrfWeight(0.7) // weight for the dense component in RRF fusion (0.0–1.0) + .rrfRankConstant(60) // RRF rank constant (default 60) .build() ); ``` -| Parameter | Description | Default | Range | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------- | ----------------- | -| `prefilterCardinalityThreshold` | When the estimated number of vectors matching the filter exceeds this value, postfiltering is used instead of prefiltering. | 10,000 | 1,000–1,000,000 | -| `filterBoostPercentage` | Percentage by which filter-matching vectors are boosted during scoring. Set to `0` to disable. Higher values favor filtered results. | 0 | 0–100 | - -## Hybrid Search +You can also query with only dense (`vector`) or only sparse (`sparseIndices` + `sparseValues`). -### Upserting Hybrid Vectors - -Provide both dense vectors and sparse representations: +### All Query Options ```java -Index index = client.getIndex("hybrid_index"); - -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}) // Weights for each position - .meta(Map.of("title", "Document 1")) - .build(), - - VectorItem.builder("doc2", new double[] {0.3, 0.4 /* ... */}) - .sparseIndices(new int[] {15, 100, 500}) - .sparseValues(new double[] {0.9, 0.4, 0.6}) - .meta(Map.of("title", "Document 2")) - .build() -); - -index.upsert(vectors); +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() ``` -**Hybrid Vector Fields:** - -| Field | Required | Description | -| --------------- | ------------ | ---------------------------------------- | -| `id` | Yes | Unique identifier | -| `vector` | Yes | Dense embedding vector | -| `sparseIndices` | Yes (hybrid) | Non-zero term positions in sparse vector | -| `sparseValues` | Yes (hybrid) | Weights for each sparse index | -| `meta` | No | Metadata map | -| `filter` | No | Filter fields | +--- -> **Important:** `sparseIndices` and `sparseValues` must have the same length. Values in `sparseIndices` must be within `[0, sparseDimension)`. +## CRUD Operations -### Querying Hybrid Index - -Provide both dense and sparse query vectors: +### Get a Vector by ID ```java -List results = index.query( - QueryOptions.builder() - .vector(new double[] {0.15, 0.25 /* ... */}) // Dense query - .sparseIndices(new int[] {10, 100, 300}) // Sparse query positions - .sparseValues(new double[] {0.7, 0.5, 0.4}) // Sparse query weights - .topK(5) - .build() -); - -for (QueryResult item : results) { - System.out.println("ID: " + item.getId() + ", Similarity: " + item.getSimilarity()); -} +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())); ``` -You can also query with: - -- **Dense only**: Provide only `vector` -- **Sparse only**: Provide only `sparseIndices` and `sparseValues` -- **Hybrid**: Provide all three for combined results +### Update Filters -## Updating Filters - -Use `index.updateFilters()` to update the filter fields of existing vectors without re-upserting them. +Updates filter fields on existing vectors without re-upserting. The entire filter object is replaced: ```java -import io.endee.client.types.UpdateFilterParams; -import java.util.List; -import java.util.Map; - index.updateFilters(List.of( new UpdateFilterParams("vec1", Map.of("category", "ml", "score", 95)), new UpdateFilterParams("vec2", Map.of("category", "science", "score", 80)) )); ``` -**`UpdateFilterParams` Fields:** - -| Field | Required | Description | -| -------- | -------- | -------------------------------------------------- | -| `id` | Yes | ID of the vector to update | -| `filter` | Yes | New filter object to replace the existing filter | - -> **Note:** The entire filter object is replaced, not merged. - -## Deletion Methods - ### Delete by ID -Delete a vector with a specific vector ID. - ```java -index.deleteVector("vec1"); +String result = index.deleteVector("vec1"); +// returns e.g. "1 rows deleted" ``` ### Delete by Filter -Delete all vectors matching specific filters. - ```java index.deleteWithFilter(List.of( Map.of("category", Map.of("$eq", "tech")) )); ``` -### Delete Index - -Delete an entire index. - -```java -client.deleteIndex("my_index"); -``` +--- -> **Warning:** Deletion operations are **irreversible**. +## Index Maintenance -## Additional Operations +### Describe Index -### Get Vector by ID +Returns stored metadata without a network call: ```java -import io.endee.client.types.VectorInfo; - -VectorInfo vector = index.getVector("vec1"); -System.out.println("ID: " + vector.getId()); -System.out.println("Vector: " + Arrays.toString(vector.getVector())); -System.out.println("Meta: " + vector.getMeta()); -System.out.println("Norm: " + vector.getNorm()); +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} ``` -### Describe Index +### Refresh Metadata -```java -import io.endee.client.types.IndexDescription; +Fetches the latest metadata from the server and updates the local Index object: -IndexDescription info = index.describe(); -System.out.println(info); -// {name='my_index', spaceType= COSINE, dimension=384, precision=INT16, -// count=1000, isHybrid=false, sparseDimension=0, M=16, efCon=128} +```java +Map meta = index.refreshMetadata(); +// returns: {count, space_type, dimension, precision, M, ef_con, sparse_model, is_hybrid} ``` -### Check if Index is Hybrid +### Rebuild Index + +Rebuilds the HNSW graph with new parameters. Useful after bulk inserts or to tune recall: ```java -boolean isHybrid = index.isHybrid(); +Map result = index.rebuild(16, 200); +// result: {status, previous_config, new_config, total_vectors} ``` -## Precision Options +> `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. -Endee supports different quantization precision levels: +### Rebuild Status -```java -import io.endee.client.types.Precision; +Poll the rebuild progress: -Precision.BINARY // Binary quantization (1-bit) - smallest storage, fastest search -Precision.INT8 // 8-bit integer quantization - balanced performance -Precision.INT16 // 16-bit integer quantization (default) - higher precision -Precision.FLOAT16 // 16-bit floating point - good balance -Precision.FLOAT32 // 32-bit floating point - highest precision +```java +Map status = index.rebuildStatus(); +// status: {status: "in_progress"|"completed"|"failed"|"idle", +// vectors_processed, total_vectors, percent_complete} ``` -**Choosing Precision:** +--- -| Precision | Use Case | -| --------- | ------------------------------------------------------------------------- | -| `BINARY` | Very large datasets where speed and storage are critical | -| `INT8` | Recommended for most use cases - good balance of accuracy and performance | -| `INT16` | Better accuracy than INT8 but less storage than FLOAT32 | -| `FLOAT16` | Good compromise between precision and storage for embeddings | -| `FLOAT32` | Maximum precision when storage is not a concern | +## Precision Options -## Space Types (Distance Metrics) +| 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 | -```java -import io.endee.client.types.SpaceType; +## Space Types -SpaceType.COSINE // Cosine similarity (default) - best for normalized embeddings -SpaceType.L2 // Euclidean distance - best for spatial data -SpaceType.IP // Inner product - best for unnormalized embeddings -``` +| Value | Wire | Best For | +|----------|----------|----------------------------------------| +| `COSINE` | `cosine` | Normalized embeddings (default) | +| `L2` | `l2` | Spatial / Euclidean distance | +| `IP` | `ip` | Unnormalized embeddings (dot product) | + +--- ## Error Handling -The client throws specific exceptions for different error scenarios: +The client uses a typed exception hierarchy. All exceptions extend `EndeeException`: ```java -import io.endee.client.exception.EndeeException; -import io.endee.client.exception.EndeeApiException; +import io.endee.client.exception.*; try { - client.createIndex(options); + index.getVector("missing_id"); +} catch (NotFoundException e) { + System.err.println("Not found: " + e.getMessage()); +} catch (AuthenticationException e) { + System.err.println("Auth failed: " + e.getMessage()); } catch (EndeeApiException e) { - // API-specific errors (e.g., 400, 401, 404, 409, 500) - System.err.println("Status Code: " + e.getStatusCode()); - System.err.println("Error Body: " + e.getErrorBody()); + // catch-all for any API error — provides status code and raw body + System.err.println("HTTP " + e.getStatusCode() + ": " + e.getErrorBody()); } catch (EndeeException e) { - // Client errors (network, serialization, etc.) - System.err.println("Client Error: " + e.getMessage()); + // network / serialization errors + System.err.println("Client error: " + e.getMessage()); } catch (IllegalArgumentException e) { - // Validation errors - System.err.println("Validation Error: " + e.getMessage()); + // validation errors (invalid params, dimension mismatch, etc.) + System.err.println("Validation: " + e.getMessage()); } ``` -**HTTP Status Codes:** +**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 | -| Code | Description | -| ---- | ------------------------------------------------ | -| 400 | Bad Request - Invalid parameters | -| 401 | Unauthorized - Invalid or missing authentication | -| 403 | Forbidden - Insufficient permissions | -| 404 | Not Found - Index or vector doesn't exist | -| 409 | Conflict - Index already exists | -| 500 | Internal Server Error | +All typed exceptions also extend `EndeeApiException`, so catching `EndeeApiException` handles every API error if you only need the status code. + +--- ## Complete Example ```java import io.endee.client.Endee; import io.endee.client.Index; +import io.endee.client.exception.*; import io.endee.client.types.*; - +import java.util.Arrays; import java.util.List; import java.util.Map; -public class EndeeExample { +public class Example { public static void main(String[] args) { - // Initialize client Endee client = new Endee(); - // Create a dense index - CreateIndexOptions createOptions = CreateIndexOptions.builder("documents", 384) - .spaceType(SpaceType.COSINE) - .precision(Precision.INT16) - .build(); - - client.createIndex(createOptions); - - // Get the index - Index index = client.getIndex("documents"); - - // Add vectors - List vectors = List.of( - VectorItem.builder("doc1", new double[384]{/*...*/}) // 384 dimensions - .meta(Map.of("title", "First Document")) - .filter(Map.of("category", "tech")) - .build(), - - VectorItem.builder("doc2", new double[384]) - .meta(Map.of("title", "Second Document")) - .filter(Map.of("category", "science")) + // 1. Create a hybrid index + client.createIndex( + CreateIndexOptions.builder("docs", 384) + .spaceType(SpaceType.COSINE) + .precision(Precision.INT8) + .sparseModel("default") .build() ); - index.upsert(vectors); + // 2. Get index reference + Index index = client.getIndex("docs"); + System.out.println("isHybrid: " + index.isHybrid()); // true + + // 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}) + .meta(Map.of("title", "Hello World")) + .filter(Map.of("category", "tech", "score", 90)) + .build() + )); - // Query the index + // 4. Query List results = index.query( QueryOptions.builder() - .vector(new double[384]) // Query vector + .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() ); - for (QueryResult item : results) { - System.out.println("ID: " + item.getId() + ", Similarity: " + item.getSimilarity()); + for (QueryResult r : results) { + System.out.printf("ID: %s Similarity: %.4f Meta: %s%n", + r.getId(), r.getSimilarity(), r.getMeta()); } - // Describe the index - IndexDescription description = index.describe(); - System.out.println(description); + // 5. Get a vector (hybrid returns sparse fields too) + VectorInfo info = index.getVector("doc1"); + System.out.println("SparseIndices: " + Arrays.toString(info.getSparseIndices())); - // Clean up - client.deleteIndex("documents"); - } -} -``` + // 6. Update filter + index.updateFilters(List.of( + new UpdateFilterParams("doc1", Map.of("category", "ml", "score", 95)) + )); -## API Reference + // 7. Rebuild index after bulk inserts + Map rebuildResult = index.rebuild(16, 200); + System.out.println("Rebuild: " + rebuildResult.get("status")); -### Endee Class + // 8. Poll rebuild status + Map status = index.rebuildStatus(); + System.out.println("Status: " + status); -| Method | Parameters | Return Type | Description | -| --------------------------------- | ---------- | ----------- | ----------------------------- | -| `Endee()` | - | - | Create client without auth | -| `Endee(String token)` | `token` | - | Create client with auth token | -| `setBaseUrl(String url)` | `url` | `String` | Set custom base URL | -| `createIndex(CreateIndexOptions)` | `options` | `String` | Create a new index | -| `listIndexes()` | - | `String` | List all indexes (JSON) | -| `deleteIndex(String name)` | `name` | `String` | Delete an index | -| `getIndex(String name)` | `name` | `Index` | Get reference to an index | - -### Index Class + // 9. Cleanup + client.deleteIndex("docs"); + } +} +``` -| Method | Parameters | Return Type | Description | -| ----------------------------- | ---------- | ------------------- | -------------------------- | -| `upsert(List)` | `vectors` | `String` | Insert or update vectors | -| `query(QueryOptions)` | `options` | `List` | Search for similar vectors | -| `updateFilters(List)` | `updates` | `String` | Update filter fields on existing vectors | -| `deleteVector(String id)` | `id` | `String` | Delete a vector by ID | -| `deleteWithFilter(List)` | `filter` | `String` | Delete vectors by filter | -| `getVector(String id)` | `id` | `VectorInfo` | Get a vector by ID | -| `describe()` | - | `IndexDescription` | Get index metadata | -| `isHybrid()` | - | `boolean` | Check if index is hybrid | +--- -### Builder Classes +## API Reference -#### CreateIndexOptions.Builder +### `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 - .sparseDimension(Integer) // Optional, for hybrid indexes + .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 +### `QueryOptions.Builder` ```java QueryOptions.builder() - .vector(double[]) // Required for dense search - .topK(int) // Default: 10, max 512 - .ef(int) // Default: 128, max 1024 - .filter(List>) // Optional - .includeVectors(boolean) // Default: false - .sparseIndices(int[]) // Optional, for hybrid search - .sparseValues(double[]) // Optional, for hybrid search - .prefilterCardinalityThreshold(int) // Default: 10000, range 1000–1000000 - .filterBoostPercentage(int) // Default: 0, range 0–100 + .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 +### `VectorItem.Builder` ```java VectorItem.builder(String id, double[] vector) - .meta(Map) // Optional - .filter(Map) // Optional - .sparseIndices(int[]) // Optional, for hybrid - .sparseValues(double[]) // Optional, for hybrid + .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 identifier | -| `similarity` | `double` | Similarity score | -| `distance` | `double` | Distance (1 - similarity) | -| `meta` | `Map` | Metadata | -| `filter` | `Map` | Filter values | -| `norm` | `double` | Normalization factor | -| `vector` | `double[]` | Vector data (if requested) | - -### VectorInfo - -| Field | Type | Description | -| -------- | --------------------- | -------------------- | -| `id` | `String` | Vector identifier | -| `vector` | `double[]` | Vector data | -| `meta` | `Map` | Metadata | -| `filter` | `Map` | Filter values | -| `norm` | `double` | Normalization factor | - -### IndexDescription - -| Field | Type | Description | -| ----------------- | ----------- | ------------------------------------ | -| `name` | `String` | Index name | -| `spaceType` | `SpaceType` | Distance metric | -| `dimension` | `int` | Vector dimension | -| `sparseDimension` | `int` | Sparse vector dimension | -| `isHybrid` | `boolean` | Whether index supports hybrid search | -| `count` | `long` | Number of vectors | -| `precision` | `Precision` | Quantization precision | -| `m` | `int` | Graph connectivity | -| `efCon` | `int` | Construction-time quality parameter | +### `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 | + +--- ## Code Formatting -This project uses [Spotless](https://github.com/diffplug/spotless) with [Google Java Format](https://github.com/google/google-java-format) to enforce consistent code style. +This project uses [Spotless](https://github.com/diffplug/spotless) with [Google Java Format](https://github.com/google/google-java-format). -**Format all source files:** ```bash -mvn spotless:apply +mvn spotless:apply # auto-format all source files +mvn spotless:check # verify formatting (runs in CI) ``` -**Check formatting without modifying files:** -```bash -mvn spotless:check -``` - -Formatting is also checked automatically as part of `mvn verify` — CI will fail if any file is not properly formatted. - ## Dependencies -- Jackson (JSON serialization) -- MessagePack (Binary serialization) -- SLF4J (Logging) +- **Jackson** — JSON serialization +- **MessagePack** — binary serialization for vector payloads +- **SLF4J** — logging facade ## License diff --git a/src/main/java/io/endee/client/Endee.java b/src/main/java/io/endee/client/Endee.java index b0b6ea0..9129141 100644 --- a/src/main/java/io/endee/client/Endee.java +++ b/src/main/java/io/endee/client/Endee.java @@ -42,7 +42,9 @@ 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 = 10000; + 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 String token; private String baseUrl; @@ -56,13 +58,13 @@ public class Endee { */ public Endee() { this(null); - this.baseUrl = "http://127.0.0.1:8080/api/v1"; } /** * Creates a new Endee client. * - * @param token the Auth token (optional) + * @param token the Auth token (optional). Format: {@code "account:password"} or {@code + * "account:password:region"} */ public Endee(String token) { this.token = token; @@ -106,13 +108,11 @@ public String setBaseUrl(String url) { public String createIndex(CreateIndexOptions options) { if (!ValidationUtils.isValidIndexName(options.getName())) { throw new IllegalArgumentException( - "Invalid index name. Index name must be alphanumeric and can contain underscores and less than 48 characters"); - } - if (options.getDimension() > MAX_DIMENSION) { - throw new IllegalArgumentException("Dimension cannot be greater than " + MAX_DIMENSION); + "Invalid index name. Must be alphanumeric with underscores, max 48 characters."); } - if (options.getSparseDimension() != null && options.getSparseDimension() < 0) { - throw new IllegalArgumentException("Sparse dimension cannot be less than 0"); + if (options.getDimension() < MIN_DIMENSION || options.getDimension() > MAX_DIMENSION) { + throw new IllegalArgumentException( + "Dimension must be between " + MIN_DIMENSION + " and " + MAX_DIMENSION); } String normalizedSpaceType = options.getSpaceType().getValue().toLowerCase(); @@ -120,6 +120,16 @@ public String createIndex(CreateIndexOptions options) { throw new IllegalArgumentException("Invalid space type: " + options.getSpaceType()); } + 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; + } + Map data = new HashMap<>(); data.put("index_name", options.getName()); data.put("dim", options.getDimension()); @@ -129,8 +139,8 @@ public String createIndex(CreateIndexOptions options) { data.put("checksum", -1); data.put("precision", options.getPrecision().getValue()); - if (options.getSparseDimension() != null) { - data.put("sparse_dim", options.getSparseDimension()); + if (sparseModel != null) { + data.put("sparse_model", sparseModel); } if (options.getVersion() != null) { data.put("version", options.getVersion()); @@ -158,7 +168,7 @@ public String createIndex(CreateIndexOptions options) { /** * Lists all indexes. * - * @return list of index information + * @return raw JSON string of index information * @throws EndeeException if the operation fails */ public String listIndexes() { @@ -228,14 +238,19 @@ public Index getIndex(String name) { indexInfo.setTotalElements(data.get("total_elements").asLong()); indexInfo.setPrecision(Precision.fromValue(data.get("precision").asText())); indexInfo.setM(data.get("M").asInt()); - indexInfo.setChecksum(data.get("checksum").asLong()); indexInfo.setEfCon(data.get("ef_con").asInt()); + if (data.has("checksum") && !data.get("checksum").isNull()) { + indexInfo.setChecksum(data.get("checksum").asLong()); + } if (data.has("version") && !data.get("version").isNull()) { indexInfo.setVersion(data.get("version").asInt()); } - if (data.has("sparse_dim") && !data.get("sparse_dim").isNull()) { - indexInfo.setSparseDimension(data.get("sparse_dim").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); diff --git a/src/main/java/io/endee/client/Index.java b/src/main/java/io/endee/client/Index.java index 118fbbe..e74c7fd 100644 --- a/src/main/java/io/endee/client/Index.java +++ b/src/main/java/io/endee/client/Index.java @@ -1,5 +1,7 @@ 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.*; @@ -42,20 +44,23 @@ 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 = 512; + 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 int sparseDimension; + private String sparseModel; + private String libToken; private int efCon; /** Creates a new Index instance. */ @@ -63,16 +68,17 @@ public Index(String name, String token, String url, int version, IndexInfo param 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.INT16; + params != null && params.getPrecision() != null ? params.getPrecision() : Precision.INT8; this.m = params != null ? params.getM() : 16; - this.sparseDimension = - params != null && params.getSparseDimension() != null ? params.getSparseDimension() : 0; + this.sparseModel = params != null ? params.getSparseModel() : "None"; + this.libToken = params != null ? params.getLibToken() : null; this.efCon = params != null ? params.getEfCon() : 128; this.httpClient = @@ -87,9 +93,16 @@ public String toString() { return name; } - /** Checks if this index supports hybrid (sparse + dense) vectors. */ + 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 sparseDimension > 0; + return sparseModel != null && !"None".equals(sparseModel); } /** Normalizes a vector for cosine similarity. Returns [normalizedVector, norm]. */ @@ -121,13 +134,26 @@ private double[][] normalizeVector(double[] vector) { 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 + * @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"); @@ -142,6 +168,7 @@ public String upsert(List inputArray) { 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]; @@ -154,7 +181,7 @@ public String upsert(List inputArray) { if (!isHybrid() && (sparseIndices.length > 0 || sparseValues.length > 0)) { throw new IllegalArgumentException( - "Cannot insert sparse data into a dense-only index. Create index with sparseDimension > 0 for hybrid support."); + "Cannot insert sparse data into a dense-only index. Use sparseModel(\"default\") when creating the index."); } if (isHybrid()) { @@ -170,16 +197,6 @@ public String upsert(List inputArray) { + sparseValues.length + " values."); } - for (int idx : sparseIndices) { - if (idx < 0 || idx >= sparseDimension) { - throw new IllegalArgumentException( - "Sparse index " - + idx - + " is out of bounds. Must be in range [0," - + sparseDimension - + ")."); - } - } } String filterJson = JsonUtils.toJson(item.getFilter() != null ? item.getFilter() : Map.of()); @@ -228,20 +245,27 @@ public String upsert(List inputArray) { * @return list of query results */ public List query(QueryOptions options) { - if (options.getTopK() > MAX_TOP_K || options.getTopK() < 0) { - throw new IllegalArgumentException( - "top_k cannot be greater than " + MAX_TOP_K + " and less than 0"); + 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 search cannot be greater than " + 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() > 100) { - throw new IllegalArgumentException("filterBoostPercentage must be between 0 and 100"); + 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 = @@ -257,8 +281,7 @@ public List query(QueryOptions options) { } if (hasSparse && !isHybrid()) { - throw new IllegalArgumentException( - "Cannot perform sparse search on a dense-only index. Create index with sparseDimension > 0 for hybrid support."); + throw new IllegalArgumentException("Cannot perform sparse search on a dense-only index."); } if (hasSparse && options.getSparseIndices().length != options.getSparseValues().length) { @@ -286,10 +309,13 @@ public List query(QueryOptions options) { } Map filterParams = new HashMap<>(); - filterParams.put("prefilter_cardinality_threshold", options.getPrefilterCardinalityThreshold()); - filterParams.put("filter_boost_percentage", options.getFilterBoostPercentage()); + 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); @@ -318,6 +344,7 @@ public List query(QueryOptions options) { result.setDistance(1 - similarity); result.setMeta(meta); result.setNorm(normValue); + result.setVector(new double[0]); if (filterStr != null && !filterStr.isEmpty() && !filterStr.equals("{}")) { @SuppressWarnings("unchecked") @@ -345,7 +372,7 @@ public List query(QueryOptions options) { * 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 success message + * @return server response text */ public String updateFilters(List updates) { List ids = updates.stream().map(UpdateFilterParams::getId).collect(Collectors.toList()); @@ -382,7 +409,7 @@ public String updateFilters(List updates) { * Deletes a vector by ID. * * @param id the vector ID to delete - * @return success message + * @return deletion count message (e.g. {@code "1 rows deleted"}) */ public String deleteVector(String id) { try { @@ -394,7 +421,7 @@ public String deleteVector(String id) { EndeeApiException.raiseException(response.statusCode(), response.body()); } - return response.body(); + return response.body() + " rows deleted"; } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); @@ -435,7 +462,7 @@ public String deleteWithFilter(List> filter) { * Gets a vector by ID. * * @param id the vector ID - * @return the vector information + * @return the vector information including sparse fields for hybrid indexes */ public VectorInfo getVector(String id) { try { @@ -466,6 +493,11 @@ public VectorInfo getVector(String id) { 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) { @@ -476,18 +508,151 @@ public VectorInfo getVector(String id) { } /** - * Returns a description of this index. + * Returns a description of this index without making a network call. * * @return the index description */ public IndexDescription describe() { return new IndexDescription( - name, spaceType, dimension, sparseDimension, isHybrid(), count, precision, m, efCon); + 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); + } } - // ==================== HTTP Request Helper Methods ==================== + /** + * 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(); + } - /** Builds a POST request with JSON body. */ private HttpRequest buildPostJsonRequest(String path, String jsonBody) { HttpRequest.Builder builder = HttpRequest.newBuilder() @@ -503,7 +668,6 @@ private HttpRequest buildPostJsonRequest(String path, String jsonBody) { return builder.build(); } - /** Builds a POST request with MessagePack body. */ private HttpRequest buildPostMsgpackRequest(String path, byte[] body) { HttpRequest.Builder builder = HttpRequest.newBuilder() @@ -519,7 +683,6 @@ private HttpRequest buildPostMsgpackRequest(String path, byte[] body) { return builder.build(); } - /** Builds a DELETE request. */ private HttpRequest buildDeleteRequest(String path) { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url + path)).timeout(DEFAULT_TIMEOUT).DELETE(); @@ -531,7 +694,6 @@ private HttpRequest buildDeleteRequest(String path) { return builder.build(); } - /** Builds a DELETE request with JSON body. */ private HttpRequest buildDeleteJsonRequest(String path, String jsonBody) { HttpRequest.Builder builder = HttpRequest.newBuilder() diff --git a/src/main/java/io/endee/client/exception/AuthenticationException.java b/src/main/java/io/endee/client/exception/AuthenticationException.java new file mode 100644 index 0000000..488c37b --- /dev/null +++ b/src/main/java/io/endee/client/exception/AuthenticationException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 401 — invalid or expired token. */ +public class AuthenticationException extends EndeeApiException { + + public AuthenticationException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/exception/ConflictException.java b/src/main/java/io/endee/client/exception/ConflictException.java new file mode 100644 index 0000000..eb1e69f --- /dev/null +++ b/src/main/java/io/endee/client/exception/ConflictException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 409 — resource already exists. */ +public class ConflictException extends EndeeApiException { + + public ConflictException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/exception/EndeeApiException.java b/src/main/java/io/endee/client/exception/EndeeApiException.java index 70bcb11..51983d5 100644 --- a/src/main/java/io/endee/client/exception/EndeeApiException.java +++ b/src/main/java/io/endee/client/exception/EndeeApiException.java @@ -1,8 +1,13 @@ package io.endee.client.exception; -/** Exception thrown when the Endee API returns an error response. */ +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Exception thrown when the Endee API returns an error response (HTTP 400 / catch-all). */ public class EndeeApiException extends EndeeException { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final int statusCode; private final String errorBody; @@ -20,18 +25,42 @@ public String getErrorBody() { return errorBody; } - /** Raises the appropriate exception based on status code. */ + /** Raises the appropriate typed exception based on HTTP status code. */ public static void raiseException(int statusCode, String errorBody) { - String message = - switch (statusCode) { - case 400 -> "Bad Request: " + errorBody; - case 401 -> "Unauthorized: " + errorBody; - case 403 -> "Forbidden: " + errorBody; - case 404 -> "Not Found: " + errorBody; - case 409 -> "Conflict: " + errorBody; - case 500 -> "Internal Server Error: " + errorBody; - default -> "API Error (" + statusCode + "): " + errorBody; - }; - throw new EndeeApiException(message, statusCode, errorBody); + String message = extractMessage(errorBody); + + if (statusCode == 400) { + throw new EndeeApiException("API Error: " + message, statusCode, errorBody); + } else if (statusCode == 401) { + throw new AuthenticationException("Authentication Error: " + message, statusCode, errorBody); + } else if (statusCode == 402) { + throw new SubscriptionException("Subscription Error: " + message, statusCode, errorBody); + } else if (statusCode == 403) { + throw new ForbiddenException("Forbidden: " + message, statusCode, errorBody); + } else if (statusCode == 404) { + throw new NotFoundException("Resource Not Found: " + message, statusCode, errorBody); + } else if (statusCode == 409) { + throw new ConflictException("Conflict: " + message, statusCode, errorBody); + } else if (statusCode >= 500) { + throw new ServerException( + "Server Busy: Server is busy. Please try again in sometime", statusCode, errorBody); + } else { + throw new EndeeApiException( + "API Error: Unknown Error. Please try again in sometime", statusCode, errorBody); + } + } + + private static String extractMessage(String errorBody) { + if (errorBody == null || errorBody.isBlank()) { + return "Unknown error"; + } + try { + JsonNode node = OBJECT_MAPPER.readTree(errorBody); + if (node.has("error")) { + return node.get("error").asText(); + } + } catch (Exception ignored) { + } + return errorBody; } } diff --git a/src/main/java/io/endee/client/exception/ForbiddenException.java b/src/main/java/io/endee/client/exception/ForbiddenException.java new file mode 100644 index 0000000..aae2171 --- /dev/null +++ b/src/main/java/io/endee/client/exception/ForbiddenException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 403 — insufficient permissions. */ +public class ForbiddenException extends EndeeApiException { + + public ForbiddenException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/exception/NotFoundException.java b/src/main/java/io/endee/client/exception/NotFoundException.java new file mode 100644 index 0000000..56942a5 --- /dev/null +++ b/src/main/java/io/endee/client/exception/NotFoundException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 404 — index or vector not found. */ +public class NotFoundException extends EndeeApiException { + + public NotFoundException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/exception/ServerException.java b/src/main/java/io/endee/client/exception/ServerException.java new file mode 100644 index 0000000..01ec7d1 --- /dev/null +++ b/src/main/java/io/endee/client/exception/ServerException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 5xx — internal server or service error. */ +public class ServerException extends EndeeApiException { + + public ServerException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/exception/SubscriptionException.java b/src/main/java/io/endee/client/exception/SubscriptionException.java new file mode 100644 index 0000000..6395231 --- /dev/null +++ b/src/main/java/io/endee/client/exception/SubscriptionException.java @@ -0,0 +1,9 @@ +package io.endee.client.exception; + +/** Exception thrown when the API returns HTTP 402 — quota exceeded or tier limit reached. */ +public class SubscriptionException extends EndeeApiException { + + public SubscriptionException(String message, int statusCode, String errorBody) { + super(message, statusCode, errorBody); + } +} diff --git a/src/main/java/io/endee/client/types/CreateIndexOptions.java b/src/main/java/io/endee/client/types/CreateIndexOptions.java index 83b204a..311651d 100644 --- a/src/main/java/io/endee/client/types/CreateIndexOptions.java +++ b/src/main/java/io/endee/client/types/CreateIndexOptions.java @@ -7,9 +7,9 @@ public class CreateIndexOptions { private SpaceType spaceType = SpaceType.COSINE; private int m = 16; private int efCon = 128; - private Precision precision = Precision.INT16; + private Precision precision = Precision.INT8; private Integer version = null; - private Integer sparseDimension = null; + private String sparseModel = null; private CreateIndexOptions(String name, int dimension) { this.name = name; @@ -48,8 +48,8 @@ public Integer getVersion() { return version; } - public Integer getSparseDimension() { - return sparseDimension; + public String getSparseModel() { + return sparseModel; } public static class Builder { @@ -84,8 +84,14 @@ public Builder version(Integer version) { return this; } - public Builder sparseDimension(Integer sparseDimension) { - options.sparseDimension = sparseDimension; + /** + * 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; } diff --git a/src/main/java/io/endee/client/types/IndexDescription.java b/src/main/java/io/endee/client/types/IndexDescription.java index 02bfeac..94732de 100644 --- a/src/main/java/io/endee/client/types/IndexDescription.java +++ b/src/main/java/io/endee/client/types/IndexDescription.java @@ -1,11 +1,11 @@ package io.endee.client.types; -/** Description of an Endee index. */ +/** 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 int sparseDimension; + private final String sparseModel; private final boolean isHybrid; private final long count; private final Precision precision; @@ -16,7 +16,7 @@ public IndexDescription( String name, SpaceType spaceType, int dimension, - int sparseDimension, + String sparseModel, boolean isHybrid, long count, Precision precision, @@ -25,7 +25,7 @@ public IndexDescription( this.name = name; this.spaceType = spaceType; this.dimension = dimension; - this.sparseDimension = sparseDimension; + this.sparseModel = sparseModel; this.isHybrid = isHybrid; this.count = count; this.precision = precision; @@ -45,8 +45,8 @@ public int getDimension() { return dimension; } - public int getSparseDimension() { - return sparseDimension; + public String getSparseModel() { + return sparseModel; } public boolean isHybrid() { @@ -73,7 +73,7 @@ public int getEfCon() { public String toString() { return "{name='" + name - + "', spaceType= " + + "', spaceType=" + spaceType + ", dimension=" + dimension @@ -83,9 +83,9 @@ public String toString() { + count + ", isHybrid=" + isHybrid - + ", sparseDimension=" - + sparseDimension - + ", M=" + + ", 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 index 5fbd8df..54c9eaf 100644 --- a/src/main/java/io/endee/client/types/IndexInfo.java +++ b/src/main/java/io/endee/client/types/IndexInfo.java @@ -10,7 +10,8 @@ public class IndexInfo { private int m; private long checksum; private int version; - private Integer sparseDimension; + private String sparseModel; + private String libToken; private int efCon; public IndexInfo() {} @@ -79,12 +80,20 @@ public void setVersion(int version) { this.version = version; } - public Integer getSparseDimension() { - return sparseDimension; + public String getSparseModel() { + return sparseModel; } - public void setSparseDimension(Integer sparseDimension) { - this.sparseDimension = sparseDimension; + public void setSparseModel(String sparseModel) { + this.sparseModel = sparseModel; + } + + public String getLibToken() { + return libToken; + } + + public void setLibToken(String libToken) { + this.libToken = libToken; } public int getEfCon() { diff --git a/src/main/java/io/endee/client/types/QueryOptions.java b/src/main/java/io/endee/client/types/QueryOptions.java index 54d798d..c79e5c8 100644 --- a/src/main/java/io/endee/client/types/QueryOptions.java +++ b/src/main/java/io/endee/client/types/QueryOptions.java @@ -20,17 +20,17 @@ * } */ public class QueryOptions { - private static final int DEFAULT_PREFILTER_CARDINALITY_THRESHOLD = 10_000; - private double[] vector; - private int topK; + private int topK = 10; private List> filter; private int ef = 128; private boolean includeVectors = false; private int[] sparseIndices; private double[] sparseValues; - private int prefilterCardinalityThreshold = DEFAULT_PREFILTER_CARDINALITY_THRESHOLD; + private int prefilterCardinalityThreshold = 10_000; private int filterBoostPercentage = 0; + private double denseRrfWeight = 0.5; + private int rrfRankConstant = 60; private QueryOptions() {} @@ -74,6 +74,14 @@ 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(); @@ -92,7 +100,6 @@ public Builder topK(int topK) { * * @param filter list of filter conditions, e.g.: [{"category": {"$eq": "tech"}}, {"score": * {"$range": [80, 100]}}] - * @return this builder */ public Builder filter(List> filter) { options.filter = filter; @@ -120,9 +127,8 @@ public Builder sparseValues(double[] sparseValues) { } /** - * Sets the prefilter cardinality threshold. When the estimated number of matching vectors - * exceeds this value, postfiltering is used instead. Must be between 1,000 and 1,000,000. - * Default: 10,000. + * 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; @@ -130,14 +136,26 @@ public Builder prefilterCardinalityThreshold(int prefilterCardinalityThreshold) } /** - * Sets the filter boost percentage (0-100). Higher values bias results toward filter matches. - * Default: 0. + * 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/VectorInfo.java b/src/main/java/io/endee/client/types/VectorInfo.java index 358dc42..bafad1a 100644 --- a/src/main/java/io/endee/client/types/VectorInfo.java +++ b/src/main/java/io/endee/client/types/VectorInfo.java @@ -9,6 +9,8 @@ public class VectorInfo { private Map filter; private double norm; private double[] vector; + private int[] sparseIndices; + private double[] sparseValues; public VectorInfo() {} @@ -52,6 +54,22 @@ 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='" @@ -60,6 +78,8 @@ public String toString() { + norm + ", vectorLength=" + (vector != null ? vector.length : 0) + + ", isHybrid=" + + (sparseIndices != null) + "}"; } } diff --git a/src/main/java/io/endee/client/util/MessagePackUtils.java b/src/main/java/io/endee/client/util/MessagePackUtils.java index b6ab743..fb8febf 100644 --- a/src/main/java/io/endee/client/util/MessagePackUtils.java +++ b/src/main/java/io/endee/client/util/MessagePackUtils.java @@ -14,7 +14,7 @@ public final class MessagePackUtils { private MessagePackUtils() {} - /** Packs vector data for upsert operations. */ + /** Packs vector data for upsert operations using single-precision floats. */ public static byte[] packVectors(List vectors) { try (MessageBufferPacker packer = MessagePack.newDefaultBufferPacker()) { packer.packArrayHeader(vectors.size()); @@ -44,17 +44,17 @@ private static void packVectorTuple(MessageBufferPacker packer, Object[] vector) // filter (string) packer.packString((String) vector[2]); - // norm (double) - packer.packDouble((Double) vector[3]); + // norm (single-precision float) + packer.packFloat((float) (double) (Double) vector[3]); - // vector (double[]) + // vector (single-precision floats) double[] vec = (double[]) vector[4]; packer.packArrayHeader(vec.length); for (double v : vec) { - packer.packDouble(v); + packer.packFloat((float) v); } - // Optional sparse data + // sparse data (hybrid only) if (vector.length > 5) { int[] sparseIndices = (int[]) vector[5]; packer.packArrayHeader(sparseIndices.length); @@ -65,7 +65,7 @@ private static void packVectorTuple(MessageBufferPacker packer, Object[] vector) double[] sparseValues = (double[]) vector[6]; packer.packArrayHeader(sparseValues.length); for (double val : sparseValues) { - packer.packDouble(val); + packer.packFloat((float) val); } } } @@ -106,7 +106,10 @@ public static List unpackQueryResults(byte[] data) { return results; } - /** Unpacks a single vector from MessagePack bytes. */ + /** + * Unpacks a single vector from MessagePack bytes. Handles both dense (5-element) and hybrid + * (7-element) tuples. + */ public static Object[] unpackVector(byte[] data) { try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data)) { int tupleSize = unpacker.unpackArrayHeader(); @@ -125,19 +128,29 @@ public static Object[] unpackVector(byte[] data) { } 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; + + int sparseValLen = unpacker.unpackArrayHeader(); + double[] sparseValues = new double[sparseValLen]; + for (int i = 0; i < sparseValLen; i++) { + sparseValues[i] = unpackNumberAsDouble(unpacker); + } + tuple[6] = sparseValues; + } + return tuple; } catch (IOException e) { throw new EndeeException("Failed to unpack vector", e); } } - /** - * Helper function to unpackNumber as double even though it can be integer - * - * @param unpacker - * @return - * @throws IOException - */ private static double unpackNumberAsDouble(MessageUnpacker unpacker) throws IOException { Value value = unpacker.unpackValue(); diff --git a/src/main/java/io/endee/client/util/ValidationUtils.java b/src/main/java/io/endee/client/util/ValidationUtils.java index b617d04..7fac7ed 100644 --- a/src/main/java/io/endee/client/util/ValidationUtils.java +++ b/src/main/java/io/endee/client/util/ValidationUtils.java @@ -18,7 +18,7 @@ public static boolean isValidIndexName(String name) { if (name == null || name.isEmpty()) { return false; } - if (name.length() >= MAX_INDEX_NAME_LENGTH) { + if (name.length() > MAX_INDEX_NAME_LENGTH) { return false; } return INDEX_NAME_PATTERN.matcher(name).matches();