From 517e2c4d7b37dbc8ab83ffc6361b8f5560e789e5 Mon Sep 17 00:00:00 2001 From: Dooyong Kim Date: Tue, 11 Mar 2025 11:14:48 -0700 Subject: [PATCH] Added FaissHNSW and bridge to Lucene HNSW graph. Signed-off-by: Dooyong Kim --- .../knn/memoryoptsearch/faiss/FaissHNSW.java | 94 +++++++++ .../faiss/FaissHNSWFlatIndex.java | 47 ----- .../memoryoptsearch/faiss/FaissHNSWIndex.java | 80 ++++++++ .../memoryoptsearch/faiss/FaissHnswGraph.java | 187 ++++++++++++++++++ .../faiss/FaissIdMapIndex.java | 14 +- .../memoryoptsearch/faiss/FaissSection.java | 55 ++++++ .../faiss/IndexTypeToFaissIndexMapping.java | 14 +- .../knn/memoryoptsearch/FaissHNSWTests.java | 176 +++++++++++++++++ .../memoryoptsearch/FaissHnswGraphTests.java | 120 +++++++++++ .../memoryoptsearch/FaissIdMapIndexTests.java | 4 +- .../faiss_flat_float_50_vectors_128_dim.bin | Bin 0 -> 26065 bytes .../faiss_hnsw_100_vectors.bin | Bin 0 -> 14626 bytes .../memoryoptsearch/faiss_hnsw_one_vector.bin | Bin 0 -> 308 bytes 13 files changed, 728 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSW.java delete mode 100644 src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWFlatIndex.java create mode 100644 src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWIndex.java create mode 100644 src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHnswGraph.java create mode 100644 src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissSection.java create mode 100644 src/test/java/org/opensearch/knn/memoryoptsearch/FaissHNSWTests.java create mode 100644 src/test/java/org/opensearch/knn/memoryoptsearch/FaissHnswGraphTests.java create mode 100644 src/test/resources/data/memoryoptsearch/faiss_flat_float_50_vectors_128_dim.bin create mode 100644 src/test/resources/data/memoryoptsearch/faiss_hnsw_100_vectors.bin create mode 100644 src/test/resources/data/memoryoptsearch/faiss_hnsw_one_vector.bin diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSW.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSW.java new file mode 100644 index 0000000000..7f38a1394c --- /dev/null +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSW.java @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch.faiss; + +import lombok.Getter; +import org.apache.lucene.store.IndexInput; + +import java.io.IOException; + +/** + * While it follows the same steps as the original FAISS deserialization, differences in how the JVM and C++ handle floating-point + * calculations can lead to slight variations in results. However, such cases are very rare, and in most instances, the results are + * identical to FAISS. Even when there are ranking differences, they do not impact the precision or recall of the search. + * For more details, refer to the [FAISS HNSW implementation]( + * ...). + */ +@Getter +public class FaissHNSW { + // Cumulative number of neighbors per each level. + private int[] cumNumberNeighborPerLevel; + // offsets[i]:offset[i+1] gives all the neighbors for vector i + // Offset to be added to cumNumberNeighborPerLevel[level] to get the actual start offset of neighbor list. + private long[] offsets = null; + // Neighbor list storage. + private FaissSection neighbors; + // levels[i] = the maximum levels of `i`th vector + 1. + // Ex: If 544th vector has three levels (e.g. 0-level, 1-level, 2-level), then levels[433] would be 3. + // This indicates that 544th vector exists at all levels of (0-level, 1-level, 2-level). + private FaissSection levels; + // Entry point in HNSW graph + private int entryPoint; + // Maximum level of HNSW graph + private int maxLevel = -1; + // Default efSearch parameter. This determines the navigation queue size. + // More value, algorithm will more navigate candidates. + private int efSearch = 16; + // Total number of vectors stored in graph. + private long totalNumberOfVectors; + + /** + * Partially loads the FAISS HNSW graph from the provided index input stream. + * The graph is divided into multiple sections, and this method marks the starting offset of each section then skip to the next + * section instead of loading the entire graph into memory. During the search, bytes will be accessed via {@link IndexInput}. + * + * @param input An input stream for a FAISS HNSW graph file, allowing access to the neighbor list and vector locations. + * @param totalNumberOfVectors The total number of vectors stored in the graph. + * + * FYI FAISS Deserialization + * + * @throws IOException + */ + public void load(IndexInput input, long totalNumberOfVectors) throws IOException { + // Total number of vectors + this.totalNumberOfVectors = totalNumberOfVectors; + + // We don't use `double[] assignProbas` for search. It is for index construction. + long size = input.readLong(); + input.skipBytes(Double.BYTES * size); + + // Accumulate number of neighbor per each level. + size = input.readLong(); + cumNumberNeighborPerLevel = new int[Math.toIntExact(size)]; + if (size > 0) { + input.readInts(cumNumberNeighborPerLevel, 0, (int) size); + } + + // Maximum levels per each vector + levels = new FaissSection(input, Integer.BYTES); + + // Load `offsets` into memory. + size = input.readLong(); + offsets = new long[(int) size]; + input.readLongs(offsets, 0, offsets.length); + + // Mark neighbor list section. + neighbors = new FaissSection(input, Integer.BYTES); + + // HNSW graph parameters + entryPoint = input.readInt(); + + maxLevel = input.readInt(); + + // Gets efConstruction. We don't use this field. It's for index building. + input.readInt(); + + efSearch = input.readInt(); + + // dummy read a deprecated field. + input.readInt(); + } +} diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWFlatIndex.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWFlatIndex.java deleted file mode 100644 index 023b877c32..0000000000 --- a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWFlatIndex.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.opensearch.knn.memoryoptsearch.faiss; - -import org.apache.lucene.index.ByteVectorValues; -import org.apache.lucene.index.FloatVectorValues; -import org.apache.lucene.index.VectorEncoding; -import org.apache.lucene.store.IndexInput; - -import java.io.IOException; - -/** - * A flat HNSW index that contains both an HNSW graph and flat vector storage. - * This is the ported version of `IndexHNSW` from FAISS. - * For more details, please refer to ... - */ -public class FaissHNSWFlatIndex extends FaissIndex { - public FaissHNSWFlatIndex(final String indexType) { - super(indexType); - } - - @Override - protected void doLoad(IndexInput input) throws IOException { - // TODO(KDY) : This will be covered in part-3 (FAISS HNSW). - } - - @Override - public VectorEncoding getVectorEncoding() { - // TODO(KDY) : This will be covered in part-3 (FAISS HNSW). - return null; - } - - @Override - public FloatVectorValues getFloatValues(IndexInput indexInput) throws IOException { - // TODO(KDY) : This will be covered in part-3 (FAISS HNSW). - return null; - } - - @Override - public ByteVectorValues getByteValues(IndexInput indexInput) throws IOException { - // TODO(KDY) : This will be covered in part-3 (FAISS HNSW). - return null; - } -} diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWIndex.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWIndex.java new file mode 100644 index 0000000000..1122e2b8d9 --- /dev/null +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHNSWIndex.java @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch.faiss; + +import lombok.Getter; +import org.apache.lucene.index.ByteVectorValues; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.VectorEncoding; +import org.apache.lucene.store.IndexInput; + +import java.io.IOException; + +/** + * A flat HNSW index that contains both an HNSW graph and flat vector storage. + * This is the ported version of `IndexHNSW` from FAISS. + * For more details, please refer to ... + */ +public class FaissHNSWIndex extends FaissIndex { + // Flat float vector format - + // https://github.com/facebookresearch/faiss/blob/15491a1e4f5a513a8684e5b7262ef4ec22eda19d/faiss/IndexHNSW.h#L122 + public static final String IHNF = "IHNf"; + // Quantized flat format with HNSW - + // https://github.com/facebookresearch/faiss/blob/15491a1e4f5a513a8684e5b7262ef4ec22eda19d/faiss/IndexHNSW.h#L144C8-L144C19 + public static final String IHNS = "IHNs"; + + @Getter + private FaissHNSW hnsw = new FaissHNSW(); + private FaissIndex flatVectors; + private VectorEncoding vectorEncoding; + + public FaissHNSWIndex(final String indexType) { + super(indexType); + + // Set encoding + if (indexType.equals(IHNF)) { + vectorEncoding = VectorEncoding.FLOAT32; + } else if (indexType.equals(IHNS)) { + vectorEncoding = VectorEncoding.BYTE; + } else { + throw new IllegalStateException("Unsupported index type: " + indexType + " in " + FaissHNSWIndex.class.getSimpleName()); + } + } + + /** + * Loading HNSW graph and nested storage index. + * For more details, please refer to + * ... + * @param input + * @throws IOException + */ + @Override + protected void doLoad(IndexInput input) throws IOException { + // Read common header + readCommonHeader(input); + + // Partial load HNSW graph + hnsw.load(input, getTotalNumberOfVectors()); + + // Partial load flat vector storage + flatVectors = FaissIndex.load(input); + } + + @Override + public VectorEncoding getVectorEncoding() { + return vectorEncoding; + } + + @Override + public FloatVectorValues getFloatValues(IndexInput indexInput) throws IOException { + return flatVectors.getFloatValues(indexInput); + } + + @Override + public ByteVectorValues getByteValues(IndexInput indexInput) throws IOException { + return flatVectors.getByteValues(indexInput); + } +} diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHnswGraph.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHnswGraph.java new file mode 100644 index 0000000000..682731a1f9 --- /dev/null +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissHnswGraph.java @@ -0,0 +1,187 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch.faiss; + +import org.apache.lucene.codecs.KnnVectorsReader; +import org.apache.lucene.store.IndexInput; +import org.apache.lucene.util.hnsw.HnswGraph; + +import java.io.IOException; +import java.util.NoSuchElementException; + +import static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS; + +/** + * This graph implements Lucene's HNSW graph interface using the FAISS HNSW graph. Conceptually, both libraries represent the graph + * similarly, maintaining a list of neighbor IDs. This implementation acts as a bridge, enabling Lucene's HNSW graph searcher to perform + * vector searches on a FAISS index. + * + * NOTE: This is not thread safe. It should be created every time in {@link KnnVectorsReader}.search likewise + * OffHeapHnswGraph + * in Lucene. + */ +public class FaissHnswGraph extends HnswGraph { + private final FaissHNSW faissHnsw; + private final IndexInput indexInput; + private final int numVectors; + private int[] neighborIdList; + private int numNeighbors; + private int nextNeighborIndex; + + public FaissHnswGraph(final FaissHNSW faissHNSW, final int numVectors, final IndexInput indexInput) { + this.faissHnsw = faissHNSW; + this.indexInput = indexInput; + this.numVectors = numVectors; + } + + /** + * Seek to the starting offset of neighbor ids at the given `level`. In which, it will load all ids into a buffer array. + * @param level The level of graph + * @param internalVectorId An internal vector id. + */ + @Override + public void seek(int level, int internalVectorId) { + // Get a relative starting offset of neighbor list at `level`. + long o = faissHnsw.getOffsets()[internalVectorId]; + + // `begin` and `end` represent for a pair of staring offset and end offset. + // But, what `end` represents is the maximum offset a neighbor list at a level can have. + // Therefore, it is required to traverse a list until getting a terminal `-1`. + // Ex: [1, 5, 20, 100, -1, -1, ..., -1] + final long begin = o + faissHnsw.getCumNumberNeighborPerLevel()[level]; + final long end = o + faissHnsw.getCumNumberNeighborPerLevel()[level + 1]; + loadNeighborIdList(begin, end); + } + + private void loadNeighborIdList(final long begin, final long end) { + // Make sure we have sufficient space for neighbor list + final long maxLength = end - begin; + if (neighborIdList == null || neighborIdList.length < maxLength) { + neighborIdList = new int[(int) (maxLength)]; + } + + // Seek to the first offset of neighbor list + try { + indexInput.seek(faissHnsw.getNeighbors().getBaseOffset() + Integer.BYTES * begin); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // Fill the array with neighbor ids + int index = 0; + try { + for (long i = begin; i < end; i++) { + final int neighborId = indexInput.readInt(); + // The idea is that a vector does not always have a complete list of neighbor vectors. + // FAISS assigns a fixed size to the neighbor list and uses -1 to indicate missing entries. + // Therefore, we can safely stop once hit -1. + // For example, if the neighbor list size is 16 and a vector has only 8 neighbors, the list would appear as: + // [1, 4, 6, 8, 13, 17, 60, 88, -1, -1, ..., -1]. + if (neighborId >= 0) { + neighborIdList[index++] = neighborId; + } else { + break; + } + } + + // Set variables for navigation + numNeighbors = index; + nextNeighborIndex = 0; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public int size() { + return numVectors; + } + + @Override + public int nextNeighbor() { + if (nextNeighborIndex < numNeighbors) { + return neighborIdList[nextNeighborIndex++]; + } + + // Neighbor list has been exhausted. + return NO_MORE_DOCS; + } + + @Override + public int numLevels() { + return faissHnsw.getMaxLevel(); + } + + @Override + public int entryNode() { + return faissHnsw.getEntryPoint(); + } + + @Override + public NodesIterator getNodesOnLevel(final int level) { + try { + // Prepare input stream to `level` section. + final FaissSection levelsSection = faissHnsw.getLevels(); + final IndexInput levelIndexInput = indexInput.clone(); + levelIndexInput.seek(levelsSection.getBaseOffset()); + + // Count the number of vectors at the level. + int numVectorsAtLevel = 0; + for (int i = 0; i < numVectors; ++i) { + final int maxLevel = levelIndexInput.readInt(); + // Note that maxLevel=3 indicates that a vector exists level-0 (bottom), level-1 and level-2. + if (maxLevel > level) { + ++numVectorsAtLevel; + } + } + + // Return iterator + levelIndexInput.seek(levelsSection.getBaseOffset()); + return new NodesIterator(numVectorsAtLevel) { + int vectorNo = -1; + int numVisitedVectors = 0; + + @Override + public boolean hasNext() { + return numVisitedVectors < size; + } + + @Override + public int nextInt() { + while (true) { + try { + // Advance + ++vectorNo; + final int maxLevel = levelIndexInput.readInt(); + + // Check the level + if (maxLevel > level) { + ++numVisitedVectors; + return vectorNo; + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public int consume(int[] ints) { + if (hasNext() == false) { + throw new NoSuchElementException(); + } + final int copySize = Math.min(size - numVisitedVectors, ints.length); + for (int i = 0; i < copySize; ++i) { + ints[i] = nextInt(); + } + return copySize; + } + }; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissIdMapIndex.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissIdMapIndex.java index c5d43a384d..9d0ea13437 100644 --- a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissIdMapIndex.java +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissIdMapIndex.java @@ -24,15 +24,12 @@ * However, these IDs only cover the sparse 30% of Lucene documents, so an ID mapping is needed to convert the internal physical vector ID * into the corresponding Lucene document ID. * If the mapping is an identity mapping, where each `i` is mapped to itself, we omit storing it to save memory. - *

- * FYI : - * IndexIDMap.h */ public class FaissIdMapIndex extends FaissIndex { public static final String IXMP = "IxMp"; @Getter - private FaissHNSWFlatIndex nestedIndex; + private FaissHNSWIndex nestedIndex; private long[] vectorIdToDocIdMapping; public FaissIdMapIndex() { @@ -41,7 +38,8 @@ public FaissIdMapIndex() { /** * Partially load id mapping and its nested index to which vector searching will be delegated. - * + * Faiss deserialization code : + * IndexIDMap.h * @param input An input stream for a FAISS HNSW graph file, allowing access to the neighbor list and vector locations. * @throws IOException */ @@ -50,11 +48,11 @@ protected void doLoad(IndexInput input) throws IOException { readCommonHeader(input); final FaissIndex nestedIndex = FaissIndex.load(input); - if (nestedIndex instanceof FaissHNSWFlatIndex) { - this.nestedIndex = (FaissHNSWFlatIndex) nestedIndex; + if (nestedIndex instanceof FaissHNSWIndex) { + this.nestedIndex = (FaissHNSWIndex) nestedIndex; } else { throw new IllegalStateException( - "Invalid nested index. Expected " + FaissHNSWFlatIndex.class.getSimpleName() + " , but got " + nestedIndex.getIndexType() + "Invalid nested index. Expected " + FaissHNSWIndex.class.getSimpleName() + " , but got " + nestedIndex.getIndexType() ); } diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissSection.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissSection.java new file mode 100644 index 0000000000..19c71754b4 --- /dev/null +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/FaissSection.java @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch.faiss; + +import lombok.Getter; +import org.apache.lucene.store.IndexInput; + +import java.io.IOException; + +/** + * This section maps to a section in FAISS index with a starting offset and the section size. + * A FAISS index file consists of multiple logical sections, each beginning with four bytes indicating an index type. A section may contain + * a nested section or vector storage, forming a tree structure with a top-level index as the starting point. + * + * Ex: FAISS index file + * +------------+ -> 0 + * + + + * + IxMp + -> FaissSection(offset=0, section_size=120) + * +------------+ -> 120 + * + + + * + IHNf + -> FaissSection(offset=120, section_size=380) + * +------------+ -> 500 + * + + + * + IxF2 + -> FaissSection(offset=500, section_size=700) + * +------------+ -> 1200 + * + */ +public class FaissSection { + @Getter + private long baseOffset; + @Getter + private long sectionSize; + + /** + * Mark the starting offset and the size of section then skip to the next section. + * + * @param input Input read stream. + * @param singleElementSize Size of atomic element. In file, it only stores the number of elements and the size of element will be + * used to calculate the actual size of section. Ex: size=100, element=int, then the actual section size=400. + * @throws IOException + */ + public FaissSection(IndexInput input, int singleElementSize) throws IOException { + this.sectionSize = input.readLong() * singleElementSize; + this.baseOffset = input.getFilePointer(); + // Skip the whole section and jump to the next section in the file. + try { + input.seek(baseOffset + sectionSize); + } catch (IOException e) { + throw new IOException("Failed to partial load where baseOffset=" + baseOffset + ", sectionSize=" + sectionSize, e); + } + } +} diff --git a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/IndexTypeToFaissIndexMapping.java b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/IndexTypeToFaissIndexMapping.java index d2c5b1fd77..45b9fe498e 100644 --- a/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/IndexTypeToFaissIndexMapping.java +++ b/src/main/java/org/opensearch/knn/memoryoptsearch/faiss/IndexTypeToFaissIndexMapping.java @@ -10,7 +10,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.function.Supplier; +import java.util.function.Function; /** * This table maintains a mapping between FAISS index types and their corresponding index implementations. @@ -20,12 +20,14 @@ */ @UtilityClass public class IndexTypeToFaissIndexMapping { - private static final Map> INDEX_TYPE_TO_FAISS_INDEX; + private static final Map> INDEX_TYPE_TO_FAISS_INDEX; static { - final Map> mapping = new HashMap<>(); + final Map> mapping = new HashMap<>(); - mapping.put(FaissIdMapIndex.IXMP, FaissIdMapIndex::new); + mapping.put(FaissIdMapIndex.IXMP, (indexType) -> new FaissIdMapIndex()); + mapping.put(FaissHNSWIndex.IHNF, FaissHNSWIndex::new); + mapping.put(FaissHNSWIndex.IHNS, FaissHNSWIndex::new); INDEX_TYPE_TO_FAISS_INDEX = Collections.unmodifiableMap(mapping); } @@ -37,9 +39,9 @@ public class IndexTypeToFaissIndexMapping { * @return Actual implementation that is corresponding to the given index type. */ public FaissIndex getFaissIndex(final String indexType) { - final Supplier faissIndexSupplier = INDEX_TYPE_TO_FAISS_INDEX.get(indexType); + final Function faissIndexSupplier = INDEX_TYPE_TO_FAISS_INDEX.get(indexType); if (faissIndexSupplier != null) { - return faissIndexSupplier.get(); + return faissIndexSupplier.apply(indexType); } throw new UnsupportedFaissIndexException("Index type [" + indexType + "] is not supported."); } diff --git a/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHNSWTests.java b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHNSWTests.java new file mode 100644 index 0000000000..139227e494 --- /dev/null +++ b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHNSWTests.java @@ -0,0 +1,176 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch; + +import lombok.SneakyThrows; +import org.apache.lucene.store.IndexInput; +import org.opensearch.common.lucene.store.ByteArrayIndexInput; +import org.opensearch.knn.KNNTestCase; +import org.opensearch.knn.memoryoptsearch.faiss.FaissHNSW; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +public class FaissHNSWTests extends KNNTestCase { + @SneakyThrows + public void testLoadGraphWithSingleVector() { + final IndexInput indexInput = loadHnswBinary("data/memoryoptsearch/faiss_hnsw_one_vector.bin"); + final FaissHNSW faissHNSW = new FaissHNSW(); + faissHNSW.load(indexInput, 1); + doTest(faissHNSW, new int[] { 0, 32, 48, 64, 80, 96, 112, 128, 144 }, new long[] { 0, 32 }, 160, 128, 0, 0, 100); + } + + @SneakyThrows + public void testLoadGraphWithNVectors() { + final IndexInput indexInput = loadHnswBinary("data/memoryoptsearch/faiss_hnsw_100_vectors.bin"); + final FaissHNSW faissHNSW = new FaissHNSW(); + faissHNSW.load(indexInput, 100); + final int[] cumulativeNumNeighbors = new int[] { 0, 32, 48, 64, 80, 96, 112, 128, 144 }; + doTest(faissHNSW, cumulativeNumNeighbors, ANSWER_OFFSETS, 1348, 13184, 12, 1, 100); + } + + @SneakyThrows + public static IndexInput loadHnswBinary(final String relativePath) { + final URL hnswWithOneVector = FaissHNSWTests.class.getClassLoader().getResource(relativePath); + final byte[] bytes = Files.readAllBytes(Path.of(hnswWithOneVector.toURI())); + final IndexInput indexInput = new ByteArrayIndexInput("FaissHNSWTests", bytes); + return indexInput; + } + + private void doTest( + final FaissHNSW faissHNSW, + final int[] cumulativeNumNeighbors, + final long[] offsets, + final long neighborsBaseOffset, + final long neighborsSectionSize, + final int entryPoint, + final int maxLevel, + final int efSearch + ) { + // Cumulative number of neighbor per level + assertArrayEquals(cumulativeNumNeighbors, faissHNSW.getCumNumberNeighborPerLevel()); + + // offsets + assertArrayEquals(offsets, faissHNSW.getOffsets()); + + // neighbors + assertEquals(neighborsBaseOffset, faissHNSW.getNeighbors().getBaseOffset()); + assertEquals(neighborsSectionSize, faissHNSW.getNeighbors().getSectionSize()); + + // entry point + assertEquals(entryPoint, faissHNSW.getEntryPoint()); + + // max level + assertEquals(maxLevel, faissHNSW.getMaxLevel()); + + // efSearch + assertEquals(efSearch, faissHNSW.getEfSearch()); + } + + private static final long[] ANSWER_OFFSETS = new long[] { + 0, + 32, + 64, + 96, + 128, + 160, + 192, + 224, + 256, + 288, + 320, + 352, + 400, + 448, + 480, + 512, + 544, + 576, + 608, + 640, + 672, + 704, + 736, + 784, + 816, + 848, + 880, + 912, + 944, + 976, + 1008, + 1040, + 1072, + 1104, + 1136, + 1168, + 1200, + 1248, + 1280, + 1312, + 1344, + 1376, + 1408, + 1440, + 1472, + 1504, + 1536, + 1568, + 1600, + 1632, + 1664, + 1696, + 1728, + 1776, + 1808, + 1840, + 1872, + 1904, + 1936, + 1968, + 2000, + 2032, + 2064, + 2096, + 2128, + 2160, + 2192, + 2224, + 2256, + 2288, + 2320, + 2352, + 2384, + 2416, + 2464, + 2496, + 2528, + 2560, + 2592, + 2624, + 2656, + 2688, + 2720, + 2752, + 2784, + 2816, + 2848, + 2880, + 2912, + 2944, + 2976, + 3008, + 3040, + 3072, + 3104, + 3136, + 3168, + 3200, + 3232, + 3264, + 3296 }; +} diff --git a/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHnswGraphTests.java b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHnswGraphTests.java new file mode 100644 index 0000000000..e69598fa6f --- /dev/null +++ b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissHnswGraphTests.java @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.knn.memoryoptsearch; + +import lombok.SneakyThrows; +import org.apache.lucene.search.DocIdSetIterator; +import org.apache.lucene.store.IndexInput; +import org.apache.lucene.util.hnsw.HnswGraph; +import org.mockito.Mockito; +import org.opensearch.knn.KNNTestCase; +import org.opensearch.knn.memoryoptsearch.faiss.FaissHNSW; +import org.opensearch.knn.memoryoptsearch.faiss.FaissHNSWIndex; +import org.opensearch.knn.memoryoptsearch.faiss.FaissHnswGraph; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; + +import static org.mockito.Mockito.when; +import static org.opensearch.knn.memoryoptsearch.FaissHNSWTests.loadHnswBinary; + +public class FaissHnswGraphTests extends KNNTestCase { + private static final int NUM_VECTORS = 100; + + @SneakyThrows + public void testTraverseHnswGraph() { + final FaissHnswGraph graph = prepareFaissHnswGraph(); + + // Validate graph + graph.seek(0, 0); + assertArrayEquals(FIRST_NEIGHBOR_LIST_AT_0_LEVEL, getNeighborIdList(graph)); + + graph.seek(0, 99); + assertArrayEquals(NINETY_NINETH_NEIGHBOR_LIST_AT_0_LEVEL, getNeighborIdList(graph)); + + graph.seek(1, 0); + assertArrayEquals(FIRST_NEIGHBOR_LIST_AT_1_LEVEL, getNeighborIdList(graph)); + } + + @SneakyThrows + public void testNodesIterator() { + final FaissHnswGraph graph = prepareFaissHnswGraph(); + // Iterate all vectors at level-0 + HnswGraph.NodesIterator iterator = graph.getNodesOnLevel(0); + Set vectorIds = new HashSet<>(); + while (iterator.hasNext()) { + vectorIds.add(iterator.next()); + } + assertEquals(NUM_VECTORS, vectorIds.size()); + for (int i = 0; i < NUM_VECTORS; ++i) { + assertTrue(vectorIds.contains(i)); + } + + // Test bulk + int[] buffer = new int[37]; + iterator = graph.getNodesOnLevel(0); + + // Copied 37/100 + int copied = iterator.consume(buffer); + assertEquals(buffer.length, copied); + + // Copied 74/100 + copied = iterator.consume(buffer); + assertEquals(buffer.length, copied); + + // Copied 26 more, 100/100. + copied = iterator.consume(buffer); + assertEquals(26, copied); + + try { + iterator.consume(buffer); + fail(); + } catch (NoSuchElementException e) { + // exhausted + } + } + + @SneakyThrows + private static int[] getNeighborIdList(final FaissHnswGraph graph) { + final List neighborIds = new ArrayList<>(); + while (true) { + final int vectorId = graph.nextNeighbor(); + if (vectorId != DocIdSetIterator.NO_MORE_DOCS) { + neighborIds.add(vectorId); + } else { + break; + } + } + + return neighborIds.stream().mapToInt(i -> i).toArray(); + } + + @SneakyThrows + private static FaissHnswGraph prepareFaissHnswGraph() { + // Prepare parent index + final FaissHNSWIndex parentIndex = Mockito.mock(FaissHNSWIndex.class); + IndexInput indexInput = loadHnswBinary("data/memoryoptsearch/faiss_hnsw_100_vectors.bin"); + + // Prepare FaissHNSW + final int totalNumberOfVectors = 100; + final FaissHNSW faissHNSW = new FaissHNSW(); + faissHNSW.load(indexInput, totalNumberOfVectors); + when(parentIndex.getHnsw()).thenReturn(faissHNSW); + when(parentIndex.getTotalNumberOfVectors()).thenReturn(totalNumberOfVectors); + + // Create LuceneFaissHnswGraph + indexInput = loadHnswBinary("data/memoryoptsearch/faiss_hnsw_100_vectors.bin"); + final FaissHnswGraph graph = new FaissHnswGraph(faissHNSW, totalNumberOfVectors, indexInput); + return graph; + } + + private static final int[] FIRST_NEIGHBOR_LIST_AT_0_LEVEL = new int[] { 25, 10, 11, 16, 82 }; + private static final int[] NINETY_NINETH_NEIGHBOR_LIST_AT_0_LEVEL = new int[] { 79, 14, 51, 42, 87, 11, 34, 60, 77, 46, 37, 62 }; + private static final int[] FIRST_NEIGHBOR_LIST_AT_1_LEVEL = new int[] { 51, 31, 10, 33, 11, 23, 97, 16, 65, 32, 24, 98 }; +} diff --git a/src/test/java/org/opensearch/knn/memoryoptsearch/FaissIdMapIndexTests.java b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissIdMapIndexTests.java index 38190ab143..29cbdfba42 100644 --- a/src/test/java/org/opensearch/knn/memoryoptsearch/FaissIdMapIndexTests.java +++ b/src/test/java/org/opensearch/knn/memoryoptsearch/FaissIdMapIndexTests.java @@ -16,7 +16,7 @@ import org.opensearch.common.lucene.store.ByteArrayIndexInput; import org.opensearch.knn.KNNTestCase; import org.opensearch.knn.index.SpaceType; -import org.opensearch.knn.memoryoptsearch.faiss.FaissHNSWFlatIndex; +import org.opensearch.knn.memoryoptsearch.faiss.FaissHNSWIndex; import org.opensearch.knn.memoryoptsearch.faiss.FaissIdMapIndex; import org.opensearch.knn.memoryoptsearch.faiss.FaissIndex; @@ -173,7 +173,7 @@ private static FaissIdMapIndex triggerLoadAndGetIndex( // Mock static `load` to return a dummy mock try (MockedStatic mockStaticFaissIndex = mockStatic(FaissIndex.class)) { // Nested index - final FaissHNSWFlatIndex nestedIndex = mock(FaissHNSWFlatIndex.class); + final FaissHNSWIndex nestedIndex = mock(FaissHNSWIndex.class); mockStaticFaissIndex.when(() -> FaissIndex.load(any())).thenReturn(nestedIndex); // Byte vectors diff --git a/src/test/resources/data/memoryoptsearch/faiss_flat_float_50_vectors_128_dim.bin b/src/test/resources/data/memoryoptsearch/faiss_flat_float_50_vectors_128_dim.bin new file mode 100644 index 0000000000000000000000000000000000000000..cf92a4c9f0382ecc0f58c8dc3d783f9dbc2492aa GIT binary patch literal 26065 zcmY(KcQ}@R*v2J=l9Y->Ta+TvKs@(#Qc9bQNE;QJN<(FD*?aH3_k8ZhZfa=K&|W_p z8j^bN_jvz$9f!m5_~W_f_qxvOJU?GoAt9k<-0%PWNd3RRqelJj$Las~zk9vi${7^9!c@RL6axsWR6$4V`!)9U#PgeITYn4g+&Rb;8ZATXNEEFPpO1FMb=bZ? z9-*bZG_&))Zt@rUP6ZUX~@_$U}KyPtagTBtZor|wP+W{yWZs4RO`})d&{uHCY-{rRH8E{ z0}hwsnMG9|n3WiSFSb$G6yAXEf7e zv%3VfS~+rDY!082z34lh&er!%h28xQY`DG!H5zWT>dQ8q8_dFo_9*OlBOj%6~cvm4wp@=CnO2XEO=MXh{G`@&8V4q4F zZsnf9J9U42R~n{?J8qEFn>aXL%A!9Z!7$J4WVW$0@rMtr?7vP^|0~Dw`ZU;mnk4uy zbUbR;isI&+2#S|q3{}xsa%hi%W>N-*#{$2{8^b7dKgLz3kmi+tB=4xpt`0el%-$xv zm~M}bnkalS31Cup#N%v211#)1i2uAC2O3_GY=~DAorpM3PV}w ziO)vSSp~u0w;q`NMh#D-TKVEi4PbkHsNrlS#%ip``@~4h{$mcOcn3VP3&+-7Ce-+! zBYTLE!NU#5anq-dBo4(fi&na0om(;FB=Z>W=xG7jQZ6Q6pmf>5j(gk|IVsc>a9j;}JtxmCUNTq+7hr}w};RvgEk zT*P>LKjx=m48OJP1P-lyNrtwim{{yWzcqcR-)xK^d(m1f^(sNdSxuNf&1QA>xYO<< zuDFmlA74E$v%%^MG5&=PNhbs&Og4<{|ICBZ#%r{2r!v!UE)^%gw;=G+K8jsjh&8h1 z&=MSl-SJF3>(9U>&BIhF9|&6IfunOJN$TP}>{{WB!DaQh&=ZXtB7yXx(Hx)6<*B4? zj9_4r92^gQr_h4+*b%+}y=NxC;K_94Rm9QaD+lrPR0XbHngaW+2OwNIQBan<8cq-Y zFk%r3cshM8X;CH8t1sis(XF_0uMHIgawNG}8;=rp<9$~q5`K@NLxc8+J3PW}iA%tN zlX2K?lt;d!Vh~>+j54)!Tw6a#r^RaVa9{nT1U8B9ttfg6=CCc)2Pc zZiAMjYHP&8YqQO?rb1XiVxt^hT_rOGUxl zT3$jyt={Mgv%@)N6;!^KBJ!UtGIkD=(c#J1f2oqJe#K)rHJ`WNbP=YUU@?7G7Tx!b zfwJ9lyqS}QXj@sqwI(&BhLN+`EWj> znumFvA0%+tr4HZXdYC7kC1l)Sis^hlQi@aYGROeVhO=NXe=DRfH0ZtEA}d&TVKe)x>7Ud4fSb>kk^Vw&teM~niMI+c4|0dl;94g8+6HLfOM}t5#Rwm{4E@C| zB)Y(typNovZ4O6BV(eu8`ZZAqaF${+d{V%>W{JW#kLdN+Rro`~f}}+U5q~m*rn$Z) zlcId+jNT1Dp?0#=9?N&QCazbwRF!@8LKE%Hj(E7z2BqImWBS3Z^cPR)zr+RbywZWG z_wotH(wa_ed&8v9Xu(^#T6kQ~pmuf_g*`K;F}?ms)}O-^_PwLGITEP;B*5D>dvU!a z81n+Y(ZRK1IA=DUmy*|xXdz$pAA7?p%od=hdlyQx>oKUAO9D$PeEepMnD5&7y?HI} zth#`W!?h@P$Oa?62xAKWFoo|EAzJmEcUC5Z*%&DX|0k-jy0RS7sVQ{mNg15}8X^kK zv_ho=l5(~bTw;LlBT;Byss!im3R-=qnLT^?70EAkhXyZ@@lR=myJH6o){J6Ap6r0= zdViF7gtJG~B9Jp_GR7V{hH-8IXyMkdvGG4>pJnL!bWgev5UE%uNgL%GkFVwkrPtJ5M?n|cA;rT&WRVSdpIg{zq?p!=-%)o&T zao)(;2{htlz%1BN3bkfg=&UQkoa$DzK98b(HT&=`E{uI^CXapgskpsbnPjW}(6T9Z zSREM4c)#ari=PDcoZby7MTROhm*Z<-K8oM^!BJ`v_7Bbys4kz0OHNHl^$)^mn-H)< z<+v|hN@H#>gFUHGt(_sZ)r@0aPhQHJh}pr%!jTR>6oHOe6CJprgP5JYtb6}7T;+{N zk+BoqUg?F(=SKMVDgb)n)p#t{j>lvJzVKN{nAGEkb|X$ck;lwuujs%>J+d5KgwiB8 z#;nW%OO`dzOr;LgHEzTSM-?P~@MN{}df4kTKT%ak82bFZD1v37e|I+S)OAxxoIj>6 z)I{EK0i7|?!}GaWIIX2fbLJS*0Vh>7{54}$Gc(cPGXY8sI#|0)hzz{^p{-bsfWyC8 zt7C^y_uUBmFS`+as||MtMFc0_t%3M;Rm_}v4*4F7aej0I;`V)^f7%atp&P~t<~{U< zOQ0Fb!fFw4&YzY~Jdd&^x@dQLM!M>S@Cm;J@3D&Lk2s9F>63VK*LcHBRve;l65+S~ zIOJb{W3ci9rLDHYruRv>l(ht>E?Q$zMlHrIz0X=cw8h(^FZ5i^8CfOUa4BLcGo7CY z;k>N~a@q^_^=9~VDqxhFE4G|1hq~rAeE)I@Co2SGzsV76GL)g)^o|6(^KtOQV#q!4 zgJMnsU9t7Vm#MlCKDiDZ@f|2okHap}wMc$GA8nIQqxB+ppU!Qjm(C+3HpLmE@7816 z*ln<|Y=pSRRf?VwNa}^UIPxhB+k^9X2Mp#gwTUM&-8~ZLU8Hf`W`N%B{lb)Xwc5!Rtt*)&*7#?DDub_@~h_}4T@Bp6HYQQ3%P4A zLW+e4#1`zLX$7%VayFKZDwMNlw*4Ue`g6GS__W^kxn7iu!)&!fFk)&hJR1jPZjcEe5^>qVkHMQa5 zoR2+;ALv$N4YWGa@l3k`n({ULzS;@!F>c1J)&_K`hGP4+i+Jlcn|)}OLm&HovJKIS zNXu!(EjxESh@OYvvQuHomlvpBR6taj7M%L-v8$3VLuI!Yggt%mexf17F9Zy4tfFw#HS|=Nt^Vy4SSNcNI;nnhdQ)oy^S#8xX7HjQ#iYpf)uEpNGn@ z;6VrM8FTn9vqiJp1fI_Z11x-Xm$~)i5F@2fOcnjANL^oa&QUnvV z8qlRK%90L-lKeu1I-jQ#$3D|+4L?|PM`PvnRz|lq5&`BcHEIQr$n-L*Uo;zY(&PlX zY8N52aw*BpWe`1k0gS4n!Kw!1>f5!Mehr1b1fhXg-zA3x~7i8#;&+(9%*ynvyR9PK=|LWy)y3riWBW5-G^P?PUIN>vwb6YCL;YiP-i=JbQEuV}cDay4sa` z0`tf&oJGq{Gp2sHiBYz%z`>qcLwO3)- z=nVBmjUk8s!ef($&h4Y$alyG}0+@3FlRa_HI5!J$D%NQsYR$|K+4MyV{ zA2q|NcqA7=lV^vb>C$;v{2qk@l~IDGied)7Mm31CP&Bs-RG$i|%VBy-&tPyui@iCHk-OGpDqiNI@9|@_J2NXEt z2X`I*jK)9*Z(2smepXN?n>Ogluu>)j~`E{r(Ue&&?=V(Pm$7d~$$@+wPx zVH~gs#hb??ct;zpb(ka=&YOX=-5;p2Gl;z(<3yT|J;?K?D_dml%#{995NzEbU}dxV z=t^WAB)fDGoIj2K<4`5y3N~Zyfk`NyYl6Vk9q@e+3H!tc6y@FwvjrPzjQCP4-{DPP z7Hwm1^i+bsR6?*?D45aO(TW2f+Aw=eI*Rt^P@Sk9CAkzK>*htQ(m932{2VggHAUdV z?t_wP9JPiA!z`V_sEuiu6z>aldl|~f4ukZ{AN&tZEzoZcgZ$DlIB<6+#-C6@>9llK zXPub9#@inct<@O(K)0}u2uX^$gz)J;I=NHp~~UdEcZApG2ujQui`(HS2~IcMnvH?EpSU-KA!3rnz^rhvq5Yag3y*?ipACKm^X%d4r?dD)Nm|n-!?LZ6GNHHpTAI1mn7#yti?HpNM7gHKkVu$ z*)&{r3C}a@usnn%VM`uKAMiosia0#gxq!E`E0JY*0V^$=a80BbLD#LA?gxKq)q1|( zexFM)@2!NUTnXCxoN!xD2pjyL^M|5ZXfSXx>i0F%rtjH!sFlt}ZwkVVhB&nBYUh{F zaY3qj78QjrLaO^jI$AmgUg!Uif3`R_{%k?q(NCNt~NZ4l!Wrbl9pJ4&B70d8zek>B-ZG_@u30Uru zMbCc$kTPhbZ|nBKd~*;g)N1rxhhpF_xD1V|DXgSPHyxj{R3O4|8yo5ArAY;GL#FuYOMi{yX`bga#%+ z;n+oJ?PHK+dzw2}M+}E<$LOchDE=%DMp zyiRsmwXBK>4;6M!^!-FH(~y{pEpB*oyIx+9`R!|}fQDO0c_ zl7CO(FKrX$(a=&kXzjjDZ+@7dJ+F(EoR~)C@-l*yJ9|*Ulf|#G<&gSqik(0En%2HE zMepN{OxQVTT-Z2`&R-EhL+=XI>AOK$tCLlJp{rXoe?82LIo~fn4{8UKspZZep74EJ z95ataxr78Vlvo6`bdlvMDQvA;%P2L=;F*$w;ONx`jCr4il82w@?bqY{T?PNhZrT_6 zvv?w%+j$H-B8BMj<{Y%tYQjOo43`xo5&S)g8idK^w%9DifOH~MGY!24=4xW@lJUL~&#;n7mq6+i@1Y`R>Hz z&(jbjoda7=OL-JLpp~!h5Z~2|48%`jR;MBk?*7hPDiR>}=rH5e?SZ||$Dvun8?UT2 zX|d%=>~9N(?GF}3HZDxz+IAe0a^Qc89-tq!h6r1kLciMXQ$;{L`zb9NBk7kh^wAi- z$_^MCSAzOn2ipHN5zZGmZ5L%reLECj)EA1Ujk##Hs)F+FX{6C9%06^_!FuqDU~N}~ z38xIuFm@fjG#tV)r4zKiPYI_I{h%!EjINX2lpGuhNtHZmt}=vDU`x}GRTwvyRjnX%C+FY+2b@|@|rCvk%qxdUz~rkh8_L$6v)~W zdb6bPY)dXasN_&`@oTn|#=9VF}QIj~!wN&hvzrS*E(>4V2fg!k}C^V)tCvFW5d zRReuNZYXckM}y`(Ce^i;Og_0o5NHfxl}YHi6p1gRnsMZ6J~|{g-)gEqBX)f)>|_KO z^2>x)fd^UH6kwcREcpehBg(@K75%gE=cYUV<5m&DzoKP$Q4>QRgxOaWI22}6!^Wpf zo^l$djFA*5)FdOpN(=g7x=@HugKg79Jb8DAiLXt9>)Uob93v0YTp__e&Kr$ZPr$pV zow%Nu${*Xb70;Tp=~v_f>YLz;m3e#cNwW}6M)BBFWkK$6y7-t~Nm}CV|{?`GmNS9|A-&%wOrS&&f`6<9Q? zpz0(adb%gDQc(iNi)(RDvI#T3E(O2l9G;C1N4KE|eB$Nc^VJtU)`?8hwRpG{jDh{7 zR$9H<80As2&>Fo2x;rMJRCyt09=Hg1Nee`-{z1zJVo<4)7@yvW!+J@LIenifPfJvwRCR?UMGm2M@HW*>2*tOm+0Y-%#e@mRkdqPz zAvYt=_nC{b6km2=PcGs%A4N(=Bo4gshoZqSJunX^$s|1-|4)H#{(4BGj+C=Ps+`{W z69cucS!Cd!g9FR8NJ2Re-Q}0*<$s*s_^Ar@7k<#rE{6SKmhT^1f(?fbW0YwTgv9ot ze(VFbMc{!pr)*rgJ;+2`jK+@^5&XI+g0j{@+7Zqt{hh&(+UbIKv)pK}i2+i&tFSyO z8R`)(klpFV{&cluf-Sn}S(6?x;>GOu@Wn}+gQVRVLB>r&Oi}+S)GwD3{M2pc8KDI- z(~42)whNEhUVgH>8-HxF6berT!`;T6GC$U#|KEAU-OI-Wwm5DmSsNA#ovcqtOXn`M3p&d~;J?X~avhxwUONE*pzBOVGjoPUe$r3qo|8 zF}c4CO~G%;wNMD7viT6Xx*Hv#^4RmanqD_#LE#bS`CoJdBWj7b%3bj0e6!x9B=*Ur z9Sm)CW#%h7&=q~NZm9P z-4m3cE>uCrKc?vYikikcdp_mO%&)-Q^&N;?S3%o1pN4NlIyAPNz<<@M@SPCMXd1au zxUMt5ovY_l&4n1>3%lWA{)tId%7u`q_^(Pov+7<-B*5_jm%) zL|C3)02yyRyjhe?N?U8Nrq={=ZZ}!MyX}k}ca6JUOkg~*hVI5oQqrhK@c(tu54B2+ zJ{Aw5@kMwk8_f)|B7!f49yF{sNdIJv;EaDw+O;|8_!vNQzJx&3)ri_(h`?`237+a_ z;PC0u^q$M`3=@8mtbr(n?rDHY&IZWu$-}yb71(6xf?`)IGI}=ysf|DRij^G*bCeLg zARETsSsq=5=~x)mhL7^o@b1Q5#6J`fY~Z|QU70cPzfwj?O9Sy`;1zH6T`O#MGDNpe zH1(e5GNZ(3T>bW(KIyZ#v!xQwo=%uE{F+$H$*Air$G~5IXc-r<_p17sm+ub2;=@+V zObDZqv0RQl_Z(f>yqZpa%pog-O&Dueft5>|@pDo$6TLGU>elBFRKEsuz6K*bY&GOg zIzf@CCzmci&I1e}8_SJ+A1;61y-^x2?nh9qtb!@ZUU)q=g`RG@2+K2*uyC9a-k@7VACB zu|bzacS^g);LB$DN)LvF1xz4&mKago`M-#4Y18K+}meKQG{PpBjJ zTO~|{S$5@IUo5_J0*qA;^XR1q{#wq)x-H-MQRITo>8^-**nu&ki@`gtKrNe!DEnO| zvah}-{aRd_c59$##Acb^=a1xHWM`?viaP)c*&7KsC zdxAU6^d>oUaGtNzp`EZklt!YZPRI)Er9Ib2;jq>WL~KV%-l<&H z`;B^b_%SMrH=y|2H#)bp0XF?J1a|U#nEp&g4(kg2-Oe=m$6{!&DQ0;!miS_{oyk5V zNw2deHVjHUWi(DCQvy74i-ag`b{z|-3!}~*O&`Z7ARTDhis}FYQ#Ed$&n5c4E&@VH50th5m2JGFT_Sv zSkK&vg7sShVBKniwt!i%QVGV1`FoMTBx2urO?Z3nMCz(AJc|mXsl74upFso7I~s&X z(Zxtp^MHR`U2+{NmpY)VIc+c1lXb!3*YMF%-8V}F!$PtZF6)ek}YP1r=4Pb z-3xI>*9@Jb+gQm59qjDNXv|qHFX)gNi`zGgX)&kc^HLO`Kc@iBrbp2ACX;43?n9h? zB^6$l#q*%6%$wE6aA4jXICC2G>~dcmERn#kqqTH>i4A^+_mWt)A!MTE1apm+u+6#= zto_7X*vgfX`#XDVv@5~YKkjJxDnN#C9c@_;tW^lc#i1(52V~&!uMp;y!fndAI6^zO zTeBaj7-t_0F_F&%7#aG>6xe*Ez9nJQ{O1)3^;BVn*>C#OWy(nSYvSgXdUSq@Lv1As zBWpwK9x`D4pBmx%q((+0i@RT+OUU6~4Gv^*IrR4;gsh9imf#kIw#>zd)G`WM-@?_K zm25M|gESsg!swt?Nc*VF$cLZc#n|7WyO9~tWY-|xA_M;|j)$Y6A~LT3Aj#uCWa8n- zx}KVi`69GoHJ&aJ%>y};ToJ`=TrqBh5&%v(yJ)#yckOX6|ow<0>#Id5!5GIW+`BmBb$ z3gky2{Zl=pR-cAPy*6Z3{b80I%wM5Vk5>y@>G%~s?51O?Y0~Wg359GehPp5+XyVT62|UjU4Q{vO1ol{@n*_@JX^K5 zbf)|{t1_KqDom$hg}Nkeo_Bx3pmaHR%ujUF`M!Jz~hMdr3T+f<81-FAKEngg2z6$W=_=mFEUySrJQ`*>2hd7aiDEwoG>m4jS z9TFh3$`#D0O8kipLa=it^??sVwMcH>yhER#iXoeuXXPq0(B5~Gb)HGg7Cmn6C@*4t zC8Y#U);DoJhvh^>`rVg4H1}$x=KCg_XZ)^7uOD-t9L{DZv8Bh4INn@*{m=QyGPfI&=u1fcKV* zbUA9&`>~NSqi$QC-UPCsgk;f zKC5fv0R0_pcoQW;C5oze7B9`1_qX8sk|a7kA%>pMoe5rMD|^wgm3#kI<3hhF#WjcV z6|A3j;^moqqPWnzlG6QlT&bsk%m{`dAyg4Ma_OsE_JU(!NdSK zt5ri_s)yYVGIfqB{ypn2w$E|r#5hs{d`m%# zsKh-Aic@vio!*Zqynhvv_LQS%lPVrA^hYb#*#nHdkU29B3MNN!c4+{Xe+>Yi=ZOz} zS7_6>m8ie3gGHki8JqYfws?36?sU%-9M_J4@#ZKh&g>@LsA$}_8Y>u5jA7pOT%$`$ zsd(_ongk5|Fjj-cMg(juRf&J<)Bpml0VXt%d~H4r9YFDd+qAq^3Ma#S5uS9L=`s-KGQTt^ES!Sk zM@lf3)M<2H(@wtpQ8+k0n(ZEHrM(w6;BK!O!+Yk1`Da7nxL_-OFEitqrFls2{X)qR zQ8;M-m;JBF8?Jrv`0`*9yGL&(D!JUk&t^Sdth`1hE`QlhpC+UygwvZ(xm>oB3M*Ar z*2W?dEk28JanXCmqi+k%Ip_vkheY(hx1*V>?vT0s1+3cAjMr&(IR8~0iS`1HTP&s= zE;s(CZvj!eN>bvU>D}7#0>5=yP%=1z(BENHao39co&T{4?|hM1|CZ!xVlgH74pk2+ zWBb-Jw8#W7+k-4H!{aP`cjY0i$Q>`lYvB-_K*u)?(Fp$sBkWUy5w?!jXkW%Lv6-le z97}uumSe_h8{~SMGqHhkwBVvTW`CUrJLOEY%m|=O#|PQbo^^QpWtKocsD=)>Rx+L) zEhMJ59}iP@lWf6Dn(tMHyC*h7QGE_$B)1c4p_dT)ubh5<9ON&*!a_F3A3Uq|a2J%| z>JRx;*-%elFl(&wJagll}ZLKRE{jX;E;R8jW>Y+O#Bi91cX6 z(bV_xc(x=LEz=o9{T(Y94*$*{UNHv8)4NFhM<(Rn39~=Wt%8Oy4_Cc?5O3GSUOTaj zJ!5nkdsLoL%kzT>68s^R`q?nhT!*y@o~(_a0?ucuNaA}gyKYw{EGiQJ z+IjRCrUlL>(P_sZn(czaPY0OJr3ZOKIvsRg&W;V*xqzKHs*9#yuf~U0+aY^r6l3fx zM%^x_p?}kpjXPd~tK^ zAeXVb(Uk3vnQxC|1m`spk)<^k^Y3_K=NhnmGz@>z6+_2IO}g|{v^8r}qJJh(3$Z?kk5l$j1gAuwQ=#-;9A7sPoeMY*_jDc%SNO53HnH$FyFrSh79vP20_pFM z;p@|xtn`)`G|X+n{2Vu$enOQ>y1XFIwqw7^d5qz-e*G^MXf-xLdd_UlA3O{@9Y2oK zj(~B`24r;dv2k}XdwXRYeq~idYR_z3o%w}VH5MC5OJ zXFE-h^H37G`M;@4%aP;I>ft_PiXiJ-4%T>&!ba@?=oV!nXzc{ZDN4}NBu8wW-hv4W zF4IxvgE&`I35Us{^x@(-yu6-)?~Cj3EcF%L%^59N*y{nISw)QPn=;H=_Ki(2*$>r5 zZYH_MN7Ctqc+y>r4U#bsDGp*s`RmZTi18$+B8U9e1VkJVr%lUC@H38MszYBEG-ei_ZNVk6fRR12fI>J?0WdXW7Hxh8cY* zWBOjS_G<|I#C63QTqU}*`PD{hZXHE2}q!ef0JCWFV2D7D<5%uo`yj+qo ze*8X8y9hxvY6aASw&Ls0wJ`DQr&JkXXs!t0=9(5r+?~WYZPcaZ^qoQqxVhV~6b|2i z(#HAwNyA47hj{m>F+~i^S8%-Bno;=oNCz*LTF{>Z+0dWug|fT(Q2Drp68_9!TJl(S z!o5_iFPFoP*m1ab--?!VTvE&oBDK2(Fg=`ri>veD)gA=JxMvg{9S4nDqhKUFMUb;^ z2TmN%p@y42s8Qyzf3yk!E=N4Rxr{k>=MUx08iQZ&>`BAyDszL&bG%(2(W}Rm`0`~g zOi!fK-6$`3%H=~Mc!YVprW~{SIksmqL0(oBZE5f5phzsFcNjxLC!9X+si7ASGHH)j zDaX>q(ak~)C`_nq=rhPlVN_m1l!rv2Oahm;iH z{=l95g)3ll_68f)dXr3e*0{Mo9E&Ay(A?2u1yaW2;H;Dn>k0l`%_H{B0X3}LbrG9C z-y=2tD;h#ESr%EKG59a3Uz$f{K^g3emj=jt)C{9{^h9y^YKCJ|t$* zzSlRIltsDteW(D2y(P3(-XA#=B9W1hhhHzm(URH$)5{SU<*$dts4VzJ`=Ueq7L6NS z&x+}vhMTi8-zhN>8;tCs^)(#hi_{Q(H;o;1%|uOd0KGNixCOst?EZWbrqey}eUmrF zi8|oBrv?sMjZjmEFy)Fg;_eSG{1I}X{);V8{_h*f9Sy_Om;(?Gkj4Gmk<8|WZ>icU zAGI$^VR2*|R2Rz&GQTXq@}bkz?DCX!rt)#MH<0`-cVcdAIh;8SC}pt?L020{Y2Gx} z-*!DdwH+YmiP!1=OKD2_J`HwfUXfa{7rP~Ukizy!2u_#IqPy1Vh;T2)Z_aZu>|4sp zU5LS?nX~cSwgQ_M$RYZ|f3#m?1#-lEP&!>1SGK&St~XQgaa%sdJC7Bpt_Y{pIXkc- zt%F{YJ9;LpLfbPZwl(MwgoZ5GqLF&c>s^CB?`lljG8&5J`*9(@fJ!!JVpyO1zN}XJ{bNwhd7>I;^HHFh zialQ%v7yfkGuwP{)43Hl^#yP-;W1TXYVbLK3V+?6H882pMB1KcY(LOQiGN~ocDp48 zaxP*ZL&-LZ3DWU<=rZ*!zr+lkK8I}ko!GfK18xg% zlXcixNbY}1|L(Oi8O!+SjxVBL*Usb6aZ|k6Fcvjx{fwOYB<7>33pF_2V8mSZ!+wnh zsa=$T&Rh|+n;*kA;RV!rMVGuhym43Z4;y+b9xKUVHc@1@7WTz?;+_G>> zYzDR8aU}=%yDPnq9_o(~s@Zxcn| zQmg1MH>b=qDdo*Ft;g)C^}LD&El_#)ggzXV$G1l_;dRLslf+v92Pck$6+_4qJup{m z(RWH6^27f4s`i-u|alZIEt8z>dO0LxK z%f}K!gZJ5WtG_mt92rgOTRxMXfhV@Ll_F;GR~ojCqa|M}VXdXby!wzzK0R9@(VdDb zj@#Ke6*CYyHw!m!*Kn-sAu#FR*=G-Z@S69SrJ#1C6ht8Ti4{fNoJybiN~nI#Rt$Ir zqb>a)-UT;cmFGKRW?As`x}p(ve+G$e;F!P8c2;W03eiVLsG&j%8VM~J(;k3p&Zpt| z`vFs)Bj9gIN+8<^20~mvdOz=q-h%e&l%g<~U1rQN#vLW_7+nDKssnJ59gi>OZM4p* z5FfZ1GQIIXR%V?Ap1_m7%+bO9pFCu%?16X-F-{T%7zp*ioS{}s*Z58jA9*+<+JT*) z%gEhkhG6q&GuUxVdXID((pu#OQ>-?@uTclhU%#snYG;n;Kjua~D_;_|H6a%++;m1rP>?=})=6GT3TWg3}@n$^e)uET)_rPJbDkhkuKwCA5W3`)bI@A@pm92Pq zB#zghYk*eqIMVssgjst9tk$D=_p((*NF2`*CXR$D2z9i@s~VyXCvnc z5U1_Q{Eez&=eT{NZYj>QN|az!a;H*mk_lOH487#+LVg|AQ_o&&9J{>^4cD}B(f&Vj z{PCIL5I~p&S>Z|NDg=r@q~AT3>59#=SMI(K8fs9$E|Shn}}OeR*?KapWY4KT&7Jyhr(~XV76`$tw3!)MkWbjN0? zP3&9^P2{)zV-8)-q+EH9nRpTc@tx1;<-aH__;Vgdwi#f32$zXum>^`25A|GKfy-fK zxc>bt4hM5wbOpCR^drYH?i~*f#->(tF<9;DrcM6EH0_fwTXZ&nKXs!YI{bGu-Pjb4 z=4S$C*#8gn!*4orzn0~|jqdu$As4fA2l&1)aBlkv2+jCJAXFu%Ee znIDi%O$Q2KH#-GV7t_&jmSdk% zmMs3vDu68;hq;4+G%Z6Idq?tlyq2SM=2Ho7#TTP^u{2gzx8V`T?pRFxKo6xh!DQ!N zysX#hwoG9_XaloT{k_S)lHE{cB7%} z6Y2YI$FHs3{3~$|_{7xX*bzP@Y}Y3Fixas$XR9bwSruL@P9PuR^jN_bDgGDWAuyn$ zLLqqMmxIgw={P1-fsvGQbXIfz<&JrfVcySX%PRyY45_9~SV+GMs>u673 zFwU&!djFd=2pye*k%kOnS9voQ%a7vtlTvb1iNWW;O0b>iMuWHOkQAPW(c#8W_%M}Y zI?I`*Vr4ilQ;6#dGPK&P6j{R?P!wiJyQ~W_JiicQbtdA|gi_=U&ck=7Vw!u%lg7-x z2vs8?dh<&S8D5L=Gt~hzV@v7gx_)xpABrFmYe?M^$A`yNJk1q#LF;Eqwf!EWCy#1ik|O?ze1VR&oSLYT<12zZ=C$zGK>F>wyo{0hPJo8QR( zT@!9wD#LA+2byg=m=zIqIQYDusZ{&NH<*&aX^3ccaG^Wi%wUn|sYT0oT|)GcAFTDz z|1tsRu`x6plO{ya$3_9iyGww7>>{r{LJj{)Ij*oe8O<^^)Ri#A?fLqjp3XF!syFQ7 z6w0h9LQ*s<(mY__D??N?N2Q_^np8@m5FvBsAyX2WRWdvK82^ouMwRASlP02ipZ9vt z^?o_)_~g3IdG_AVbFbf8rwUsh=aK)|1h$6bQSed)4*fG}^@(ikTa!l};&YM8)-?r% zA*-<&!`z~fdESYZuKP!4*cr6?`x#XHD5XI2WbirPNNP|xWb&&pGGi3}kp+A%sp80A z1OAeEIZkU^arG@mxOz+mD}yg{;`WuCGW!gAHC!>ahJF4EqjB<8JN+EQ;pnDn?4DSK zaM`n{co0qhm6%eUnF8h-XF>LeGp^JGz{+D2l-JLu*Lz=)PP+@jRR-|4l3U4Z_F4Xp zbsauGxkeu+f2HYDd&vHpF~7Pr9($i>kV9LVNgDGmzKv&DYE=#NeTt?RhK(pyU{lkA zJUUho&u89>hI7m@j4{s_Op>$2u3;W{w>uk~D>Q_CI%)8(;gI`eB+PT-QPN~e*9}g? zCcO~fE%xBQ=Cjl&^MYm`_Q$e|33OPv0-Hz7V{At|XL7g}kK3YX(42Kxzc7e8Z+7#1 zeG^iC=wiZKJ)AoojOgKIh?6NJ*;7q4^Q)|2Lib!KoHB>-qcK@W4TMVKO5}WS6ZCf_ zqG@miTCyM0v*q>N(cSrYs&*FpSCwI_o|@jYU)(c8o{m`FXY|ci_eT1UinKJ{VXq)roEb`+Sc<*;+m)!w}Ez~OlFM9 zU;4XM6O}&-xWuj|IuLRm8jQKo>uTcy4_-Fu)-oq+-7nM;Kf~lgiW59HJL2gtImk^( zg{H&?l*`Ox99j#?u0}vnCXi&tWJ4y|54|pC*r&V{eXoSj-;++6za8ktzvU=>0qh?$ z6+$+j9=EUpQq3z7@T>|0+z3vR(kN$pRj~ohG7qFK)6tR?Zr0`kWR?*O#VIj)3J=CKSQ{ z&oi36?Ip!YWJ6PmW!>v#AS0iM51)icPaDo@b!4Obk3OzkuZ8>Fkx*glC&{^-W!d^^ ztGy~`bzc()O5Rc1sW9Y9@zj~DEX=);NxSR9&gYLZKe~RV?tUb`-mZ zjbvVJHg2=*l=72AB&a##LXZFtKj&gyBYTD((cu3#SGKFH6#I_MK-i~Ah-orLS7khU zGF{MUvkIf8p6BXkPQbO-_K=@4QQ&Cyo=nzQ*jXQXrs;n;z&Oc=emw}U9bba(#=RK>#mDYvBs+po8YYG z1yu=Y4Bep)cb3Cc{b&G_8yV2u5rFSEq>;APoYuLl$5Eqj)H}#PT5k{ZGymgEmC``$ zJ}#$Sh~qPrsAV;qMJ(3E@x?>Xx+@j#HDeJ|yApnJ$LNci1!{h6BZcB z7R&r9ec3~D?A~!ZEe;2F97daVC}#XRgxQHIET;s%l(FPrjo*`EGGozZW}@u(Sz0m1 z6L}FYjLXO7(fda^IOAK1;4!B$K353~v=gCa56F9FLT>O#e9x&RAN`@iWaX21c{_}M z{=5>6*^8-dn*=@AbVuZ&CMY%s${T{Q?7Bu~@k%3~;$Zh@Sv1Ma+u!R40~m?SxrzTF&5Jx~0QqOlmWgNLKD zGZ6>%6`;E41@-5a(YnpEQQ;f}h1l^(aaaJ|*vT~6@h$zFuOS?;DFgXiHq&&*rF>}^ zf>AxLn8TQfW11|#6&V9h30r6io1yVzBUz;mNBwTLcTGG6KflNH;!`yreW(bgug}u^ zWvQ&=D2>$Cva@c%IZk`Pa>U$Ve!=-+T=12@{M`OyR5EaVT|&raSlLZRbxIf|y|Tlr zIXyJvXff3;kEeMz*bLC-6D7$vQyV+O|B>2_O>9=$x??#s^;P+A-nry5AdK4jd4BMv zD)=18;f!MgQ5$svb9@9`jSS0ECQpOHI}gk+WBbF|z`s#!zEtar_#_QsYaPKORf$|# ze&m!(4t{V7@xWjO$_n(Vgdaf3$+T=`yXb9RF6&jDoIG=T<>9gKZeO?V=;kM8~sK- zw8V}54RCDDIK7*UkGJ-h!^( z4ML=GGJY5|lHtEvlIG*Mwx)kH(^MQ&M^xk7Mtj^>4}?!qF}&}mKxoT&@B&FZ6FbgH zvb9xSb{Z9rnD6F4mB!sT2+QUe^nX&pv~2>IxEVuz`gbb1JOc5%fY6?DsQr4vrB*D2 zSo%ZSB=yr|y301iSwG^g&FJHd0?uK@t1!OC>Jyi~IuZl4{Ai@)Ii$;YU_@>w-8PS` zJ8sZP%3Alyw8x6CX&-@2F~!{H4{OZ-_&d5vHj&5qRF=jmPbZ?G#!UOZ0~k_jUO({{o{R~chZ^3G1ReUtjV~0d3dNRkC@~o_*=OeF2nuF z&fW}hF_LhbBaZi8$Iz^CqRuqi6Urx;cP)&?6w?IMC0Outx5l9RrW*W=>v2d~TKLT@ z1KlMKFo^CI*#Bq8nCAZ|*wOX!ukh9+S9&z{_C;V1 z3w`bbt;To}1-7!}TpruqB> z-YxPt&-t%G++9yJ-z~&W*>#u`q6s^b5|~uf)8P4y*sHRO#`qk@t`9l56%~UIYeSe% zF3<1UV9YtG59AsHx8t?JKzyk1z~jnj(pE2n_1qJf;-N?%Dy#7+egHO|+l0Gp41IxT zT?ouq4mc}B(7*^%7gM13Yv$p_nLK1ySE1-v3>2%LkZSc>T(L93L)*#tUH^x~2VUk( zU!}u6SWX!9c?_+OlHg(tLhy7SW23vX(9HJf(V@+_`9KxXRVS&eREm^CGw@_@D&`iS z#>|ugEN7f~>yS)vQVq!WJOt|sNeU}?M|#z{P@iv%4dWN{w{Hg`B5flQW_u&{F=Mx; z#$%q;GH^$Kl3a{FcVvhv7j>+U+*MQHnkz}NrinPT^?}LW=nb^2CYH{JGVW>K_d2b8 zlM$-r2M4PPXg#`s5v&V>!$m&hls3to>EO2xw?J*|75a2F8_I9a(w=!yu$!01cUXJU z@n30VG;s%=ADo7DZ#b;#3`APtNI31j2*cISY0->kZo}UXG-pjC;%=Cc-vZXFv%?MY zXFa%aY>!>|-VQFeJIKwdh53`2oKQjoi(j&itl}y3a&anc6yzXns2)zDjsSl*A@`Z=2R39i!Gd{wS}d^ifi!fCo!B59orrIm4^eD?5SK4@?i4w(hvgIO>t zE$dOW#sS9vyda*+GFCIs&@96;{(uiipsxIB^ibknkx;yeW=jb7Wksrf&8#aqAxDrYi zrCX^yqX>Opy=lcAKWKe*#P*%}SUoQcJ&HZNYfTo`*DuD=4c=tfF%2`epMv%17nFH+ z1J)&$3)UXLMsvAy(6eo$m7N~A^jdT{y`|Gj_ai zBG#=9H8u2IoBj8Wj7B>)bsD zgTDJ?-?2>IZg@DUoqJ6oPhBCo*$?-7AJf5QftYt|Ar~h*2-3q0A$|EbEpqNA*#={r z@oJ(a!9iHQjLkdiPmp_rEydL)VYc-XE^{91$;cVO*%-(n@md9{OeFBZq#R4H_X^Zm z#{SMWcStb?*OHyJ)VEjQ^s6nL%*O;Q4E#=C4AW`uy#1E8sO z0Hx|y^v_6@_eu{%Hp`r@ou7-9C++B*3G>7TDsf7z|Dih}7M(#}*nZv%!BhV6pO3z! z<5B^L(a69NS$70ZG{BTChV;B!62c9&cz^mDO})1f+CSZ3J5v^aM^$22*fZm;_G>9H zU?09TiZSlRksC2(11G`qbPiLdBYdt3w%&?@{B(D!C_Yc+Vdl*9NQFUVB^s7g;3eyK zINr{_SaE|at!IFd36L2u$%I63&}n-|ja z9VhYCG6nW(^J&UaAA0qE0!sQCkg-aIURfSRklp~?zmbBmRx_aDL{PatnuN~`aFRI_ z8|Q|>*CPf>!&(2IOcqyqTm@n-zPPogGD?{g{ain;2~F{7kOj#Sl`v9=gws5WX@RIopH~e^E&`?q8|WXghT{=-}D& z_4u)^9Q8UL@K5qX(&{8G!r1~}uFR>G-z`md6&i54a3hz#zY@`mD?BuYF!tVQ%!=27 z^uq6a$*2kxR#kIJ#wAeD3x`?-J2U*&z!m=*6bA#xk1a#n;gy&lrcYzoT-a%dEgE}I zAhKHu_6eof8~Bhe+;xPcXBqDI*J7*&>$2p(a_%dVux(!>CbHSdoE@j}kY%6djGD-& zUKN9^ffwwpa{08^uGm+2RZt!Go~9k%&-o=(;>O4EoVs){+pp&0tY#t}jY-6bJVR_1 zE1u8=7B zx3+Rm%<{3(DwM)^Nz*HLKa-a_rEG>i0v&8G<$d5c?f9y}o%2n?($p!q&Bs!&a{}v5 zNJsU*XnbXJw=-W%O;T(J;;oS{PFS)o(cmIne&C7Z{@cvA8$%0*%tOQtE1YuJjTg+f z3#vJdwRYz*(?W?p*6qT$zzz64m(X9l8WH;aSgDRRTMTe;&L(uIY((9ebeP}V zjs0=*WY0KA)%AwF+sPj4VEG<3gJ!Js4S?RkRb1U-#!QFRp=|Cme!O)R7In2z59=bZ z$_Ya5=?bh{oJCg)&S8o>(VQ$b_>`+ar)3-hwKK5p^J(%!3j|NtoW3@eU-ph=yd^(# zPW8d4QMX0s@hBX#Ig7a!j#%EELN&c+IK`NHm1Bw+vM>!#tV5w3auz@Df1#IAa=6O6 zl?5LJcysA3Evl&{_n);Gx^WXeJ!v&QZxcfP+Hw@MM_TYKrjfL2%usMuV5I%XU7-Bz zFZcFYBg=2L(GkF|@OW zj9-`_B{T}vOV2{xW*xE=T%jx1M`Ac z=oZ|6w1^h%X<^(kJKN-xLM|Fj5K680T-$mc@Gn`r=KBq)N3gyJEzVPi)FVBvLaYqi z45wgtr8O31`7j@@0k*SJC}-phz*|8WdBzM0EbI4MEQ3yHjYmn!Px??2M6*>g5qB_~ z=1mxe|JF?uc!mU$l!`I=u9Xz-u*!sa=_0H@p+Tm@t?-LeqrZ=wG2{Cg<}>Ev$jPH* zHa`MZ&r{H#xe>mOyRhfiIbLCv7W(Q;=yLo;J~dOEok?c#4s8*Fw%L9xpYnxdul_Mv z*(rzf(#0lrGOETcOR~A5ZF+p_$@MTW=23ko4|RseG5AddE(W^d7h|2L`k7(y)C6*V z=Y!pBPBvNjI;{$>#H*8garJIG}<_uz;29{EK0(<%t#E%PJzUStAZQJ&M^DfU^zR#t zNft4ivmVoO>*!>rxCwXu5_DM~<&nq{*!gGz)p9Lx+^L50 zT2C6hj|P<0v<#9d>siO% z5$sx(O1HagaN0!y*E>|;#hCOV@&Uwix(K|s22!#==(!v7ghHpo=Ys^gUR%=BjBwoX zPQiS?RL;0wTu^*13)d=g@I`YP`B)U9$J2?kV@##zw$qfln0YpAFSh+&1GoQcKeZ96j=|&XpcU^*!IXn08M{q^{k4!m4|QrN{pItf`pfj(b22N zF+7o(U<;xI+J70tJNqvG%_AFMtJ}$=Vh5gD))>#*e*r(XgOB;nx}d*)qUUP+=}C11 zerl{nop&kzvmFOd);Dn7f_XB3^h~VW=2CM?D5W_jW9g?f_6*j?*OE%~j#+~Bw`V}f zPEz>pWFpI0JJYw2V3;_J#e|3wywB#zcaj}q8}e}^yNJ&Ic_^6gfUV`^^E~zI+@iErtq1Cne(Bd2ueY zQj4s1uDXs04p?4Nh$M$0fg-PhGmr=u$RB z<(hadVf8pvsR!d#s|V%{Nu^kKOLW_{K*1{qF8bM&JvkJ0Dr@-b8LMgS(CxT8bq>t6 zEBHOP8jzS{ikYKx;K=rIu8&#vUS$$HKUwj2P89HAOV>lDVkLh5TSdzv+bPv$CYA>abJz8_#gRScwf>l?2vEY#<1+s1-#V>EEVhfuI%}BzJd+E6Hw}AB& zS7BYcKFwcL!PtA&t8wll$(L%;ouRe(V;Dg_dn++$uPP25O2)Z|gna9t7sD5 zST5pw?xcX6_h3SI4J{sCf=?I1p}Ft?20s5q_peuD=T0Tzo!eFTy?;0CycLC?CwZ{G zBOf?@5Rl%$gIFwH$kh*yM@H68sP0j~hN3^*8Jkkhe#B}{;)@?f1hcMDz6tjpKjj;B zHX(7}0nGXnfUG;u1pmG$LV1usE;kP#L7fVx9$A8A-IiqJd;#NbhobMT5T8A2V5aB+ z#n1Jk{%w1S1#*zCUu?)_UsxU~nv3}~cx>$B~7K$9_aF2JH zp#4rN!o^oZCrX=f{`$DwUQgZQqOg8kARWLWR5a+?4p@o(g( z+{>@<2*ilxnOG$DfDHfdXF&e{Rb2FLfJg_5R6?YZB9#*9AdwChskBICL@Fy%Ig!eX zR6(SQB2^OU5RncQsj^5_L^@2Q!$qnpQZ9MLJHT<3&0_q!UFtNu-lSswdJZBAqHyeUTc7bec%{2{CU)Z^hUQdBgtz Dz!wAO literal 0 HcmV?d00001 diff --git a/src/test/resources/data/memoryoptsearch/faiss_hnsw_100_vectors.bin b/src/test/resources/data/memoryoptsearch/faiss_hnsw_100_vectors.bin new file mode 100644 index 0000000000000000000000000000000000000000..f09cc6de6f7c0e6a507e8cb08f34632c5c4e98a6 GIT binary patch literal 14626 zcmciF&5zZ06~}P~s0C{2RIs(Br5BLWQlJC1mX?-YhNnJI8T$aX+V%ojTymx4YROICs`VwiC3__oOD>mODS5l( z?8D`E$%T?POD>hXRr2eSw@c1$tMw%pO5QBFRPt8IuS?!8Ia|#?S8}1`&63L{S4ysy z+_a}S6`@4cDII6^so1>yuTXG1S8$v)x&!0 z+WNi8{Y=@mcl*|Rdhuvz%hvt$$iL5xFdyW>9FU8T1HZ(dKXSk)b$LHs0 zTOW*~N5B3os8c@wG|UFR%O~H}?fGynwB1wu=Qsb&@=^Uaf5n|1e#^hT(9;cv!pSg7 zOh$QE-qpLgW8U&b{@J}2^!OzQt9_2Eci46M zoqhGC|DO$Pn^|U+d*`sa#(D2@^vi+Ssqgu)FQ|d7;d&TH@33pcyqTfn%+JGFuU}_^ z*qK|$!>%x_50kv7AH>a^JRSIPA($!V1AivbzusDB&qG1})R4UCrR~ApVcPj~R+$~@ zMlG8YW{6z4Kda@>gPg3lmvMdIjh z(A1$mXllUxx)es$Im-Lu-_*C6&AuAo&(&ZaI8R2=Im)~C`czm5>c5$V`}3?8^rO24 zpPC$uqJO=2-5oWsX=kDS2<`?|9ZOI9}a|O{>g!wFq4~mqFy)^ zn)qMI^YyTv?qPnnck$P2a?k!S>za4`|D;y;hCQJh=7YT2lR0XRJQkWYZA$Sox7>mB zj=HrU@!J{HZJYK6b+K)BT@3Q&9C07zkGqALCVy(1KkB;^oGb2T?y5(Ee2z0~)Gb?b zAb;}7AGXwtT49IoEn(3ANxwfEo#%o)n9c4@{4ulTqshgz&NTfi-sYE@`#6|aP24}s z^Rb`~&IGfx%{b6+|9Vrb%`vvwly~{*1hud`Od9XO_3F-?)@Ncp?Rp=y@BCC}JA%Ed zL+6Qo%fsONZGV@0Ge~^hW7NQbz_xpwx-i=w4xJ$Xdaf-$^gCPjt@hmI%|iEjx!4>| z2mRib&3--X==lp_PY?sWcrDnsT2zNyL;t$Ap3Of#JRQXNwV)@(U|;A2F`rid?}~1* zcjoB1H-p;Ss6McNHFq(nLuY`!zaHA0GY`s_p4WryZwqQc{MCZ^n+>l9bIN=g=Ztxf zZGTr|e0U->w%2-p@HcfMck<@$VUBGNgYQl2yO@0u%nvh+FY3oEQ_uWr>iP3Lt1I<9 z7uvYf$P2rdgII{6Gu2EnyTn61@k?)?54~_IICD;i_ruAc-bOuR)T`RDXT3YEI6p}5 zE3s*h?hM^98``eX2if*_J*$4z9^39L`bG}Ugipeeu$GQVetRLh)tA_t;cOlaZPeB< zeyKh6uLhhOW|{L_EYyS^Fh86j>aQ2hg%e>I{p-EMwlnl>VE4ITrsxB+OMgBe^g}ni z6FNcNzZlljzuxa(Mu(j1KQ;Jr5Ci*`6L;tJbWHO5f#|nSdlm=%;C~lqSueOpZw-^! zSZl5GOAnYsa$z2bhr7&s!Mx#v^X!?hmacJrGsg}GGe8_>!bZ-2XQx_MZ+buth=H27 ze|5hzs0DFQ4{E=y8Kvhw4Pwr=S>)b&J&Y62QP-%uYr#49QqWUuZwaI78s~le*A4b9 z#_G`Q)sNT@ zJ{07tIg{k+WN37aBh{UmC;ocSonj;RZ|`Dn_UM1-k$HbMn8A9HUvkt7=KuDfPLG6f z_T0ZlZHfE1_g8+KU-ISrk~8zfnXb0FVQ=UK`O{;&LQ`jt<+)K8`eqn?qrPL$4+Zt=uEMUp zi-9_pANM9ch}G$!c6WzSZQbvE^HB||H*-@B=oS0!1orN?uC2e>yZRUReStsDO@15; zX7=VVtvd|c_C6EbpV;3YoTbg#&4>4c-gAdJ8u)W5Od1Du&#n(&)CuOh`k)>hsLh>_rkTnt{B*R8+IEV7o+d#zz?<`31(_HnA>bO?@mLq zza#7oY`+oo*Oo91dxJXdQLoFbI#dtxYxZq~5B6{Fh{27(2l;Sscr?5h%pZB^gzI5& z50iX95PjzF<-qRQU?w{IoQKX;KAMYSr6$d%No@A7Wl#LsR(CVOOk(rlAO`BvSs(@n zLnr8i6QN(%IL~TLUzss%=)L2?oKX|v&+a(7hgl=;SA!h5L&}G}i-kPw3*uuIioe*a z&u0H8^E`~52fcG6>)BEFdeEGqyRGx>L3W$J+rPNW{|CW7uY^wcEHv*;OTHZ&a?Fo+ z1N;2x2Jzk*)cm2aJ-GWhd)o5HeXrTSx|iQxaPDu^{@J&8wa*{%aPFx?HQCl)@25x3 z)xTJO9PFQ6eu%x8IFDz-xgaKUVM}PEM(I2o%sMun2<)4~a?ZE=`Q6sv)SZ345)KFU zoP+y=*o%+)I~deNTXq{g&CLBOx*-Pd{^r7o#rhtRkDH0lJvrm0yVEz~pWj@Z|MOpd gH~;UYf6V{ykH4D#*Ps4A|Jz^x;mk|Ne?Ncg{}{PuH~;_u literal 0 HcmV?d00001 diff --git a/src/test/resources/data/memoryoptsearch/faiss_hnsw_one_vector.bin b/src/test/resources/data/memoryoptsearch/faiss_hnsw_one_vector.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5d3483cb92eb1b8795a81b5093a8388a7953060 GIT binary patch literal 308 zcmd;JKnCyZA-r`^IuA0mwE0Vh11&0OAB7E&$>N jAf5okj6e(mAesrxLJ$g2CXD_+zyRj56d(hHL2d>Bk|vcm literal 0 HcmV?d00001