Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,20 @@ All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). See the [CONTRIBUTING guide](./CONTRIBUTING.md#Changelog) for instructions on how to add changelog entries.

## [Unreleased 3.7](https://github.com/opensearch-project/k-NN/compare/main...HEAD)
## [Unreleased 3.8](https://github.com/opensearch-project/k-NN/compare/main...HEAD)
### Features
* Add debug mode to MMR rerank that injects per-hit scoring details (original_score, max_similarity_to_selected, mmr_score, mmr_formula) into _source via the `debug` flag in the mmr search extension [#3254](https://github.com/opensearch-project/k-NN/pull/3254)
* Support derived source for knn with other fields [#3260](https://github.com/opensearch-project/k-NN/pull/3260)
* Added support for 1 bit SQ with remote build [#3270](https://github.com/opensearch-project/k-NN/pull/3270)
* Add rescoring phase after radial search on quantized index [#3347](https://github.com/opensearch-project/k-NN/pull/3347)
* Add base64 encoded vector indexing support for knn_vector fields [#3350](https://github.com/opensearch-project/k-NN/pull/3350)

### Maintenance
* Update Gradle wrapper to 9.4.1 and Jacoco to 0.8.14 to match core OpenSearch [#3308](https://github.com/opensearch-project/k-NN/pull/3308)

### Bug Fixes
* Turn off ACORN for MOS to match default Lucene HNSW behavior [#3346](https://github.com/opensearch-project/k-NN/pull/3346)
* Use KNN1040ScalarQuantizedVectorsFormat for Faiss SQ flat format to enable prefetch [#3302](https://github.com/opensearch-project/k-NN/pull/3302)
* Preserve mixed-case derived source vector field names and add backward-compatible field resolution for previously lowercased segment metadata [#3313](https://github.com/opensearch-project/k-NN/pull/3313)
* Fix score to radius conversion for IP with faiss [#3336](https://github.com/opensearch-project/k-NN/pull/3336)
* Fix rescore flag not propagating over transport layer in multi-node clusters [#3343](https://github.com/opensearch-project/k-NN/pull/3343)


### Refactoring


### Enhancements
* Add the bulkscore logic in MOS when K is greater than number of docs in segment [#3285](https://github.com/opensearch-project/k-NN/pull/3285)
* Added capability to retrieve float data type vectors using doc_values [#3321](https://github.com/opensearch-project/k-NN/pull/3321)
* Add base64 binary encoding as default format for knn_vector docvalue_fields [#3324](https://github.com/opensearch-project/k-NN/pull/3324)
* Add support for binary and byte field support in doc_values [#3340](https://github.com/opensearch-project/k-NN/pull/3340)
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
package org.opensearch.knn.index.mapper;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -73,6 +76,7 @@ public abstract class KNNVectorFieldMapper extends ParametrizedFieldMapper {

public static final String CONTENT_TYPE = "knn_vector";
public static final String KNN_FIELD = "knn_field";
private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder();

private static KNNVectorFieldMapper toType(FieldMapper in) {
return (KNNVectorFieldMapper) in;
Expand Down Expand Up @@ -832,6 +836,21 @@ Optional<byte[]> getBytesFromContext(ParseContext context, int dimension, Vector
perDimensionValidator.validateByte(value);
vector.add((byte) value);
context.parser().nextToken();
} else if (token == XContentParser.Token.VALUE_STRING) {
final byte[] decoded;
try {
decoded = BASE64_DECODER.decode(context.parser().text());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "Invalid base64 encoding for vector field [%s]: %s", name(), e.getMessage()),
e
);
}
validateVectorDimension(dimension, decoded.length, dataType);
Comment thread
navneet1v marked this conversation as resolved.
Resolved
// Per-dimension validation is intentionally skipped: base64-decoded bytes are inherently
// in [-128, 127] range, cannot be NaN/Inf, and have no decimal component — all checks
// that validateByte() performs are satisfied by construction for raw byte values.
return Optional.of(decoded);
} else if (token == XContentParser.Token.VALUE_NULL) {
context.path().remove();
return Optional.empty();
Expand Down Expand Up @@ -867,6 +886,36 @@ Optional<float[]> getFloatsFromContext(ParseContext context, int dimension) thro
perDimensionValidator.validate(value);
vector.add(value);
context.parser().nextToken();
} else if (token == XContentParser.Token.VALUE_STRING) {
final byte[] decoded;
try {
decoded = BASE64_DECODER.decode(context.parser().text());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "Invalid base64 encoding for vector field [%s]: %s", name(), e.getMessage()),
e
);
}
if (decoded.length % Float.BYTES != 0) {
throw new IllegalArgumentException(
String.format(
Locale.ROOT,
"Base64 encoded vector for field [%s] has invalid byte length [%d], must be a multiple of %d (float size)",
name(),
decoded.length,
Float.BYTES
)
);
}
int numFloats = decoded.length / Float.BYTES;
validateVectorDimension(dimension, numFloats, vectorDataType);
final float[] array = new float[numFloats];
ByteBuffer.wrap(decoded).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(array);
for (int idx = 0; idx < numFloats; idx++) {
array[idx] = perDimensionProcessor.process(array[idx]);
perDimensionValidator.validate(array[idx]);
}
return Optional.of(array);
} else if (token == XContentParser.Token.VALUE_NULL) {
context.path().remove();
return Optional.empty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2865,6 +2865,168 @@ private XContentBuilder createXContentForFieldMapping_Engine(
return xContentBuilder;
}

public void testBase64Indexing_allDataTypes_parsesCorrectly() throws IOException {
// --- Float ---
float[] expectedFloat = TEST_VECTOR;
java.nio.ByteBuffer buffer = java.nio.ByteBuffer.allocate(expectedFloat.length * Float.BYTES)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
buffer.asFloatBuffer().put(expectedFloat);
String floatBase64 = java.util.Base64.getEncoder().encodeToString(buffer.array());

EngineFieldMapper floatMapper = createFieldMapperForBase64Test(VectorDataType.FLOAT, TEST_DIMENSION);
ParseContext floatCtx = createParseContextForBase64(floatBase64);
Optional<float[]> floatResult = floatMapper.getFloatsFromContext(floatCtx, TEST_DIMENSION);
assertTrue("Float base64 should parse", floatResult.isPresent());
assertArrayEquals(expectedFloat, floatResult.get(), 0.0f);

// --- Byte ---
byte[] expectedByte = TEST_BYTE_VECTOR;
String byteBase64 = java.util.Base64.getEncoder().encodeToString(expectedByte);

EngineFieldMapper byteMapper = createFieldMapperForBase64Test(VectorDataType.BYTE, TEST_DIMENSION);
ParseContext byteCtx = createParseContextForBase64(byteBase64);
Optional<byte[]> byteResult = byteMapper.getBytesFromContext(byteCtx, TEST_DIMENSION, VectorDataType.BYTE);
assertTrue("Byte base64 should parse", byteResult.isPresent());
assertArrayEquals(expectedByte, byteResult.get());

// --- Binary (dimension = bits, so dimension 8*TEST_DIMENSION needs TEST_DIMENSION bytes) ---
int binaryDimension = TEST_DIMENSION * Byte.SIZE;
byte[] expectedBinary = TEST_BYTE_VECTOR;
String binaryBase64 = java.util.Base64.getEncoder().encodeToString(expectedBinary);

EngineFieldMapper binaryMapper = createFieldMapperForBase64Test(VectorDataType.BINARY, binaryDimension);
ParseContext binaryCtx = createParseContextForBase64(binaryBase64);
Optional<byte[]> binaryResult = binaryMapper.getBytesFromContext(binaryCtx, binaryDimension, VectorDataType.BINARY);
assertTrue("Binary base64 should parse", binaryResult.isPresent());
assertArrayEquals(expectedBinary, binaryResult.get());
}

public void testBase64Indexing_invalidInput_throwsException() throws IOException {
// --- Float: byte length not multiple of 4 ---
String invalidFloatBase64 = java.util.Base64.getEncoder().encodeToString(new byte[] { 1, 2, 3 });
EngineFieldMapper floatMapper = createFieldMapperForBase64Test(VectorDataType.FLOAT, TEST_DIMENSION);
ParseContext floatCtx = createParseContextForBase64(invalidFloatBase64);
IllegalArgumentException floatEx = expectThrows(
IllegalArgumentException.class,
() -> floatMapper.getFloatsFromContext(floatCtx, TEST_DIMENSION)
);
assertTrue(floatEx.getMessage().contains("invalid byte length"));

// --- Byte: wrong dimension ---
byte[] wrongDimByteVector = new byte[TEST_DIMENSION + 1];
Arrays.fill(wrongDimByteVector, (byte) 5);
String wrongDimByteBase64 = java.util.Base64.getEncoder().encodeToString(wrongDimByteVector);
EngineFieldMapper byteMapper = createFieldMapperForBase64Test(VectorDataType.BYTE, TEST_DIMENSION);
ParseContext byteCtx = createParseContextForBase64(wrongDimByteBase64);
expectThrows(IllegalArgumentException.class, () -> byteMapper.getBytesFromContext(byteCtx, TEST_DIMENSION, VectorDataType.BYTE));

// --- Binary: wrong dimension (dimension=16 bits expects 2 bytes, provide 3) ---
int binaryDimension = 16;
byte[] wrongDimBinaryVector = new byte[] { 1, 2, 3 };
String wrongDimBinaryBase64 = java.util.Base64.getEncoder().encodeToString(wrongDimBinaryVector);
EngineFieldMapper binaryMapper = createFieldMapperForBase64Test(VectorDataType.BINARY, binaryDimension);
ParseContext binaryCtx = createParseContextForBase64(wrongDimBinaryBase64);
expectThrows(
IllegalArgumentException.class,
() -> binaryMapper.getBytesFromContext(binaryCtx, binaryDimension, VectorDataType.BINARY)
);
}

public void testBase64Indexing_malformedBase64_throwsDescriptiveError() throws IOException {
String malformedBase64 = "not!valid@base64$$";

// Float path: malformed base64 should mention field name
EngineFieldMapper floatMapper = createFieldMapperForBase64Test(VectorDataType.FLOAT, TEST_DIMENSION);
ParseContext floatCtx = createParseContextForBase64(malformedBase64);
IllegalArgumentException floatEx = expectThrows(
IllegalArgumentException.class,
() -> floatMapper.getFloatsFromContext(floatCtx, TEST_DIMENSION)
);
assertTrue(floatEx.getMessage().contains("Invalid base64 encoding for vector field"));
assertTrue(floatEx.getMessage().contains(TEST_FIELD_NAME));
assertNotNull(floatEx.getCause());

// Byte path: malformed base64 should mention field name
EngineFieldMapper byteMapper = createFieldMapperForBase64Test(VectorDataType.BYTE, TEST_DIMENSION);
ParseContext byteCtx = createParseContextForBase64(malformedBase64);
IllegalArgumentException byteEx = expectThrows(
IllegalArgumentException.class,
() -> byteMapper.getBytesFromContext(byteCtx, TEST_DIMENSION, VectorDataType.BYTE)
);
assertTrue(byteEx.getMessage().contains("Invalid base64 encoding for vector field"));
assertTrue(byteEx.getMessage().contains(TEST_FIELD_NAME));
assertNotNull(byteEx.getCause());
}

private EngineFieldMapper createFieldMapperForBase64Test(VectorDataType dataType, int dimension) {
KNNMethodConfigContext knnMethodConfigContext = KNNMethodConfigContext.builder()
.vectorDataType(dataType)
.versionCreated(CURRENT)
.dimension(dimension)
.build();

KNNMethodContext luceneMethodContext = new KNNMethodContext(
KNNEngine.LUCENE,
SpaceType.DEFAULT,
new MethodComponentContext(METHOD_HNSW, Collections.emptyMap())
);

OriginalMappingParameters originalMappingParameters = new OriginalMappingParameters(
dataType,
dimension,
luceneMethodContext,
Mode.NOT_CONFIGURED.getName(),
CompressionLevel.NOT_CONFIGURED.getName(),
null,
SpaceType.UNDEFINED.getValue(),
KNNEngine.UNDEFINED.getName()
);
originalMappingParameters.setResolvedKnnMethodContext(originalMappingParameters.getKnnMethodContext());

return EngineFieldMapper.createFieldMapper(
TEST_FIELD_NAME,
TEST_FIELD_NAME,
Collections.emptyMap(),
knnMethodConfigContext,
FieldMapper.MultiFields.empty(),
FieldMapper.CopyTo.empty(),
new Explicit<>(true, true),
false,
true,
originalMappingParameters,
CURRENT
);
}

@SneakyThrows
private ParseContext createParseContextForBase64(String base64Value) {
XContentParser parser = createXContentParserForBase64(base64Value);
IndexSettings indexSettingsMock = mock(IndexSettings.class);
when(indexSettingsMock.getSettings()).thenReturn(Settings.EMPTY);
ParseContext.Document document = new ParseContext.Document();
ContentPath contentPath = new ContentPath();
ParseContext parseContext = mock(ParseContext.class);
when(parseContext.doc()).thenReturn(document);
when(parseContext.path()).thenReturn(contentPath);
when(parseContext.parser()).thenReturn(parser);
when(parseContext.indexSettings()).thenReturn(indexSettingsMock);
return parseContext;
}

@SneakyThrows
private XContentParser createXContentParserForBase64(final String base64Value) {
XContentParser parser = XContentHelper.createParser(
NamedXContentRegistry.EMPTY,
LoggingDeprecationHandler.INSTANCE,
new BytesArray("{\"" + TEST_FIELD_NAME + "\":\"" + base64Value + "\"}"),
MediaTypeRegistry.JSON
);
parser.nextToken();
parser.nextToken();
parser.nextToken();
return parser;
}

private static float[] createInitializedFloatArray(int dimension, float value) {
float[] array = new float[dimension];
Arrays.fill(array, value);
Expand Down
Loading
Loading