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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### 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)
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,29 @@ public SortedBinaryDocValues getBytesValues() {
* iterator so that multiple {@code Leaf} instances obtained from the same
* {@code KNNVectorDVLeafFieldData} cannot interfere with each other's state.
*
* <p><b>Return type:</b> {@code float[]} for FLOAT vectors —
* {@link org.opensearch.core.xcontent.XContentBuilder} serializes this as a JSON numeric array.
* <p><b>Return type:</b> Depends on the format:
* <ul>
* <li>Array format ({@link KNNVectorDocValueFormat#ARRAY_FORMAT}): returns {@code float[]},
* serialized by {@link org.opensearch.core.xcontent.XContentBuilder} as a JSON numeric array.</li>
* <li>Binary format ({@link KNNVectorDocValueFormat#BINARY_FORMAT}, the default): returns a
* base64-encoded {@link String} of little-endian float bytes.</li>
* </ul>
*
* @param format the doc value format — currently unused but required by the interface
* @param format the {@link KNNVectorDocValueFormat} that determines output encoding (array or binary)
* @return a leaf fetcher that yields vector values per document, or an empty fetcher
* if the field has no vectors in this segment
* @throws UnsupportedOperationException if the vector data type is BYTE or BINARY
* @throws IllegalArgumentException if format is not an instance of {@link KNNVectorDocValueFormat}
*/
@Override
public DocValueFetcher.Leaf getLeafValueFetcher(final DocValueFormat format) {
if (!(format instanceof KNNVectorDocValueFormat knnFormat)) {
throw new IllegalArgumentException(
"Unsupported DocValueFormat [" + format + "] for knn_vector field '" + fieldName + "'. Expected KNNVectorDocValueFormat."
);
}
final boolean isBinary = knnFormat.isBinary();

if (vectorDataType == VectorDataType.BYTE || vectorDataType == VectorDataType.BINARY) {
throw new UnsupportedOperationException(
"docvalue_fields is not supported for [" + vectorDataType + "] vector field '" + fieldName + "'"
Expand Down Expand Up @@ -152,6 +165,12 @@ public int docValueCount() {

@Override
public Object nextValue() throws IOException {
// We don't need a conditional clone here since encodeToBinary will convert the array to string.
if (isBinary) {
return KNNVectorDocValueFormat.encodeToBinary((float[]) vectorValues.getVector());
}
// We need a conditional clone since vector returned from here will be added in a map, so we do the clone
// since vectorValues keep a single copy of vector array for all docs.
return vectorValues.conditionalCloneVector();
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.knn.index;

import lombok.Getter;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;
import org.opensearch.search.DocValueFormat;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Base64;

/**
* DocValueFormat for knn_vector fields. Supports two modes:
* <ul>
* <li>{@code array} — vectors are returned as JSON numeric arrays</li>
* <li>{@code binary} (default) — vectors are returned as base64-encoded little-endian byte strings (with padding)</li>
* </ul>
*/
@Getter
public enum KNNVectorDocValueFormat implements DocValueFormat {

ARRAY_FORMAT("array", false),
BINARY_FORMAT("binary", true);

public static final String NAME = "knn_vector";

private final String formatName;
private final boolean binary;
private static final Base64.Encoder BASE64_ENCODER = Base64.getEncoder();

KNNVectorDocValueFormat(final String formatName, boolean binary) {
this.formatName = formatName;
this.binary = binary;
}

public static KNNVectorDocValueFormat fromStream(final StreamInput in) throws IOException {
return in.readBoolean() ? BINARY_FORMAT : ARRAY_FORMAT;
}

/**
* Resolves the format string to the corresponding enum constant.
* Returns {@link #BINARY_FORMAT} when format is null (the default).
*
* @param format the format string from the docvalue_fields request, or null for default
* @return the matching {@link KNNVectorDocValueFormat}
* @throws IllegalArgumentException if the format string is not recognized
*/
public static KNNVectorDocValueFormat fromFormatString(final String format) {
if (format == null || BINARY_FORMAT.formatName.equals(format)) {
return BINARY_FORMAT;
}
if (ARRAY_FORMAT.formatName.equals(format)) {
return ARRAY_FORMAT;
}
throw new IllegalArgumentException(
"Unsupported knn_vector docvalue_fields format ["
+ format
+ "]. Supported formats are "
+ Arrays.toString(KNNVectorDocValueFormat.values())
);
}

@Override
public String getWriteableName() {
return NAME;
}

@Override
public void writeTo(final StreamOutput out) throws IOException {
out.writeBoolean(binary);
}

/**
* Encodes a float[] vector as a base64 string with little-endian byte order.
* Each float is written as 4 bytes in little-endian format (native byte order on x86/ARM),
* then the resulting byte array is base64-encoded.
*/
public static String encodeToBinary(final float[] vector) {
final ByteBuffer buffer = ByteBuffer.allocate(vector.length * Float.BYTES).order(ByteOrder.LITTLE_ENDIAN);
buffer.asFloatBuffer().put(vector); // Bulk operation optimized by the JVM
final byte[] bytes = buffer.array();
return BASE64_ENCODER.encodeToString(bytes);
}

@Override
public String toString() {
return "knn_vector(" + formatName + ")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.opensearch.index.mapper.ValueFetcher;
import org.opensearch.index.query.QueryShardContext;
import org.opensearch.index.query.QueryShardException;
import org.opensearch.knn.index.KNNVectorDocValueFormat;
import org.opensearch.knn.index.KNNVectorIndexFieldData;
import org.opensearch.knn.index.VectorDataType;
import org.opensearch.knn.index.engine.KNNEngine;
Expand All @@ -29,9 +30,11 @@
import org.opensearch.knn.index.query.rescore.RescoreContext;
import org.opensearch.knn.indices.ModelDao;
import org.opensearch.knn.indices.ModelMetadata;
import org.opensearch.search.DocValueFormat;
import org.opensearch.search.aggregations.support.CoreValuesSourceType;
import org.opensearch.search.lookup.SearchLookup;

import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Locale;
Expand Down Expand Up @@ -152,6 +155,14 @@ public IndexFieldData.Builder fielddataBuilder(String fullyQualifiedIndexName, S
return new KNNVectorIndexFieldData.Builder(name(), CoreValuesSourceType.BYTES, this.vectorDataType);
}

@Override
public DocValueFormat docValueFormat(final String format, final ZoneId timeZone) {
if (timeZone != null) {
throw new IllegalArgumentException("Field [" + name() + "] of type [" + typeName() + "] does not support custom time zones");
}
Comment thread
navneet1v marked this conversation as resolved.
return KNNVectorDocValueFormat.fromFormatString(format);
}

@Override
public Object valueForDisplay(Object value) {
return deserializeStoredVector((BytesRef) value, vectorDataType);
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/org/opensearch/knn/plugin/KNNPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.opensearch.index.shard.IndexSettingProvider;
import org.opensearch.indices.SystemIndexDescriptor;
import org.opensearch.knn.index.KNNCircuitBreaker;
import org.opensearch.knn.index.KNNVectorDocValueFormat;
import org.opensearch.knn.index.KNNSettings;
import org.opensearch.knn.index.codec.KNNCodecService;
import org.opensearch.knn.index.codec.derivedsource.DerivedSourceIndexOperationListener;
Expand Down Expand Up @@ -120,6 +121,7 @@
import org.opensearch.script.ScriptContext;
import org.opensearch.script.ScriptEngine;
import org.opensearch.script.ScriptService;
import org.opensearch.search.DocValueFormat;
import org.opensearch.search.SearchExtBuilder;
import org.opensearch.search.deciders.ConcurrentSearchRequestDecider;
import org.opensearch.search.pipeline.SearchRequestProcessor;
Expand Down Expand Up @@ -447,6 +449,9 @@ public List<NamedWriteableRegistry.Entry> getNamedWriteables() {

entries.add(new NamedWriteableRegistry.Entry(Metadata.Custom.class, ModelGraveyard.TYPE, ModelGraveyard::new));
entries.add(new NamedWriteableRegistry.Entry(NamedDiff.class, ModelGraveyard.TYPE, ModelGraveyard::readDiffFrom));
entries.add(
new NamedWriteableRegistry.Entry(DocValueFormat.class, KNNVectorDocValueFormat.NAME, KNNVectorDocValueFormat::fromStream)
);
return entries;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
import org.opensearch.index.fielddata.ScriptDocValues;
import org.opensearch.index.mapper.DocValueFetcher;
import org.opensearch.knn.KNNTestCase;
import org.opensearch.search.DocValueFormat;
import org.junit.Before;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

public class KNNVectorDVLeafFieldDataTests extends KNNTestCase {

Expand Down Expand Up @@ -101,7 +102,7 @@ public void testGetLeafValueFetcher_floatVector_returnsCorrectValues() throws IO
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(DocValueFormat.RAW);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT);
assertNotNull(leaf);

float[][] results = new float[ALL_VECTORS.length][];
Expand All @@ -124,7 +125,7 @@ public void testGetLeafValueFetcher_advanceExact_nonExistentDoc_returnsFalse() t
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(DocValueFormat.RAW);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT);

boolean[] advanceResults = new boolean[ALL_VECTORS.length + 1];
int[] docValueCounts = new int[ALL_VECTORS.length + 1];
Expand All @@ -150,7 +151,7 @@ public void testGetLeafValueFetcher_docValueCount_isOne() throws IOException {
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(DocValueFormat.RAW);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT);

int[] docValueCounts = new int[ALL_VECTORS.length];
for (int docId = 0; docId < ALL_VECTORS.length; docId++) {
Expand Down Expand Up @@ -195,7 +196,7 @@ public void testGetLeafValueFetcher_multipleDocuments_iteratesCorrectly() throws
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(DocValueFormat.RAW);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT);

float[][] expected = { vector1, vector2, vector3 };
float[][] results = new float[expected.length][];
Expand All @@ -212,6 +213,78 @@ public void testGetLeafValueFetcher_multipleDocuments_iteratesCorrectly() throws
}
}

public void testGetLeafValueFetcher_binaryFormat_returnsBase64String() throws IOException {
KNNVectorDVLeafFieldData leafFieldData = new KNNVectorDVLeafFieldData(
leafReaderContext.reader(),
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.BINARY_FORMAT);
assertNotNull(leaf);

assertTrue(leaf.advanceExact(0));
assertEquals(1, leaf.docValueCount());
Object value = leaf.nextValue();
assertTrue(value instanceof String);
String base64 = (String) value;

// Decode and verify
byte[] decoded = java.util.Base64.getDecoder().decode(base64);
assertEquals(SAMPLE_VECTOR_1.length * Float.BYTES, decoded.length);
ByteBuffer buffer = ByteBuffer.wrap(decoded).order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < SAMPLE_VECTOR_1.length; i++) {
assertEquals(SAMPLE_VECTOR_1[i], buffer.getFloat(), 0.001f);
}
}

public void testGetLeafValueFetcher_nonKNNFormat_throwsIllegalArgument() {
KNNVectorDVLeafFieldData leafFieldData = new KNNVectorDVLeafFieldData(
leafReaderContext.reader(),
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
IllegalArgumentException ex = expectThrows(
IllegalArgumentException.class,
() -> leafFieldData.getLeafValueFetcher(org.opensearch.search.DocValueFormat.RAW)
);
assertTrue("Error should mention unsupported format", ex.getMessage().contains("Unsupported DocValueFormat"));
assertTrue("Error should mention the field name", ex.getMessage().contains(MOCK_INDEX_FIELD_NAME));
}

public void testGetLeafValueFetcher_nullFormat_throwsIllegalArgument() {
KNNVectorDVLeafFieldData leafFieldData = new KNNVectorDVLeafFieldData(
leafReaderContext.reader(),
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> leafFieldData.getLeafValueFetcher(null));
assertTrue("Error should mention unsupported format", ex.getMessage().contains("Unsupported DocValueFormat"));
assertTrue("Error should mention the field name", ex.getMessage().contains(MOCK_INDEX_FIELD_NAME));
}

public void testGetLeafValueFetcher_binaryFormat_multipleDocuments() throws IOException {
KNNVectorDVLeafFieldData leafFieldData = new KNNVectorDVLeafFieldData(
leafReaderContext.reader(),
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.BINARY_FORMAT);

for (int docId = 0; docId < ALL_VECTORS.length; docId++) {
assertTrue("advanceExact should succeed for doc " + docId, leaf.advanceExact(docId));
assertEquals("docValueCount should be 1 for doc " + docId, 1, leaf.docValueCount());
Object value = leaf.nextValue();
assertTrue("Binary format should produce a String for doc " + docId, value instanceof String);

byte[] decoded = java.util.Base64.getDecoder().decode((String) value);
assertEquals("Decoded byte length mismatch for doc " + docId, ALL_VECTORS[docId].length * Float.BYTES, decoded.length);
ByteBuffer buffer = ByteBuffer.wrap(decoded).order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < ALL_VECTORS[docId].length; i++) {
assertEquals("Value mismatch at index " + i + " for doc " + docId, ALL_VECTORS[docId][i], buffer.getFloat(), 0.001f);
}
}
}

public void testGetLeafValueFetcher_byteVectorDataType_throwsUnsupportedOp() {
KNNVectorDVLeafFieldData leafFieldData = new KNNVectorDVLeafFieldData(
leafReaderContext.reader(),
Expand All @@ -220,7 +293,7 @@ public void testGetLeafValueFetcher_byteVectorDataType_throwsUnsupportedOp() {
);
UnsupportedOperationException ex = expectThrows(
UnsupportedOperationException.class,
() -> leafFieldData.getLeafValueFetcher(DocValueFormat.RAW)
() -> leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT)
);
assertTrue(ex.getMessage().contains("docvalue_fields is not supported"));
assertTrue(ex.getMessage().contains("BYTE"));
Expand All @@ -234,7 +307,7 @@ public void testGetLeafValueFetcher_binaryVectorDataType_throwsUnsupportedOp() {
);
UnsupportedOperationException ex = expectThrows(
UnsupportedOperationException.class,
() -> leafFieldData.getLeafValueFetcher(DocValueFormat.RAW)
() -> leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT)
);
assertTrue(ex.getMessage().contains("docvalue_fields is not supported"));
assertTrue(ex.getMessage().contains("BINARY"));
Expand All @@ -261,20 +334,17 @@ public void testGetLeafValueFetcher_fieldNotInSegment_returnsEmptyLeaf() throws
MOCK_INDEX_FIELD_NAME,
VectorDataType.FLOAT
);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(DocValueFormat.RAW);
assertNotNull(leaf);
DocValueFetcher.Leaf leaf = leafFieldData.getLeafValueFetcher(KNNVectorDocValueFormat.ARRAY_FORMAT);
assertNotNull("Empty leaf should not be null", leaf);

boolean[] advanceResults = new boolean[numDocs];
int[] docValueCounts = new int[numDocs];
// Verify advanceExact returns false for all docs
for (int docId = 0; docId < numDocs; docId++) {
advanceResults[docId] = leaf.advanceExact(docId);
docValueCounts[docId] = leaf.docValueCount();
assertFalse("advanceExact should fail for doc " + docId + " in segment without vector field", leaf.advanceExact(docId));
assertEquals("docValueCount should be 0 for doc " + docId, 0, leaf.docValueCount());
}

for (int docId = 0; docId < numDocs; docId++) {
assertFalse("advanceExact should fail for doc " + docId + " in segment without vector field", advanceResults[docId]);
assertEquals(0, docValueCounts[docId]);
}
// Verify nextValue returns null on the empty leaf
assertNull("Empty leaf nextValue should return null", leaf.nextValue());
}
}
}
Expand Down
Loading
Loading