Some checks failed
Coverage / coverage (push) Failing after 6s
Tests / test (1.24.x, ubuntu-latest) (push) Failing after 42s
Tests / test (1.26.x, ubuntu-latest) (push) Failing after 38s
Tests / test (1.25.x, ubuntu-latest) (push) Failing after 35s
Tests / test (1.25.x, windows-latest) (push) Has been cancelled
Tests / test (1.26.x, macos-latest) (push) Has been cancelled
Tests / test (1.26.x, windows-latest) (push) Has been cancelled
Tests / test (1.24.x, macos-latest) (push) Has been cancelled
Tests / test (1.24.x, windows-latest) (push) Has been cancelled
Tests / test (1.25.x, macos-latest) (push) Has been cancelled
3143 lines
88 KiB
Go
3143 lines
88 KiB
Go
// Copyright (c) 2023 Couchbase, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//go:build vectors
|
|
// +build vectors
|
|
|
|
package bleve
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
|
"github.com/blevesearch/bleve/v2/analysis/lang/en"
|
|
"github.com/blevesearch/bleve/v2/index/scorch"
|
|
"github.com/blevesearch/bleve/v2/mapping"
|
|
"github.com/blevesearch/bleve/v2/search"
|
|
"github.com/blevesearch/bleve/v2/search/query"
|
|
index "github.com/blevesearch/bleve_index_api"
|
|
)
|
|
|
|
const testInputCompressedFile = "test/knn/knn_dataset_queries.zip"
|
|
const testDatasetFileName = "knn_dataset.json"
|
|
const testQueryFileName = "knn_queries.json"
|
|
|
|
const testDatasetDims = 384
|
|
|
|
var knnOperators []knnOperator = []knnOperator{knnOperatorAnd, knnOperatorOr}
|
|
|
|
func TestSimilaritySearchPartitionedIndex(t *testing.T) {
|
|
dataset, searchRequests, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documents := makeDatasetIntoDocuments(dataset)
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Analyzer = en.AnalyzerName
|
|
|
|
vecFieldMappingL2 := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingL2.Dims = testDatasetDims
|
|
vecFieldMappingL2.Similarity = index.EuclideanDistance
|
|
|
|
indexMappingL2Norm := NewIndexMapping()
|
|
indexMappingL2Norm.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingL2Norm.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingL2)
|
|
|
|
vecFieldMappingDot := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingDot.Dims = testDatasetDims
|
|
vecFieldMappingDot.Similarity = index.InnerProduct
|
|
|
|
indexMappingDotProduct := NewIndexMapping()
|
|
indexMappingDotProduct.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingDotProduct.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingDot)
|
|
|
|
vecFieldMappingCosine := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingCosine.Dims = testDatasetDims
|
|
vecFieldMappingCosine.Similarity = index.CosineSimilarity
|
|
|
|
indexMappingCosine := NewIndexMapping()
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingCosine)
|
|
|
|
type testCase struct {
|
|
testType string
|
|
queryIndex int
|
|
numIndexPartitions int
|
|
mapping mapping.IndexMapping
|
|
}
|
|
|
|
testCases := []testCase{
|
|
// l2 norm similarity
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=3",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 4,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=2",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 10,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
testType: "multi_partition:match:oneKNNreq:k=2",
|
|
queryIndex: 1,
|
|
numIndexPartitions: 5,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
testType: "multi_partition:disjunction:twoKNNreq:k=2,2",
|
|
queryIndex: 2,
|
|
numIndexPartitions: 4,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
// dot product similarity
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=3",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 4,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=2",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 10,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
testType: "multi_partition:match:oneKNNreq:k=2",
|
|
queryIndex: 1,
|
|
numIndexPartitions: 5,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
testType: "multi_partition:disjunction:twoKNNreq:k=2,2",
|
|
queryIndex: 2,
|
|
numIndexPartitions: 4,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
// cosine similarity
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=3",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 7,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
testType: "multi_partition:match_none:oneKNNreq:k=2",
|
|
queryIndex: 0,
|
|
numIndexPartitions: 5,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
testType: "multi_partition:match:oneKNNreq:k=2",
|
|
queryIndex: 1,
|
|
numIndexPartitions: 3,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
testType: "multi_partition:disjunction:twoKNNreq:k=2,2",
|
|
queryIndex: 2,
|
|
numIndexPartitions: 9,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
}
|
|
|
|
index := NewIndexAlias()
|
|
var reqSort = search.SortOrder{&search.SortScore{Desc: true}, &search.SortDocID{Desc: true}, &search.SortField{Desc: false, Field: "content"}}
|
|
for testCaseNum, testCase := range testCases {
|
|
originalRequest := searchRequests[testCase.queryIndex]
|
|
for _, operator := range knnOperators {
|
|
|
|
index.indexes = make([]Index, 0)
|
|
query := copySearchRequest(originalRequest, nil)
|
|
query.AddKNNOperator(operator)
|
|
query.Sort = reqSort.Copy()
|
|
query.Explain = true
|
|
|
|
nameToIndex := createPartitionedIndex(documents, index, 1, testCase.mapping, t, false)
|
|
controlResult, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(controlResult.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected control result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
cleanUp(t, nameToIndex)
|
|
|
|
index.indexes = make([]Index, 0)
|
|
query = copySearchRequest(originalRequest, nil)
|
|
query.AddKNNOperator(operator)
|
|
query.Sort = reqSort.Copy()
|
|
query.Explain = true
|
|
|
|
nameToIndex = createPartitionedIndex(documents, index, testCase.numIndexPartitions, testCase.mapping, t, false)
|
|
experimentalResult, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(experimentalResult.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
verifyResult(t, controlResult, experimentalResult, testCaseNum, true)
|
|
cleanUp(t, nameToIndex)
|
|
|
|
index.indexes = make([]Index, 0)
|
|
query = copySearchRequest(originalRequest, nil)
|
|
query.AddKNNOperator(operator)
|
|
query.Sort = reqSort.Copy()
|
|
query.Explain = true
|
|
|
|
nameToIndex = createPartitionedIndex(documents, index, testCase.numIndexPartitions, testCase.mapping, t, true)
|
|
multiLevelIndexResult, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(multiLevelIndexResult.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
verifyResult(t, multiLevelIndexResult, experimentalResult, testCaseNum, false)
|
|
cleanUp(t, nameToIndex)
|
|
|
|
}
|
|
}
|
|
|
|
var facets = map[string]*FacetRequest{
|
|
"content": {
|
|
Field: "content",
|
|
Size: 10,
|
|
},
|
|
}
|
|
|
|
index = NewIndexAlias()
|
|
for testCaseNum, testCase := range testCases {
|
|
index.indexes = make([]Index, 0)
|
|
nameToIndex := createPartitionedIndex(documents, index, testCase.numIndexPartitions, testCase.mapping, t, false)
|
|
originalRequest := searchRequests[testCase.queryIndex]
|
|
for _, operator := range knnOperators {
|
|
from, size := originalRequest.From, originalRequest.Size
|
|
query := copySearchRequest(originalRequest, nil)
|
|
query.AddKNNOperator(operator)
|
|
query.Explain = true
|
|
query.From = from
|
|
query.Size = size
|
|
|
|
// Three types of queries to run wrt sort and facet fields that require fields.
|
|
// 1. Sort And Facet are there
|
|
// 2. Sort is there, Facet is not there
|
|
// 3. Sort is not there, Facet is there
|
|
// The case where both sort and facet are not there is already covered in the previous tests.
|
|
|
|
// 1. Sort And Facet are there
|
|
query.Facets = facets
|
|
query.Sort = reqSort.Copy()
|
|
|
|
res1, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(res1.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
|
|
facetRes1 := res1.Facets
|
|
facetRes1Str, err := json.Marshal(facetRes1)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 2. Sort is there, Facet is not there
|
|
query.Facets = nil
|
|
query.Sort = reqSort.Copy()
|
|
|
|
res2, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(res2.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
|
|
// 3. Sort is not there, Facet is there
|
|
query.Facets = facets
|
|
query.Sort = nil
|
|
res3, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(res3.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
|
|
facetRes3 := res3.Facets
|
|
facetRes3Str, err := json.Marshal(facetRes3)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify the facet results
|
|
if string(facetRes1Str) != string(facetRes3Str) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected facet results to be equal", testCaseNum)
|
|
}
|
|
|
|
// Verify the results
|
|
verifyResult(t, res1, res2, testCaseNum, false)
|
|
verifyResult(t, res2, res3, testCaseNum, true)
|
|
|
|
// Test early exit fail case -> matchNone + facetRequest
|
|
query.Query = NewMatchNoneQuery()
|
|
query.Sort = reqSort.Copy()
|
|
// control case
|
|
query.Facets = nil
|
|
res4Ctrl, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(res4Ctrl.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected control Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
|
|
// experimental case
|
|
query.Facets = facets
|
|
res4Exp, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(res4Exp.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental Result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
|
|
if !(operator == knnOperatorAnd && res4Ctrl.Total == 0 && res4Exp.Total == 0) {
|
|
// catch case where no hits are returned
|
|
// due to matchNone query with a KNN request with operator AND
|
|
// where no hits are part of the intersection in multi knn request
|
|
verifyResult(t, res4Ctrl, res4Exp, testCaseNum, false)
|
|
}
|
|
}
|
|
cleanUp(t, nameToIndex)
|
|
}
|
|
|
|
// Test Pagination with multi partitioned index
|
|
index = NewIndexAlias()
|
|
index.indexes = make([]Index, 0)
|
|
nameToIndex := createPartitionedIndex(documents, index, 8, indexMappingL2Norm, t, true)
|
|
|
|
// Test From + Size pagination for Hybrid Search (2-Phase)
|
|
query := copySearchRequest(searchRequests[4], nil)
|
|
query.Sort = reqSort.Copy()
|
|
query.Facets = facets
|
|
query.Explain = true
|
|
|
|
testFromSizePagination(t, query, index, nameToIndex)
|
|
|
|
// Test From + Size pagination for Early Exit Hybrid Search (1-Phase)
|
|
query = copySearchRequest(searchRequests[4], nil)
|
|
query.Query = NewMatchNoneQuery()
|
|
query.Sort = reqSort.Copy()
|
|
query.Facets = nil
|
|
query.Explain = true
|
|
|
|
testFromSizePagination(t, query, index, nameToIndex)
|
|
|
|
cleanUp(t, nameToIndex)
|
|
}
|
|
|
|
func testFromSizePagination(t *testing.T, query *SearchRequest, index Index, nameToIndex map[string]Index) {
|
|
query.From = 0
|
|
query.Size = 30
|
|
|
|
resCtrl, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctrlHitIds := make([]string, len(resCtrl.Hits))
|
|
for i, doc := range resCtrl.Hits {
|
|
ctrlHitIds[i] = doc.ID
|
|
}
|
|
// experimental case
|
|
|
|
fromValues := []int{0, 5, 10, 15, 20, 25}
|
|
size := 5
|
|
for fromIdx := 0; fromIdx < len(fromValues); fromIdx++ {
|
|
from := fromValues[fromIdx]
|
|
query.From = from
|
|
query.Size = size
|
|
resExp, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if from >= len(ctrlHitIds) {
|
|
if len(resExp.Hits) != 0 {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("expected 0 hits, got %d", len(resExp.Hits))
|
|
}
|
|
continue
|
|
}
|
|
numHitsExp := len(resExp.Hits)
|
|
numHitsCtrl := min(len(ctrlHitIds)-from, size)
|
|
if numHitsExp != numHitsCtrl {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("expected %d hits, got %d", numHitsCtrl, numHitsExp)
|
|
}
|
|
for i := 0; i < numHitsExp; i++ {
|
|
doc := resExp.Hits[i]
|
|
startOffset := from + i
|
|
if doc.ID != ctrlHitIds[startOffset] {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("expected %s at index %d, got %s", ctrlHitIds[startOffset], i, doc.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVectorBase64Index(t *testing.T) {
|
|
dataset, searchRequests, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documents := makeDatasetIntoDocuments(dataset)
|
|
|
|
_, searchRequestsCopy, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, doc := range documents {
|
|
vec, ok := doc["vector"].([]float32)
|
|
if !ok {
|
|
t.Fatal("Typecasting vector to float array failed")
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
for _, v := range vec {
|
|
err := binary.Write(buf, binary.LittleEndian, v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
doc["vectorEncoded"] = base64.StdEncoding.EncodeToString(buf.Bytes())
|
|
}
|
|
|
|
for _, sr := range searchRequestsCopy {
|
|
for _, kr := range sr.KNN {
|
|
kr.Field = "vectorEncoded"
|
|
}
|
|
}
|
|
|
|
contentFM := NewTextFieldMapping()
|
|
contentFM.Analyzer = en.AnalyzerName
|
|
|
|
vecFML2 := mapping.NewVectorFieldMapping()
|
|
vecFML2.Dims = testDatasetDims
|
|
vecFML2.Similarity = index.EuclideanDistance
|
|
|
|
vecBFML2 := mapping.NewVectorBase64FieldMapping()
|
|
vecBFML2.Dims = testDatasetDims
|
|
vecBFML2.Similarity = index.EuclideanDistance
|
|
|
|
vecFMDot := mapping.NewVectorFieldMapping()
|
|
vecFMDot.Dims = testDatasetDims
|
|
vecFMDot.Similarity = index.InnerProduct
|
|
|
|
vecBFMDot := mapping.NewVectorBase64FieldMapping()
|
|
vecBFMDot.Dims = testDatasetDims
|
|
vecBFMDot.Similarity = index.InnerProduct
|
|
|
|
indexMappingL2 := NewIndexMapping()
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("content", contentFM)
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("vector", vecFML2)
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("vectorEncoded", vecBFML2)
|
|
|
|
indexMappingDot := NewIndexMapping()
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("content", contentFM)
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("vector", vecFMDot)
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("vectorEncoded", vecBFMDot)
|
|
|
|
tmpIndexPathL2 := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPathL2)
|
|
|
|
tmpIndexPathDot := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPathDot)
|
|
|
|
indexL2, err := New(tmpIndexPathL2, indexMappingL2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := indexL2.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
indexDot, err := New(tmpIndexPathDot, indexMappingDot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := indexDot.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batchL2 := indexL2.NewBatch()
|
|
batchDot := indexDot.NewBatch()
|
|
|
|
for _, doc := range documents {
|
|
err = batchL2.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = batchDot.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = indexL2.Batch(batchL2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = indexDot.Batch(batchDot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for i := range searchRequests {
|
|
for _, operator := range knnOperators {
|
|
controlQuery := searchRequests[i]
|
|
testQuery := searchRequestsCopy[i]
|
|
|
|
controlQuery.AddKNNOperator(operator)
|
|
testQuery.AddKNNOperator(operator)
|
|
|
|
controlResultL2, err := indexL2.Search(controlQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testResultL2, err := indexL2.Search(testQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if controlResultL2 != nil && testResultL2 != nil {
|
|
if len(controlResultL2.Hits) == len(testResultL2.Hits) {
|
|
for j := range controlResultL2.Hits {
|
|
if controlResultL2.Hits[j].ID != testResultL2.Hits[j].ID {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, controlResultL2.Hits[j].ID, testResultL2.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
} else if (controlResultL2 == nil && testResultL2 != nil) ||
|
|
(controlResultL2 != nil && testResultL2 == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, controlResultL2, testResultL2)
|
|
}
|
|
|
|
controlResultDot, err := indexDot.Search(controlQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testResultDot, err := indexDot.Search(testQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if controlResultDot != nil && testResultDot != nil {
|
|
if len(controlResultDot.Hits) == len(testResultDot.Hits) {
|
|
for j := range controlResultDot.Hits {
|
|
if controlResultDot.Hits[j].ID != testResultDot.Hits[j].ID {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, controlResultDot.Hits[j].ID, testResultDot.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
} else if (controlResultDot == nil && testResultDot != nil) ||
|
|
(controlResultDot != nil && testResultDot == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, controlResultDot, testResultDot)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test to verify that the bivf-flat indexes with vector base64 field mapping returns the
|
|
// same results as the non-optimized vector field mapping for L2, Dot Product and Cosine similarities.
|
|
// Also test to see no differences in results for any distance metric
|
|
func TestVectorBivfIndexes(t *testing.T) {
|
|
optimizations := []string{index.IndexBIVFWithBackingSQ8, index.IndexBIVFWithBackingFlat}
|
|
for _, optimization := range optimizations {
|
|
testVectorBivfIndex(t, optimization)
|
|
}
|
|
}
|
|
|
|
func testVectorBivfIndex(t *testing.T, optimization string) {
|
|
|
|
dataset, searchRequests, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documents := makeDatasetIntoDocuments(dataset)
|
|
|
|
_, searchRequestsCopy, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, doc := range documents {
|
|
vec, ok := doc["vector"].([]float32)
|
|
if !ok {
|
|
t.Fatal("Typecasting vector to float array failed")
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
for _, v := range vec {
|
|
err := binary.Write(buf, binary.LittleEndian, v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
doc["vectorEncoded"] = base64.StdEncoding.EncodeToString(buf.Bytes())
|
|
}
|
|
|
|
for _, sr := range searchRequestsCopy {
|
|
for _, kr := range sr.KNN {
|
|
kr.Field = "vectorEncoded"
|
|
}
|
|
}
|
|
|
|
contentFM := NewTextFieldMapping()
|
|
contentFM.Analyzer = en.AnalyzerName
|
|
|
|
vecFML2 := mapping.NewVectorFieldMapping()
|
|
vecFML2.Dims = testDatasetDims
|
|
vecFML2.Similarity = index.EuclideanDistance
|
|
vecFML2.VectorIndexOptimizedFor = optimization
|
|
|
|
vecBFML2 := mapping.NewVectorBase64FieldMapping()
|
|
vecBFML2.Dims = testDatasetDims
|
|
vecBFML2.Similarity = index.EuclideanDistance
|
|
vecBFML2.VectorIndexOptimizedFor = optimization
|
|
|
|
vecFMDot := mapping.NewVectorFieldMapping()
|
|
vecFMDot.Dims = testDatasetDims
|
|
vecFMDot.Similarity = index.InnerProduct
|
|
vecFMDot.VectorIndexOptimizedFor = optimization
|
|
|
|
vecBFMDot := mapping.NewVectorBase64FieldMapping()
|
|
vecBFMDot.Dims = testDatasetDims
|
|
vecBFMDot.Similarity = index.InnerProduct
|
|
vecBFMDot.VectorIndexOptimizedFor = optimization
|
|
|
|
vecFMCosine := mapping.NewVectorFieldMapping()
|
|
vecFMCosine.Dims = testDatasetDims
|
|
vecFMCosine.Similarity = index.CosineSimilarity
|
|
|
|
vecBFMCosine := mapping.NewVectorBase64FieldMapping()
|
|
vecBFMCosine.Dims = testDatasetDims
|
|
vecBFMCosine.Similarity = index.CosineSimilarity
|
|
vecBFMCosine.VectorIndexOptimizedFor = optimization
|
|
|
|
indexMappingL2 := NewIndexMapping()
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("content", contentFM)
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("vector", vecFML2)
|
|
indexMappingL2.DefaultMapping.AddFieldMappingsAt("vectorEncoded", vecBFML2)
|
|
|
|
indexMappingDot := NewIndexMapping()
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("content", contentFM)
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("vector", vecFMDot)
|
|
indexMappingDot.DefaultMapping.AddFieldMappingsAt("vectorEncoded", vecBFMDot)
|
|
|
|
indexMappingCosine := NewIndexMapping()
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("content", contentFM)
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("vector", vecFMCosine)
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("vectorEncoded", vecBFMCosine)
|
|
|
|
tmpIndexPathL2 := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPathL2)
|
|
|
|
tmpIndexPathDot := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPathDot)
|
|
|
|
tmpIndexPathCosine := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPathCosine)
|
|
|
|
indexL2, err := New(tmpIndexPathL2, indexMappingL2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := indexL2.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
indexDot, err := New(tmpIndexPathDot, indexMappingDot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := indexDot.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
indexCosine, err := New(tmpIndexPathCosine, indexMappingCosine)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := indexCosine.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batchL2 := indexL2.NewBatch()
|
|
batchDot := indexDot.NewBatch()
|
|
batchCosine := indexCosine.NewBatch()
|
|
|
|
for _, doc := range documents {
|
|
err = batchL2.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = batchDot.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = batchCosine.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = indexL2.Batch(batchL2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = indexDot.Batch(batchDot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = indexCosine.Batch(batchCosine)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for i := range searchRequests {
|
|
for _, operator := range knnOperators {
|
|
normQuery := searchRequests[i]
|
|
base64Query := searchRequestsCopy[i]
|
|
|
|
normQuery.AddKNNOperator(operator)
|
|
base64Query.AddKNNOperator(operator)
|
|
|
|
normResultL2, err := indexL2.Search(normQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base64ResultL2, err := indexL2.Search(base64Query)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if normResultL2 != nil && base64ResultL2 != nil {
|
|
if len(normResultL2.Hits) == len(base64ResultL2.Hits) {
|
|
for j := range normResultL2.Hits {
|
|
if normResultL2.Hits[j].ID != base64ResultL2.Hits[j].ID {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, normResultL2.Hits[j].ID, base64ResultL2.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
} else if (normResultL2 == nil && base64ResultL2 != nil) ||
|
|
(normResultL2 != nil && base64ResultL2 == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, normResultL2, base64ResultL2)
|
|
}
|
|
|
|
normResultDot, err := indexDot.Search(normQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base64ResultDot, err := indexDot.Search(base64Query)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if normResultDot != nil && base64ResultDot != nil {
|
|
if len(normResultDot.Hits) == len(base64ResultDot.Hits) {
|
|
for j := range normResultDot.Hits {
|
|
if normResultDot.Hits[j].ID != base64ResultDot.Hits[j].ID {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, normResultDot.Hits[j].ID, base64ResultDot.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
} else if (normResultDot == nil && base64ResultDot != nil) ||
|
|
(normResultDot != nil && base64ResultDot == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, normResultDot, base64ResultDot)
|
|
}
|
|
|
|
normResultCosine, err := indexCosine.Search(normQuery)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base64ResultCosine, err := indexCosine.Search(base64Query)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if normResultCosine != nil && base64ResultCosine != nil {
|
|
if len(normResultCosine.Hits) == len(base64ResultCosine.Hits) {
|
|
for j := range normResultCosine.Hits {
|
|
if normResultCosine.Hits[j].ID != base64ResultCosine.Hits[j].ID {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, normResultCosine.Hits[j].ID, base64ResultCosine.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
} else if (normResultCosine == nil && base64ResultCosine != nil) ||
|
|
(normResultCosine != nil && base64ResultCosine == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, normResultCosine, base64ResultCosine)
|
|
}
|
|
|
|
if normResultCosine != nil && normResultL2 != nil {
|
|
if len(normResultCosine.Hits) == len(normResultL2.Hits) {
|
|
for j := range normResultCosine.Hits {
|
|
if normResultCosine.Hits[j].ID != normResultL2.Hits[j].ID {
|
|
if normResultCosine.Hits[j].Score != normResultL2.Hits[j].Score {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, normResultCosine.Hits[j].ID, normResultL2.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (normResultCosine == nil && normResultL2 != nil) ||
|
|
(normResultCosine != nil && normResultL2 == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, normResultCosine, normResultL2)
|
|
}
|
|
|
|
if normResultCosine != nil && normResultDot != nil {
|
|
if len(normResultCosine.Hits) == len(normResultDot.Hits) {
|
|
for j := range normResultCosine.Hits {
|
|
if normResultCosine.Hits[j].ID != normResultDot.Hits[j].ID {
|
|
if normResultCosine.Hits[j].Score != normResultDot.Hits[j].Score {
|
|
t.Fatalf("testcase %d failed: expected hit id %s, got hit id %s", i, normResultCosine.Hits[j].ID, normResultDot.Hits[j].ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (normResultCosine == nil && normResultDot != nil) ||
|
|
(normResultCosine != nil && normResultDot == nil) {
|
|
t.Fatalf("testcase %d failed: expected result %s, got result %s", i, normResultCosine, normResultDot)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type testDocument struct {
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
Vector []float32 `json:"vector"`
|
|
}
|
|
|
|
func readDatasetAndQueries(fileName string) ([]testDocument, []*SearchRequest, error) {
|
|
// Open the zip archive for reading
|
|
r, err := zip.OpenReader(fileName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var dataset []testDocument
|
|
var queries []*SearchRequest
|
|
|
|
defer r.Close()
|
|
for _, f := range r.File {
|
|
jsonFile, err := f.Open()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer jsonFile.Close()
|
|
if f.Name == testDatasetFileName {
|
|
err = json.NewDecoder(jsonFile).Decode(&dataset)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else if f.Name == testQueryFileName {
|
|
err = json.NewDecoder(jsonFile).Decode(&queries)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
}
|
|
return dataset, queries, nil
|
|
}
|
|
|
|
func makeDatasetIntoDocuments(dataset []testDocument) []map[string]interface{} {
|
|
documents := make([]map[string]interface{}, len(dataset))
|
|
for i := 0; i < len(dataset); i++ {
|
|
document := make(map[string]interface{})
|
|
document["id"] = dataset[i].ID
|
|
document["content"] = dataset[i].Content
|
|
document["vector"] = dataset[i].Vector
|
|
documents[i] = document
|
|
}
|
|
return documents
|
|
}
|
|
|
|
func cleanUp(t *testing.T, nameToIndex map[string]Index) {
|
|
for path, childIndex := range nameToIndex {
|
|
err := childIndex.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupTmpIndexPath(t, path)
|
|
}
|
|
}
|
|
|
|
func createChildIndex(docs []map[string]interface{}, mapping mapping.IndexMapping, t *testing.T, nameToIndex map[string]Index) Index {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
index, err := New(tmpIndexPath, mapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nameToIndex[index.Name()] = index
|
|
batch := index.NewBatch()
|
|
for _, doc := range docs {
|
|
err := batch.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
return index
|
|
}
|
|
|
|
func createPartitionedIndex(documents []map[string]interface{}, index *indexAliasImpl, numPartitions int,
|
|
mapping mapping.IndexMapping, t *testing.T, multiLevel bool) map[string]Index {
|
|
|
|
partitionSize := len(documents) / numPartitions
|
|
extraDocs := len(documents) % numPartitions
|
|
numDocsPerPartition := make([]int, numPartitions)
|
|
for i := 0; i < numPartitions; i++ {
|
|
numDocsPerPartition[i] = partitionSize
|
|
if extraDocs > 0 {
|
|
numDocsPerPartition[i]++
|
|
extraDocs--
|
|
}
|
|
}
|
|
docsPerPartition := make([][]map[string]interface{}, numPartitions)
|
|
prevCutoff := 0
|
|
for i := 0; i < numPartitions; i++ {
|
|
docsPerPartition[i] = make([]map[string]interface{}, numDocsPerPartition[i])
|
|
for j := 0; j < numDocsPerPartition[i]; j++ {
|
|
docsPerPartition[i][j] = documents[prevCutoff+j]
|
|
}
|
|
prevCutoff += numDocsPerPartition[i]
|
|
}
|
|
|
|
rv := make(map[string]Index)
|
|
if !multiLevel {
|
|
// all indexes are at the same level
|
|
for i := 0; i < numPartitions; i++ {
|
|
index.Add(createChildIndex(docsPerPartition[i], mapping, t, rv))
|
|
}
|
|
} else {
|
|
// alias tree
|
|
indexes := make([]Index, numPartitions)
|
|
for i := 0; i < numPartitions; i++ {
|
|
indexes[i] = createChildIndex(docsPerPartition[i], mapping, t, rv)
|
|
}
|
|
numAlias := int(math.Ceil(float64(numPartitions) / 2.0))
|
|
aliases := make([]IndexAlias, numAlias)
|
|
for i := 0; i < numAlias; i++ {
|
|
aliases[i] = NewIndexAlias()
|
|
aliases[i].SetName(fmt.Sprintf("alias%d", i))
|
|
for j := 0; j < 2; j++ {
|
|
if i*2+j < numPartitions {
|
|
aliases[i].Add(indexes[i*2+j])
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i < numAlias; i++ {
|
|
index.Add(aliases[i])
|
|
}
|
|
}
|
|
return rv
|
|
}
|
|
|
|
func createMultipleSegmentsIndex(documents []map[string]interface{}, index Index, numSegments int) error {
|
|
// create multiple batches to simulate more than one segment
|
|
numBatches := numSegments
|
|
|
|
batches := make([]*Batch, numBatches)
|
|
numDocsPerBatch := len(documents) / numBatches
|
|
extraDocs := len(documents) % numBatches
|
|
|
|
docsPerBatch := make([]int, numBatches)
|
|
for i := 0; i < numBatches; i++ {
|
|
docsPerBatch[i] = numDocsPerBatch
|
|
if extraDocs > 0 {
|
|
docsPerBatch[i]++
|
|
extraDocs--
|
|
}
|
|
}
|
|
prevCutoff := 0
|
|
for i := 0; i < numBatches; i++ {
|
|
batches[i] = index.NewBatch()
|
|
for j := prevCutoff; j < prevCutoff+docsPerBatch[i]; j++ {
|
|
doc := documents[j]
|
|
err := batches[i].Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
prevCutoff += docsPerBatch[i]
|
|
}
|
|
errMutex := sync.Mutex{}
|
|
var errors []error
|
|
wg := sync.WaitGroup{}
|
|
wg.Add(len(batches))
|
|
for i, batch := range batches {
|
|
go func(ix int, batchx *Batch) {
|
|
defer wg.Done()
|
|
err := index.Batch(batchx)
|
|
if err != nil {
|
|
errMutex.Lock()
|
|
errors = append(errors, err)
|
|
errMutex.Unlock()
|
|
}
|
|
}(i, batch)
|
|
}
|
|
wg.Wait()
|
|
if len(errors) > 0 {
|
|
return errors[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func truncateScore(score float64) float64 {
|
|
epsilon := 1e-4
|
|
truncated := float64(int(score*1e6)) / 1e6
|
|
if math.Abs(truncated-1.0) <= epsilon {
|
|
return 1.0
|
|
}
|
|
return truncated
|
|
}
|
|
|
|
// Function to compare two Explanation structs recursively
|
|
func compareExplanation(a, b *search.Explanation) bool {
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
|
|
if truncateScore(a.Value) != truncateScore(b.Value) || len(a.Children) != len(b.Children) {
|
|
return false
|
|
}
|
|
|
|
// Sort the children slices before comparison
|
|
sortChildren(a.Children)
|
|
sortChildren(b.Children)
|
|
|
|
for i := range a.Children {
|
|
if !compareExplanation(a.Children[i], b.Children[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Function to sort the children slices
|
|
func sortChildren(children []*search.Explanation) {
|
|
sort.Slice(children, func(i, j int) bool {
|
|
return children[i].Value < children[j].Value
|
|
})
|
|
}
|
|
|
|
// All hits from a hybrid search/knn search should not have
|
|
// index names or score breakdown.
|
|
func finalHitsOmitKNNMetadata(hits []*search.DocumentMatch) bool {
|
|
for _, hit := range hits {
|
|
if hit.IndexNames != nil || hit.ScoreBreakdown != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func finalHitsHaveValidIndex(hits []*search.DocumentMatch, indexes map[string]Index) bool {
|
|
for _, hit := range hits {
|
|
if hit.Index == "" {
|
|
return false
|
|
}
|
|
var idx Index
|
|
var ok bool
|
|
if idx, ok = indexes[hit.Index]; !ok {
|
|
return false
|
|
}
|
|
if idx == nil {
|
|
return false
|
|
}
|
|
var doc index.Document
|
|
doc, err = idx.Document(hit.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if doc == nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func verifyResult(t *testing.T, controlResult *SearchResult, experimentalResult *SearchResult, testCaseNum int, verifyOnlyDocIDs bool) {
|
|
if controlResult.Hits.Len() == 0 || experimentalResult.Hits.Len() == 0 {
|
|
t.Fatalf("test case #%d failed: 0 hits returned", testCaseNum)
|
|
}
|
|
if len(controlResult.Hits) != len(experimentalResult.Hits) {
|
|
t.Fatalf("test case #%d failed: expected %d results, got %d", testCaseNum, len(controlResult.Hits), len(experimentalResult.Hits))
|
|
}
|
|
if controlResult.Total != experimentalResult.Total {
|
|
t.Fatalf("test case #%d failed: expected total hits to be %d, got %d", testCaseNum, controlResult.Total, experimentalResult.Total)
|
|
}
|
|
// KNN Metadata -> Score Breakdown and IndexNames MUST be omitted from the final hits
|
|
if !finalHitsOmitKNNMetadata(controlResult.Hits) || !finalHitsOmitKNNMetadata(experimentalResult.Hits) {
|
|
t.Fatalf("test case #%d failed: expected no KNN metadata in hits", testCaseNum)
|
|
}
|
|
if controlResult.Took == 0 || experimentalResult.Took == 0 {
|
|
t.Fatalf("test case #%d failed: expected non-zero took time", testCaseNum)
|
|
}
|
|
if controlResult.Request == nil || experimentalResult.Request == nil {
|
|
t.Fatalf("test case #%d failed: expected non-nil request", testCaseNum)
|
|
}
|
|
if verifyOnlyDocIDs {
|
|
// in multi partitioned index, we cannot be sure of the score or the ordering of the hits as the tf-idf scores are localized to each partition
|
|
// so we only check the ids
|
|
controlMap := make(map[string]struct{})
|
|
experimentalMap := make(map[string]struct{})
|
|
for _, hit := range controlResult.Hits {
|
|
controlMap[hit.ID] = struct{}{}
|
|
}
|
|
for _, hit := range experimentalResult.Hits {
|
|
experimentalMap[hit.ID] = struct{}{}
|
|
}
|
|
if len(controlMap) != len(experimentalMap) {
|
|
t.Fatalf("test case #%d failed: expected %d results, got %d", testCaseNum, len(controlMap), len(experimentalMap))
|
|
}
|
|
for id := range controlMap {
|
|
if _, ok := experimentalMap[id]; !ok {
|
|
t.Fatalf("test case #%d failed: expected id %s to be in experimental result", testCaseNum, id)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for i := 0; i < len(controlResult.Hits); i++ {
|
|
if controlResult.Hits[i].ID != experimentalResult.Hits[i].ID {
|
|
t.Fatalf("test case #%d failed: expected hit %d to have id %s, got %s", testCaseNum, i, controlResult.Hits[i].ID, experimentalResult.Hits[i].ID)
|
|
}
|
|
// Truncate to 6 decimal places
|
|
actualScore := truncateScore(experimentalResult.Hits[i].Score)
|
|
expectScore := truncateScore(controlResult.Hits[i].Score)
|
|
if expectScore != actualScore {
|
|
t.Fatalf("test case #%d failed: expected hit %d to have score %f, got %f", testCaseNum, i, expectScore, actualScore)
|
|
}
|
|
if !compareExplanation(controlResult.Hits[i].Expl, experimentalResult.Hits[i].Expl) {
|
|
t.Fatalf("test case #%d failed: expected hit %d to have explanation %v, got %v", testCaseNum, i, controlResult.Hits[i].Expl, experimentalResult.Hits[i].Expl)
|
|
}
|
|
}
|
|
if truncateScore(controlResult.MaxScore) != truncateScore(experimentalResult.MaxScore) {
|
|
t.Fatalf("test case #%d: expected maxScore to be %f, got %f", testCaseNum, controlResult.MaxScore, experimentalResult.MaxScore)
|
|
}
|
|
}
|
|
|
|
func TestSimilaritySearchMultipleSegments(t *testing.T) {
|
|
// using scorch options to prevent merges during the course of this test
|
|
// so that the knnCollector can be accurately tested
|
|
scorch.DefaultMemoryPressurePauseThreshold = 0
|
|
scorch.DefaultMinSegmentsForInMemoryMerge = math.MaxInt
|
|
dataset, searchRequests, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documents := makeDatasetIntoDocuments(dataset)
|
|
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Analyzer = en.AnalyzerName
|
|
|
|
vecFieldMappingL2 := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingL2.Dims = testDatasetDims
|
|
vecFieldMappingL2.Similarity = index.EuclideanDistance
|
|
|
|
vecFieldMappingDot := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingDot.Dims = testDatasetDims
|
|
vecFieldMappingDot.Similarity = index.InnerProduct
|
|
|
|
vecFieldMappingCosine := mapping.NewVectorFieldMapping()
|
|
vecFieldMappingCosine.Dims = testDatasetDims
|
|
vecFieldMappingCosine.Similarity = index.CosineSimilarity
|
|
|
|
indexMappingL2Norm := NewIndexMapping()
|
|
indexMappingL2Norm.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingL2Norm.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingL2)
|
|
|
|
indexMappingDotProduct := NewIndexMapping()
|
|
indexMappingDotProduct.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingDotProduct.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingDot)
|
|
|
|
indexMappingCosine := NewIndexMapping()
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
indexMappingCosine.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMappingCosine)
|
|
|
|
var reqSort = search.SortOrder{&search.SortScore{Desc: true}, &search.SortDocID{Desc: true}, &search.SortField{Desc: false, Field: "content"}}
|
|
|
|
testCases := []struct {
|
|
numSegments int
|
|
queryIndex int
|
|
mapping mapping.IndexMapping
|
|
scoreValue string
|
|
}{
|
|
// L2 norm similarity
|
|
{
|
|
numSegments: 6,
|
|
queryIndex: 0,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 1,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
numSegments: 8,
|
|
queryIndex: 2,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
numSegments: 9,
|
|
queryIndex: 3,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
numSegments: 10,
|
|
queryIndex: 4,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
{
|
|
numSegments: 11,
|
|
queryIndex: 5,
|
|
mapping: indexMappingL2Norm,
|
|
},
|
|
// dot_product similarity
|
|
{
|
|
numSegments: 6,
|
|
queryIndex: 0,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 1,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
numSegments: 8,
|
|
queryIndex: 2,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
numSegments: 9,
|
|
queryIndex: 3,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
numSegments: 10,
|
|
queryIndex: 4,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
{
|
|
numSegments: 11,
|
|
queryIndex: 5,
|
|
mapping: indexMappingDotProduct,
|
|
},
|
|
// cosine similarity
|
|
{
|
|
numSegments: 9,
|
|
queryIndex: 0,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
numSegments: 5,
|
|
queryIndex: 1,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
numSegments: 4,
|
|
queryIndex: 2,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
numSegments: 12,
|
|
queryIndex: 3,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 4,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
{
|
|
numSegments: 11,
|
|
queryIndex: 5,
|
|
mapping: indexMappingCosine,
|
|
},
|
|
// score none test
|
|
{
|
|
numSegments: 3,
|
|
queryIndex: 0,
|
|
mapping: indexMappingL2Norm,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 1,
|
|
mapping: indexMappingL2Norm,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 8,
|
|
queryIndex: 2,
|
|
mapping: indexMappingL2Norm,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 3,
|
|
queryIndex: 0,
|
|
mapping: indexMappingDotProduct,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 1,
|
|
mapping: indexMappingDotProduct,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 8,
|
|
queryIndex: 2,
|
|
mapping: indexMappingDotProduct,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 3,
|
|
queryIndex: 0,
|
|
mapping: indexMappingCosine,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 7,
|
|
queryIndex: 1,
|
|
mapping: indexMappingCosine,
|
|
scoreValue: "none",
|
|
},
|
|
{
|
|
numSegments: 8,
|
|
queryIndex: 2,
|
|
mapping: indexMappingCosine,
|
|
scoreValue: "none",
|
|
},
|
|
}
|
|
for testCaseNum, testCase := range testCases {
|
|
originalRequest := searchRequests[testCase.queryIndex]
|
|
for _, operator := range knnOperators {
|
|
// run single segment test first
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
index, err := New(tmpIndexPath, testCase.mapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
query := copySearchRequest(originalRequest, nil)
|
|
query.Sort = reqSort.Copy()
|
|
query.AddKNNOperator(operator)
|
|
query.Explain = true
|
|
|
|
nameToIndex := make(map[string]Index)
|
|
nameToIndex[index.Name()] = index
|
|
|
|
err = createMultipleSegmentsIndex(documents, index, 1)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
controlResult, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(controlResult.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected control result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
if testCase.scoreValue == "none" {
|
|
|
|
query := copySearchRequest(originalRequest, nil)
|
|
query.Sort = reqSort.Copy()
|
|
query.AddKNNOperator(operator)
|
|
query.Explain = true
|
|
query.Score = testCase.scoreValue
|
|
|
|
expectedResultScoreNone, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(expectedResultScoreNone.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected score none hits to have valid `Index`", testCaseNum)
|
|
}
|
|
verifyResult(t, controlResult, expectedResultScoreNone, testCaseNum, true)
|
|
}
|
|
cleanUp(t, nameToIndex)
|
|
|
|
// run multiple segments test
|
|
tmpIndexPath = createTmpIndexPath(t)
|
|
index, err = New(tmpIndexPath, testCase.mapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nameToIndex = make(map[string]Index)
|
|
nameToIndex[index.Name()] = index
|
|
err = createMultipleSegmentsIndex(documents, index, testCase.numSegments)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
|
|
query = copySearchRequest(originalRequest, nil)
|
|
query.Sort = reqSort.Copy()
|
|
query.AddKNNOperator(operator)
|
|
query.Explain = true
|
|
|
|
experimentalResult, err := index.Search(query)
|
|
if err != nil {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatal(err)
|
|
}
|
|
if !finalHitsHaveValidIndex(experimentalResult.Hits, nameToIndex) {
|
|
cleanUp(t, nameToIndex)
|
|
t.Fatalf("test case #%d failed: expected experimental result hits to have valid `Index`", testCaseNum)
|
|
}
|
|
verifyResult(t, controlResult, experimentalResult, testCaseNum, false)
|
|
cleanUp(t, nameToIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test to determine the impact of boost on kNN queries.
|
|
func TestKNNScoreBoosting(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 5
|
|
getRandomVector := func() []float32 {
|
|
vec := make([]float32, dims)
|
|
for i := 0; i < dims; i++ {
|
|
vec[i] = rand.Float32()
|
|
}
|
|
return vec
|
|
}
|
|
|
|
dataset := make([]map[string]interface{}, 10)
|
|
|
|
// Indexing just a few docs to populate index.
|
|
for i := 0; i < 100; i++ {
|
|
dataset = append(dataset, map[string]interface{}{
|
|
"type": "vectorStuff",
|
|
"content": strconv.Itoa(i),
|
|
"vector": getRandomVector(),
|
|
})
|
|
}
|
|
|
|
indexMapping := NewIndexMapping()
|
|
indexMapping.TypeField = "type"
|
|
indexMapping.DefaultAnalyzer = "en"
|
|
documentMapping := NewDocumentMapping()
|
|
indexMapping.AddDocumentMapping("vectorStuff", documentMapping)
|
|
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Index = true
|
|
contentFieldMapping.Store = true
|
|
documentMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Index = true
|
|
vecFieldMapping.Dims = 5
|
|
vecFieldMapping.Similarity = "dot_product"
|
|
documentMapping.AddFieldMappingsAt("vector", vecFieldMapping)
|
|
|
|
index, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := index.NewBatch()
|
|
for i := 0; i < len(dataset); i++ {
|
|
err = batch.Index(strconv.Itoa(i), dataset[i])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
queryVec := getRandomVector()
|
|
searchRequest := NewSearchRequest(NewMatchNoneQuery())
|
|
searchRequest.AddKNN("vector", queryVec, 3, 1.0)
|
|
searchRequest.Fields = []string{"content", "vector"}
|
|
|
|
hits, err := index.Search(searchRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hitsMap := make(map[string]float64, 0)
|
|
for _, hit := range hits.Hits {
|
|
hitsMap[hit.ID] = (hit.Score)
|
|
}
|
|
|
|
searchRequest = NewSearchRequest(NewMatchNoneQuery())
|
|
searchRequest.AddKNN("vector", queryVec, 3, 10.0)
|
|
searchRequest.Fields = []string{"content", "vector"}
|
|
|
|
hits, err = index.Search(searchRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hitsMap2 := make(map[string]float64, 0)
|
|
for _, hit := range hits.Hits {
|
|
hitsMap2[hit.ID] = (hit.Score)
|
|
}
|
|
|
|
for _, hit := range hits.Hits {
|
|
if hitsMap[hit.ID] != hitsMap2[hit.ID]/10 {
|
|
t.Errorf("boosting not working: %v %v \n", hitsMap[hit.ID], hitsMap2[hit.ID])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test to see if KNN Operators get added right to the query.
|
|
func TestKNNOperator(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 5
|
|
getRandomVector := func() []float32 {
|
|
vec := make([]float32, dims)
|
|
for i := 0; i < dims; i++ {
|
|
vec[i] = rand.Float32()
|
|
}
|
|
return vec
|
|
}
|
|
|
|
dataset := make([]map[string]interface{}, 10)
|
|
|
|
// Indexing just a few docs to populate index.
|
|
for i := 0; i < 10; i++ {
|
|
dataset = append(dataset, map[string]interface{}{
|
|
"type": "vectorStuff",
|
|
"content": strconv.Itoa(i),
|
|
"vector": getRandomVector(),
|
|
})
|
|
}
|
|
|
|
indexMapping := NewIndexMapping()
|
|
indexMapping.TypeField = "type"
|
|
indexMapping.DefaultAnalyzer = "en"
|
|
documentMapping := NewDocumentMapping()
|
|
indexMapping.AddDocumentMapping("vectorStuff", documentMapping)
|
|
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Index = true
|
|
contentFieldMapping.Store = true
|
|
documentMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Index = true
|
|
vecFieldMapping.Dims = 5
|
|
vecFieldMapping.Similarity = "dot_product"
|
|
documentMapping.AddFieldMappingsAt("vector", vecFieldMapping)
|
|
|
|
index, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := index.NewBatch()
|
|
for i := 0; i < len(dataset); i++ {
|
|
err = batch.Index(strconv.Itoa(i), dataset[i])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
termQuery := query.NewTermQuery("2")
|
|
|
|
searchRequest := NewSearchRequest(termQuery)
|
|
searchRequest.AddKNN("vector", getRandomVector(), 3, 2.0)
|
|
searchRequest.AddKNN("vector", getRandomVector(), 2, 1.5)
|
|
searchRequest.Fields = []string{"content", "vector"}
|
|
|
|
// Conjunction
|
|
searchRequest.AddKNNOperator(knnOperatorAnd)
|
|
conjunction, _, _, err := createKNNQuery(searchRequest, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for AND knn operator")
|
|
}
|
|
|
|
conj, ok := conjunction.(*query.DisjunctionQuery)
|
|
if !ok {
|
|
t.Fatalf("expected disjunction query")
|
|
}
|
|
|
|
if len(conj.Disjuncts) != 2 {
|
|
t.Fatalf("expected 2 disjuncts")
|
|
}
|
|
|
|
// Disjunction
|
|
searchRequest.AddKNNOperator(knnOperatorOr)
|
|
disjunction, _, _, err := createKNNQuery(searchRequest, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for OR knn operator")
|
|
}
|
|
|
|
disj, ok := disjunction.(*query.DisjunctionQuery)
|
|
if !ok {
|
|
t.Fatalf("expected disjunction query")
|
|
}
|
|
|
|
if len(disj.Disjuncts) != 2 {
|
|
t.Fatalf("expected 2 disjuncts")
|
|
}
|
|
|
|
// Incorrect operator.
|
|
searchRequest.AddKNNOperator("bs_op")
|
|
searchRequest.Query, _, _, err = createKNNQuery(searchRequest, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected error for incorrect knn operator")
|
|
}
|
|
}
|
|
|
|
func TestKNNFiltering(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 5
|
|
getRandomVector := func() []float32 {
|
|
vec := make([]float32, dims)
|
|
for i := 0; i < dims; i++ {
|
|
vec[i] = rand.Float32()
|
|
}
|
|
return vec
|
|
}
|
|
|
|
dataset := make([]map[string]interface{}, 0)
|
|
|
|
// Indexing just a few docs to populate index.
|
|
for i := 0; i < 10; i++ {
|
|
dataset = append(dataset, map[string]interface{}{
|
|
"type": "vectorStuff",
|
|
"content": strconv.Itoa(i + 1000),
|
|
"vector": getRandomVector(),
|
|
})
|
|
}
|
|
|
|
indexMapping := NewIndexMapping()
|
|
indexMapping.TypeField = "type"
|
|
indexMapping.DefaultAnalyzer = "en"
|
|
documentMapping := NewDocumentMapping()
|
|
indexMapping.AddDocumentMapping("vectorStuff", documentMapping)
|
|
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Index = true
|
|
contentFieldMapping.Store = true
|
|
documentMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Index = true
|
|
vecFieldMapping.Dims = 5
|
|
vecFieldMapping.Similarity = "dot_product"
|
|
documentMapping.AddFieldMappingsAt("vector", vecFieldMapping)
|
|
|
|
index, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := index.NewBatch()
|
|
for i := 0; i < len(dataset); i++ {
|
|
// the id of term "i" is (i-1000)
|
|
err = batch.Index(strconv.Itoa(i), dataset[i])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
termQuery := query.NewTermQuery("1004")
|
|
filterRequest := NewSearchRequest(termQuery)
|
|
filteredHits, err := index.Search(filterRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filteredDocIDs := make(map[string]struct{})
|
|
for _, match := range filteredHits.Hits {
|
|
filteredDocIDs[match.ID] = struct{}{}
|
|
}
|
|
|
|
searchRequest := NewSearchRequest(NewMatchNoneQuery())
|
|
searchRequest.AddKNNWithFilter("vector", getRandomVector(), 3, 2.0, termQuery)
|
|
searchRequest.Fields = []string{"content", "vector"}
|
|
|
|
res, err := index.Search(searchRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// check if any of the returned results are not part of the filtered hits.
|
|
for _, match := range res.Hits {
|
|
if _, exists := filteredDocIDs[match.ID]; !exists {
|
|
t.Errorf("returned result not present in filtered hits")
|
|
}
|
|
}
|
|
|
|
// No results should be returned with a match_none filter.
|
|
searchRequest = NewSearchRequest(NewMatchNoneQuery())
|
|
searchRequest.AddKNNWithFilter("vector", getRandomVector(), 3, 2.0,
|
|
NewMatchNoneQuery())
|
|
res, err = index.Search(searchRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 0 {
|
|
t.Errorf("match none filter should return no hits")
|
|
}
|
|
|
|
// Testing with a disjunction query.
|
|
|
|
termQuery = query.NewTermQuery("1003")
|
|
termQuery2 := query.NewTermQuery("1005")
|
|
disjQuery := query.NewDisjunctionQuery([]query.Query{termQuery, termQuery2})
|
|
filterRequest = NewSearchRequest(disjQuery)
|
|
filteredHits, err = index.Search(filterRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filteredDocIDs = make(map[string]struct{})
|
|
for _, match := range filteredHits.Hits {
|
|
filteredDocIDs[match.ID] = struct{}{}
|
|
}
|
|
|
|
searchRequest = NewSearchRequest(NewMatchNoneQuery())
|
|
searchRequest.AddKNNWithFilter("vector", getRandomVector(), 3, 2.0, disjQuery)
|
|
searchRequest.Fields = []string{"content", "vector"}
|
|
|
|
res, err = index.Search(searchRequest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, match := range res.Hits {
|
|
if _, exists := filteredDocIDs[match.ID]; !exists {
|
|
t.Errorf("returned result not present in filtered hits")
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Test nested vectors
|
|
|
|
func TestNestedVectors(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 3
|
|
const k = 1 // one nearest neighbor
|
|
const vecFieldName = "vecData"
|
|
|
|
dataset := map[string]map[string]interface{}{ // docID -> Doc
|
|
"doc1": {
|
|
vecFieldName: []float32{100, 100, 100},
|
|
},
|
|
"doc2": {
|
|
vecFieldName: [][]float32{{0, 0, 0}, {1000, 1000, 1000}},
|
|
},
|
|
}
|
|
|
|
// Index mapping
|
|
indexMapping := NewIndexMapping()
|
|
vm := mapping.NewVectorFieldMapping()
|
|
vm.Dims = dims
|
|
vm.Similarity = "l2_norm"
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt(vecFieldName, vm)
|
|
|
|
// Create index and upload documents
|
|
index, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := index.NewBatch()
|
|
for docID, doc := range dataset {
|
|
err = batch.Index(docID, doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Run searches
|
|
|
|
tests := []struct {
|
|
queryVec []float32
|
|
expectedDocID string
|
|
}{
|
|
{
|
|
queryVec: []float32{100, 100, 100},
|
|
expectedDocID: "doc1",
|
|
},
|
|
{
|
|
queryVec: []float32{0, 0, 0},
|
|
expectedDocID: "doc2",
|
|
},
|
|
{
|
|
queryVec: []float32{1000, 1000, 1000},
|
|
expectedDocID: "doc2",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNNWithFilter(vecFieldName, test.queryVec, k, 1000,
|
|
NewMatchAllQuery())
|
|
|
|
res, err := index.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(res.Hits) != 1 {
|
|
t.Fatalf("expected 1 hit, got %d", len(res.Hits))
|
|
}
|
|
|
|
if res.Hits[0].ID != test.expectedDocID {
|
|
t.Fatalf("expected docID %s, got %s", test.expectedDocID,
|
|
res.Hits[0].ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// TestMultiVector tests the KNN functionality which handles duplicate
|
|
// vectors being matched within the same document. When a document has multiple vectors
|
|
// (via [[]] array of vectors or [{}] array of objects with vectors), the KNN
|
|
// searcher must pick the best scoring vector match for that document. This test covers these scenarios:
|
|
// - Single vector field (baseline)
|
|
// - [[]] style: array of vectors (same doc appears multiple times)
|
|
// - [{}] style: array of objects with vector field (chunks pattern)
|
|
func TestMultiVector(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
// JSON documents covering merger scenarios:
|
|
// - Single vector (baseline)
|
|
// - [[]] style: array of vectors (same doc appears multiple times)
|
|
// - [{}] style: array of objects with vector field (chunks pattern)
|
|
docs := map[string]string{
|
|
// Single vector - baseline
|
|
"doc1": `{
|
|
"vec": [10, 10, 10],
|
|
"vecB": [100, 100, 100]
|
|
}`,
|
|
// [[]] style - array of 2 vectors
|
|
"doc2": `{
|
|
"vec": [[0, 0, 0], [500, 500, 500]],
|
|
"vecB": [[900, 900, 900], [950, 950, 950], [975, 975, 975], [990, 990, 990]]
|
|
}`,
|
|
// [[]] style - array of 3 vectors
|
|
"doc3": `{
|
|
"vec": [[50, 50, 50], [200, 200, 200], [400, 400, 400]],
|
|
"vecB": [[800, 800, 800], [850, 850, 850]]
|
|
}`,
|
|
// Single vector - baseline
|
|
"doc4": `{
|
|
"vec": [1000, 1000, 1000],
|
|
"vecB": [1, 1, 1]
|
|
}`,
|
|
// [{}] style - array of objects with vector field (chunks pattern)
|
|
"doc5": `{
|
|
"chunks": [
|
|
{"vec": [10, 10, 10], "text": "chunk1"},
|
|
{"vec": [20, 20, 20], "text": "chunk2"},
|
|
{"vec": [30, 30, 30], "text": "chunk3"},
|
|
{"vec": [40, 40, 40], "text": "chunk4"}
|
|
]
|
|
}`,
|
|
"doc6": `{
|
|
"chunks": [
|
|
{"vec": [[10, 10, 10],[20, 20, 20]], "text": "chunk1"},
|
|
{"vec": [[30, 30, 30],[40, 40, 40]], "text": "chunk2"}
|
|
]
|
|
}`,
|
|
}
|
|
|
|
// Parse JSON documents
|
|
dataset := make(map[string]map[string]interface{})
|
|
for docID, jsonStr := range docs {
|
|
var doc map[string]interface{}
|
|
if err := json.Unmarshal([]byte(jsonStr), &doc); err != nil {
|
|
t.Fatalf("failed to unmarshal %s: %v", docID, err)
|
|
}
|
|
dataset[docID] = doc
|
|
}
|
|
|
|
// Index mapping
|
|
indexMapping := NewIndexMapping()
|
|
|
|
vecMapping := mapping.NewVectorFieldMapping()
|
|
vecMapping.Dims = 3
|
|
vecMapping.Similarity = index.InnerProduct
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vec", vecMapping)
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vecB", vecMapping)
|
|
|
|
// Nested chunks mapping for [{}] style
|
|
chunksMapping := mapping.NewDocumentMapping()
|
|
chunksMapping.AddFieldMappingsAt("vec", vecMapping)
|
|
indexMapping.DefaultMapping.AddSubDocumentMapping("chunks", chunksMapping)
|
|
|
|
// Create and populate index
|
|
idx, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if err := idx.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := idx.NewBatch()
|
|
for docID, doc := range dataset {
|
|
if err := batch.Index(docID, doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := idx.Batch(batch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Test: Single KNN query - basic functionality
|
|
t.Run("VecFieldSingle", func(t *testing.T) {
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("vec", []float32{1, 1, 1}, 20, 1.0)
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Inner product: score = sum(query_i * doc_i)
|
|
// doc1 vec=[10,10,10]: 1*10*3 = 30
|
|
// doc2 vec best is [500,500,500]: 1*500*3 = 1500
|
|
// doc3 vec best is [400,400,400]: 1*400*3 = 1200
|
|
// doc4 vec=[1000,1000,1000]: 1*1000*3 = 3000
|
|
expectedResult := []struct {
|
|
docID string
|
|
expectedScore float64
|
|
}{
|
|
{docID: "doc4", expectedScore: 3000},
|
|
{docID: "doc2", expectedScore: 1500},
|
|
{docID: "doc3", expectedScore: 1200},
|
|
{docID: "doc1", expectedScore: 30},
|
|
}
|
|
|
|
if len(res.Hits) != len(expectedResult) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedResult), len(res.Hits))
|
|
}
|
|
|
|
for i, expected := range expectedResult {
|
|
if res.Hits[i].ID != expected.docID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expected.docID, res.Hits[i].ID)
|
|
}
|
|
if res.Hits[i].Score != expected.expectedScore {
|
|
t.Fatalf("at rank %d, expected score %v, got %v", i+1, expected.expectedScore, res.Hits[i].Score)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Test: Single KNN query on vecB field
|
|
t.Run("VecBFieldSingle", func(t *testing.T) {
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("vecB", []float32{1000, 1000, 1000}, 20, 1.0)
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Inner product: score = sum(query_i * doc_i) for each dimension
|
|
// doc1: vecB=[100,100,100] -> 1000*100*3 = 300,000
|
|
// doc2: vecB best is [990,990,990] -> 1000*990*3 = 2,970,000
|
|
// doc3: vecB best is [850,850,850] -> 1000*850*3 = 2,550,000
|
|
// doc4: vecB=[1,1,1] -> 1000*1*3 = 3,000
|
|
expectedResult := []struct {
|
|
docID string
|
|
expectedScore float64
|
|
}{
|
|
{docID: "doc2", expectedScore: 2970000},
|
|
{docID: "doc3", expectedScore: 2550000},
|
|
{docID: "doc1", expectedScore: 300000},
|
|
{docID: "doc4", expectedScore: 3000},
|
|
}
|
|
|
|
if len(res.Hits) != len(expectedResult) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedResult), len(res.Hits))
|
|
}
|
|
|
|
for i, expected := range expectedResult {
|
|
if res.Hits[i].ID != expected.docID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expected.docID, res.Hits[i].ID)
|
|
}
|
|
if res.Hits[i].Score != expected.expectedScore {
|
|
t.Fatalf("at rank %d, expected score %v, got %v", i+1, expected.expectedScore, res.Hits[i].Score)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Test: Single KNN query on nested chunks.vec field
|
|
t.Run("ChunksVecFieldSingle", func(t *testing.T) {
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("chunks.vec", []float32{1, 1, 1}, 20, 1.0)
|
|
searchReq.SortBy([]string{"_score", "docID"})
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Only doc5 and doc6 have chunks.vec
|
|
// doc5 chunks: [10,10,10], [20,20,20], [30,30,30], [40,40,40]
|
|
// Best score: 1*40*3 = 120
|
|
// doc6 chunks: [[10,10,10],[20,20,20]], [[30,30,30],[40,40,40]]
|
|
// Best score: 1*40*3 = 120
|
|
if len(res.Hits) != 2 {
|
|
t.Fatalf("expected 2 hits, got %d", len(res.Hits))
|
|
}
|
|
|
|
// Both should have score 120
|
|
for _, hit := range res.Hits {
|
|
if hit.ID != "doc5" && hit.ID != "doc6" {
|
|
t.Fatalf("unexpected docID %s, expected doc5 or doc6", hit.ID)
|
|
}
|
|
if hit.Score != 120 {
|
|
t.Fatalf("for %s, expected score 120, got %v", hit.ID, hit.Score)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMultiVectorCosineNormalization verifies that multi-vector fields are
|
|
// normalized correctly with cosine similarity. Each sub-vector in a multi-vector
|
|
// should be independently normalized, producing correct similarity scores.
|
|
func TestMultiVectorCosineNormalization(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 3
|
|
|
|
// Create index with cosine similarity
|
|
indexMapping := NewIndexMapping()
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Dims = dims
|
|
vecFieldMapping.Similarity = index.CosineSimilarity
|
|
|
|
// Single-vector field
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vec", vecFieldMapping)
|
|
// Multi-vector field
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("multi_vec", vecFieldMapping)
|
|
|
|
idx, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := idx.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
docsString := []string{
|
|
`{"vec": [3, 0, 0]}`,
|
|
`{"vec": [0, 4, 0]}`,
|
|
`{"multi_vec": [[3, 0, 0], [0, 4, 0]]}`,
|
|
}
|
|
|
|
for i, docStr := range docsString {
|
|
var doc map[string]interface{}
|
|
err = json.Unmarshal([]byte(docStr), &doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = idx.Index(fmt.Sprintf("doc%d", i+1), doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Query for X direction [1,0,0]
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("vec", []float32{1, 0, 0}, 3, 1.0)
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 2 {
|
|
t.Fatalf("expected 2 hits, got %d", len(res.Hits))
|
|
}
|
|
// Hit 1 should be doc1 with score 1.0 (perfect match)
|
|
if res.Hits[0].ID != "doc1" {
|
|
t.Fatalf("expected doc1 as first hit, got %s", res.Hits[0].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[0].Score-1.0)) > 1e-6 {
|
|
t.Fatalf("expected score 1.0, got %f", res.Hits[0].Score)
|
|
}
|
|
// Hit 2 should be doc2 with a score of 0.0 (orthogonal)
|
|
if res.Hits[1].ID != "doc2" {
|
|
t.Fatalf("expected doc2 as second hit, got %s", res.Hits[1].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[1].Score-0.0)) > 1e-6 {
|
|
t.Fatalf("expected score 0.0, got %f", res.Hits[1].Score)
|
|
}
|
|
|
|
// Query for Y direction [0,1,0]
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("vec", []float32{0, 1, 0}, 3, 1.0)
|
|
res, err = idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 2 {
|
|
t.Fatalf("expected 2 hits, got %d", len(res.Hits))
|
|
}
|
|
// Hit 1 should be doc2 with score 1.0 (perfect match)
|
|
if res.Hits[0].ID != "doc2" {
|
|
t.Fatalf("expected doc2 as first hit, got %s", res.Hits[0].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[0].Score-1.0)) > 1e-6 {
|
|
t.Fatalf("expected score 1.0, got %f", res.Hits[0].Score)
|
|
}
|
|
// Hit 2 should be doc1 with a score of 0.0 (orthogonal)
|
|
if res.Hits[1].ID != "doc1" {
|
|
t.Fatalf("expected doc1 as second hit, got %s", res.Hits[1].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[1].Score-0.0)) > 1e-6 {
|
|
t.Fatalf("expected score 0.0, got %f", res.Hits[1].Score)
|
|
}
|
|
|
|
// Now test querying the nested multi-vector field
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("multi_vec", []float32{1, 0, 0}, 3, 1.0)
|
|
res, err = idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 1 {
|
|
t.Fatalf("expected 1 hit, got %d", len(res.Hits))
|
|
}
|
|
// Hit should be doc3 with score 1.0 (perfect match on first sub-vector)
|
|
if res.Hits[0].ID != "doc3" {
|
|
t.Fatalf("expected doc3 as first hit, got %s", res.Hits[0].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[0].Score-1.0)) > 1e-6 {
|
|
t.Fatalf("expected score 1.0, got %f", res.Hits[0].Score)
|
|
}
|
|
// Query for Y direction [0,1,0] on nested field
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("multi_vec", []float32{0, 1, 0}, 3, 1.0)
|
|
res, err = idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 1 {
|
|
t.Fatalf("expected 1 hit, got %d", len(res.Hits))
|
|
}
|
|
// Hit should be doc3 with score 1.0 (perfect match on second sub-vector)
|
|
if res.Hits[0].ID != "doc3" {
|
|
t.Fatalf("expected doc3 as first hit, got %s", res.Hits[0].ID)
|
|
}
|
|
if math.Abs(float64(res.Hits[0].Score-1.0)) > 1e-6 {
|
|
t.Fatalf("expected score 1.0, got %f", res.Hits[0].Score)
|
|
}
|
|
}
|
|
|
|
func TestNumVecsStat(t *testing.T) {
|
|
|
|
dataset, _, err := readDatasetAndQueries(testInputCompressedFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
documents := makeDatasetIntoDocuments(dataset)
|
|
|
|
indexMapping := NewIndexMapping()
|
|
|
|
contentFieldMapping := NewTextFieldMapping()
|
|
contentFieldMapping.Analyzer = en.AnalyzerName
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
|
|
|
vecFieldMapping1 := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping1.Dims = testDatasetDims
|
|
vecFieldMapping1.Similarity = index.EuclideanDistance
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vector", vecFieldMapping1)
|
|
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
index, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
for i := 0; i < 10; i++ {
|
|
batch := index.NewBatch()
|
|
for j := 0; j < 3; j++ {
|
|
for k := 0; k < 10; k++ {
|
|
err := batch.Index(fmt.Sprintf("%d", i*30+j*10+k), documents[j*10+k])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
statsMap := index.StatsMap()
|
|
|
|
if indexStats, exists := statsMap["index"]; exists {
|
|
if indexStatsMap, ok := indexStats.(map[string]interface{}); ok {
|
|
v1, ok := indexStatsMap["field:vector:num_vectors"].(uint64)
|
|
if !ok || v1 != uint64(300) {
|
|
t.Fatalf("mismatch in the number of vectors, expected 300, got %d", indexStatsMap["field:vector:num_vectors"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIndexUpdateVector(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
indexMappingBefore := mapping.NewIndexMapping()
|
|
indexMappingBefore.TypeMapping = map[string]*mapping.DocumentMapping{}
|
|
indexMappingBefore.DefaultMapping = &mapping.DocumentMapping{
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{
|
|
"a": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
"b": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
"c": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector_base64",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
"d": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector_base64",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Fields: []*mapping.FieldMapping{},
|
|
}
|
|
indexMappingBefore.IndexDynamic = false
|
|
indexMappingBefore.StoreDynamic = false
|
|
indexMappingBefore.DocValuesDynamic = false
|
|
|
|
index, err := New(tmpIndexPath, indexMappingBefore)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
doc1 := map[string]interface{}{"a": []float32{0.32894259691238403, 0.6973215341567993, 0.6835201978683472, 0.38296082615852356}, "b": []float32{0.32894259691238403, 0.6973215341567993, 0.6835201978683472, 0.38296082615852356}, "c": "L5MOPw7NID5SQMU9pHUoPw==", "d": "L5MOPw7NID5SQMU9pHUoPw=="}
|
|
doc2 := map[string]interface{}{"a": []float32{0.0018692062003538013, 0.41076546907424927, 0.5675257444381714, 0.45832985639572144}, "b": []float32{0.0018692062003538013, 0.41076546907424927, 0.5675257444381714, 0.45832985639572144}, "c": "czloP94ZCD71ldY+GbAOPw==", "d": "czloP94ZCD71ldY+GbAOPw=="}
|
|
doc3 := map[string]interface{}{"a": []float32{0.7853356599807739, 0.6904757618904114, 0.5643226504325867, 0.682637631893158}, "b": []float32{0.7853356599807739, 0.6904757618904114, 0.5643226504325867, 0.682637631893158}, "c": "Chh6P2lOqT47mjg/0odlPg==", "d": "Chh6P2lOqT47mjg/0odlPg=="}
|
|
batch := index.NewBatch()
|
|
err = batch.Index("001", doc1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = batch.Index("002", doc2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = batch.Index("003", doc3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = index.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = index.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
indexMappingAfter := mapping.NewIndexMapping()
|
|
indexMappingAfter.TypeMapping = map[string]*mapping.DocumentMapping{}
|
|
indexMappingAfter.DefaultMapping = &mapping.DocumentMapping{
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{
|
|
"a": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
"c": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector_base64",
|
|
Index: true,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
"d": {
|
|
Enabled: true,
|
|
Dynamic: false,
|
|
Properties: map[string]*mapping.DocumentMapping{},
|
|
Fields: []*mapping.FieldMapping{
|
|
{
|
|
Type: "vector_base64",
|
|
Index: false,
|
|
Dims: 4,
|
|
Similarity: "l2_norm",
|
|
VectorIndexOptimizedFor: "latency",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Fields: []*mapping.FieldMapping{},
|
|
}
|
|
indexMappingAfter.IndexDynamic = false
|
|
indexMappingAfter.StoreDynamic = false
|
|
indexMappingAfter.DocValuesDynamic = false
|
|
|
|
mappingString, err := json.Marshal(indexMappingAfter)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config := map[string]interface{}{
|
|
"updated_mapping": string(mappingString),
|
|
}
|
|
|
|
index, err = OpenUsing(tmpIndexPath, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
q1 := NewSearchRequest(NewMatchNoneQuery())
|
|
q1.AddKNN("a", []float32{1, 2, 3, 4}, 3, 1.0)
|
|
res1, err := index.Search(q1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res1.Hits) != 3 {
|
|
t.Fatalf("Expected 3 hits, got %d", len(res1.Hits))
|
|
}
|
|
q2 := NewSearchRequest(NewMatchNoneQuery())
|
|
q2.AddKNN("b", []float32{1, 2, 3, 4}, 3, 1.0)
|
|
res2, err := index.Search(q2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res2.Hits) != 0 {
|
|
t.Fatalf("Expected 0 hits, got %d", len(res2.Hits))
|
|
}
|
|
q3 := NewSearchRequest(NewMatchNoneQuery())
|
|
q3.AddKNN("c", []float32{1, 2, 3, 4}, 3, 1.0)
|
|
res3, err := index.Search(q3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res3.Hits) != 3 {
|
|
t.Fatalf("Expected 3 hits, got %d", len(res3.Hits))
|
|
}
|
|
q4 := NewSearchRequest(NewMatchNoneQuery())
|
|
q4.AddKNN("d", []float32{1, 2, 3, 4}, 3, 1.0)
|
|
res4, err := index.Search(q4)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res4.Hits) != 0 {
|
|
t.Fatalf("Expected 0 hits, got %d", len(res4.Hits))
|
|
}
|
|
}
|
|
|
|
func TestIndexInsightsTermFrequencies(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
mp := mapping.NewIndexMapping()
|
|
textMapping := mapping.NewTextFieldMapping()
|
|
textMapping.Analyzer = "en"
|
|
mp.DefaultMapping.AddFieldMappingsAt("text", textMapping)
|
|
|
|
idx, err := New(tmpIndexPath, mp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err = idx.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
data := []map[string]string{
|
|
{
|
|
"id": "one",
|
|
"text": "She sells sea shells by the sea shore",
|
|
},
|
|
{
|
|
"id": "two",
|
|
"text": "The quick brown fox jumps over the lazy dog",
|
|
},
|
|
{
|
|
"id": "three",
|
|
"text": "She sold sea shells to the person with the dog",
|
|
},
|
|
{
|
|
"id": "four",
|
|
"text": "But there are a lot of dogs on the beach",
|
|
},
|
|
{
|
|
"id": "five",
|
|
"text": "To hell with the foxes",
|
|
},
|
|
{
|
|
"id": "six",
|
|
"text": "What about the dogs",
|
|
},
|
|
{
|
|
"id": "seven",
|
|
"text": "Dogs are OK, foxes are not",
|
|
},
|
|
}
|
|
|
|
expectTermFreqs := []index.TermFreq{
|
|
{Term: "dog", Frequency: 5},
|
|
{Term: "fox", Frequency: 3},
|
|
{Term: "sea", Frequency: 2},
|
|
{Term: "shell", Frequency: 2},
|
|
{Term: "beach", Frequency: 1},
|
|
}
|
|
|
|
for _, d := range data {
|
|
err = idx.Index(d["id"], d)
|
|
if err != nil {
|
|
t.Errorf("Error updating index: %v", err)
|
|
}
|
|
}
|
|
|
|
insightsIdx, ok := idx.(InsightsIndex)
|
|
if !ok {
|
|
t.Fatal("index does not support insights")
|
|
}
|
|
|
|
termFreqs, err := insightsIdx.TermFrequencies("text", 5, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(termFreqs, expectTermFreqs) {
|
|
t.Fatalf("term freqs do not match: got: %v, expected: %v", termFreqs, expectTermFreqs)
|
|
}
|
|
}
|
|
|
|
func TestIndexInsightsCentroidCardinalities(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
vectorDims := 5
|
|
|
|
mp := mapping.NewIndexMapping()
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Dims = vectorDims
|
|
vecFieldMapping.Similarity = index.CosineSimilarity
|
|
mp.DefaultMapping.AddFieldMappingsAt("vec", vecFieldMapping)
|
|
|
|
idx, err := New(tmpIndexPath, mp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err = idx.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
rand.Seed(time.Now().UnixNano())
|
|
min, max := float32(-10.0), float32(10.0)
|
|
genRandomVector := func() []float32 {
|
|
vec := make([]float32, vectorDims)
|
|
for i := range vec {
|
|
vec[i] = min + rand.Float32()*(max-min)
|
|
}
|
|
return vec
|
|
}
|
|
|
|
batch := idx.NewBatch()
|
|
for i := 1; i <= 50000; i++ {
|
|
if err = batch.Index(fmt.Sprintf("doc-%d", i), map[string]interface{}{
|
|
"vec": genRandomVector(),
|
|
}); err != nil {
|
|
t.Fatalf("error indexing doc: %v", err)
|
|
}
|
|
|
|
if i%200 == 0 {
|
|
err = idx.Batch(batch)
|
|
if err != nil {
|
|
t.Fatalf("Error adding batch to index: %v", err)
|
|
}
|
|
batch = idx.NewBatch()
|
|
}
|
|
}
|
|
|
|
if batch.Size() > 0 {
|
|
// In case doc count is not a multiple of 200, we need to add the final batch
|
|
err = idx.Batch(batch)
|
|
if err != nil {
|
|
t.Errorf("Error adding final batch to index: %v", err)
|
|
}
|
|
}
|
|
|
|
insightsIdx, ok := idx.(InsightsIndex)
|
|
if !ok {
|
|
t.Fatal("index does not support insights")
|
|
}
|
|
|
|
centroids, err := insightsIdx.CentroidCardinalities("vec", 5, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(centroids) != 5 {
|
|
t.Fatalf("expected 5 centroids, got %d", len(centroids))
|
|
}
|
|
|
|
for _, entry := range centroids {
|
|
if len(entry.Index) == 0 {
|
|
t.Fatal("expected index name for each centroid")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHierarchicalNestedVectorSearch(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
dataset := `
|
|
[
|
|
{
|
|
"id": "doc1",
|
|
"items": [
|
|
{
|
|
"description": "I like trains",
|
|
"embedding_vector": [
|
|
1,
|
|
0,
|
|
0
|
|
],
|
|
"type": "transport"
|
|
},
|
|
{
|
|
"description": "I love pizza",
|
|
"embedding_vector": [
|
|
0,
|
|
1,
|
|
0
|
|
],
|
|
"type": "food"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "doc2",
|
|
"items": [
|
|
{
|
|
"description": "I go to school by bus",
|
|
"embedding_vector": [
|
|
0.9,
|
|
0.1,
|
|
0
|
|
],
|
|
"type": "transport"
|
|
},
|
|
{
|
|
"description": "Sushi is delicious",
|
|
"embedding_vector": [
|
|
0,
|
|
1,
|
|
0
|
|
],
|
|
"type": "food"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "doc3",
|
|
"items": [
|
|
{
|
|
"description": "Hamburgers are tasty",
|
|
"embedding_vector": [
|
|
0,
|
|
0.8,
|
|
0.2
|
|
],
|
|
"type": "food"
|
|
},
|
|
{
|
|
"description": "I enjoy biking",
|
|
"embedding_vector": [
|
|
0.7,
|
|
0,
|
|
0.3
|
|
],
|
|
"type": "transport"
|
|
}
|
|
]
|
|
}
|
|
]`
|
|
var documents []map[string]interface{}
|
|
err := json.Unmarshal([]byte(dataset), &documents)
|
|
if err != nil {
|
|
t.Fatalf("failed to unmarshal dataset: %v", err)
|
|
}
|
|
indexMapping := NewIndexMapping()
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Dims = 3
|
|
vecFieldMapping.Similarity = index.CosineSimilarity
|
|
|
|
typeMapping := mapping.NewTextFieldMapping()
|
|
typeMapping.Analyzer = keyword.Name
|
|
|
|
descMapping := mapping.NewTextFieldMapping()
|
|
descMapping.Analyzer = en.AnalyzerName
|
|
|
|
// items is NOT nested
|
|
itemsMapping := mapping.NewDocumentMapping()
|
|
itemsMapping.AddFieldMappingsAt("embedding_vector", vecFieldMapping)
|
|
itemsMapping.AddFieldMappingsAt("type", typeMapping)
|
|
itemsMapping.AddFieldMappingsAt("description", descMapping)
|
|
|
|
indexMapping.DefaultMapping.AddSubDocumentMapping("items", itemsMapping)
|
|
idx, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatalf("failed to create index: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := idx.Close(); err != nil {
|
|
t.Fatalf("failed to close index: %v", err)
|
|
}
|
|
}()
|
|
|
|
batch := idx.NewBatch()
|
|
for _, doc := range documents {
|
|
err := batch.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatalf("failed to index document %s: %v", doc["id"], err)
|
|
}
|
|
}
|
|
err = idx.Batch(batch)
|
|
if err != nil {
|
|
t.Fatalf("failed to batch index documents: %v", err)
|
|
}
|
|
|
|
// Plain vector search
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("items.embedding_vector", []float32{0, 1, 0}, 5, 1.0)
|
|
searchReq.SortBy([]string{"-_score", "_id"})
|
|
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to execute search: %v", err)
|
|
}
|
|
|
|
expectedOrder := []string{"doc1", "doc2", "doc3"}
|
|
expectedScores := []float64{1.0, 1.0, 0.970}
|
|
if len(res.Hits) != len(expectedOrder) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedOrder), len(res.Hits))
|
|
}
|
|
for i, expectedID := range expectedOrder {
|
|
if res.Hits[i].ID != expectedID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expectedID, res.Hits[i].ID)
|
|
}
|
|
if math.Abs(res.Hits[i].Score-expectedScores[i]) > 0.01 {
|
|
t.Fatalf("at rank %d, expected score %.3f, got %.3f", i+1, expectedScores[i], res.Hits[i].Score)
|
|
}
|
|
}
|
|
|
|
// Filtered vector search - should match output of plain vector search in non-nested case
|
|
filterQuery := NewTermQuery("transport")
|
|
filterQuery.SetField("items.type")
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNNWithFilter("items.embedding_vector", []float32{0, 1, 0}, 5, 1.0, filterQuery)
|
|
searchReq.SortBy([]string{"-_score", "_id"})
|
|
res, err = idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to execute filtered search: %v", err)
|
|
}
|
|
if len(res.Hits) != len(expectedOrder) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedOrder), len(res.Hits))
|
|
}
|
|
for i, expectedID := range expectedOrder {
|
|
if res.Hits[i].ID != expectedID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expectedID, res.Hits[i].ID)
|
|
}
|
|
if math.Abs(res.Hits[i].Score-expectedScores[i]) > 0.01 {
|
|
t.Fatalf("at rank %d, expected score %.3f, got %.3f", i+1, expectedScores[i], res.Hits[i].Score)
|
|
}
|
|
}
|
|
|
|
// items IS nested
|
|
nestedItemsMapping := mapping.NewNestedDocumentMapping()
|
|
nestedItemsMapping.AddFieldMappingsAt("embedding_vector", vecFieldMapping)
|
|
nestedItemsMapping.AddFieldMappingsAt("type", typeMapping)
|
|
nestedItemsMapping.AddFieldMappingsAt("description", descMapping)
|
|
|
|
indexMappingNested := NewIndexMapping()
|
|
indexMappingNested.DefaultMapping.AddSubDocumentMapping("items", nestedItemsMapping)
|
|
idxNested, err := New(tmpIndexPath+"_nested", indexMappingNested)
|
|
if err != nil {
|
|
t.Fatalf("failed to create nested index: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := idxNested.Close(); err != nil {
|
|
t.Fatalf("failed to close nested index: %v", err)
|
|
}
|
|
}()
|
|
|
|
batch = idxNested.NewBatch()
|
|
for _, doc := range documents {
|
|
err := batch.Index(doc["id"].(string), doc)
|
|
if err != nil {
|
|
t.Fatalf("failed to index document %s in nested index: %v", doc["id"], err)
|
|
}
|
|
}
|
|
err = idxNested.Batch(batch)
|
|
if err != nil {
|
|
t.Fatalf("failed to batch index documents in nested index: %v", err)
|
|
}
|
|
// Plain vector search on nested index
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("items.embedding_vector", []float32{0, 1, 0}, 5, 1.0)
|
|
searchReq.SortBy([]string{"-_score", "_id"})
|
|
|
|
res, err = idxNested.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to execute search on nested index: %v", err)
|
|
}
|
|
// Exact same behavior as non-nested in this case
|
|
if len(res.Hits) != len(expectedOrder) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedOrder), len(res.Hits))
|
|
}
|
|
for i, expectedID := range expectedOrder {
|
|
if res.Hits[i].ID != expectedID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expectedID, res.Hits[i].ID)
|
|
}
|
|
if math.Abs(res.Hits[i].Score-expectedScores[i]) > 0.01 {
|
|
t.Fatalf("at rank %d, expected score %.3f, got %.3f", i+1, expectedScores[i], res.Hits[i].Score)
|
|
}
|
|
}
|
|
|
|
// Filtered vector search on nested index - should NOT match output of plain vector search in nested case
|
|
filterQuery = NewTermQuery("transport")
|
|
filterQuery.SetField("items.type")
|
|
searchReq = NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNNWithFilter("items.embedding_vector", []float32{0, 1, 0}, 5, 1.0, filterQuery)
|
|
searchReq.SortBy([]string{"-_score", "_id"})
|
|
res, err = idxNested.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to execute filtered search on nested index: %v", err)
|
|
}
|
|
expectedNestedOrder := []string{"doc2", "doc1", "doc3"}
|
|
expectedNestedScores := []float64{0.110, 0, 0}
|
|
if len(res.Hits) != len(expectedNestedOrder) {
|
|
t.Fatalf("expected %d hits, got %d", len(expectedNestedOrder), len(res.Hits))
|
|
}
|
|
for i, expectedID := range expectedNestedOrder {
|
|
if res.Hits[i].ID != expectedID {
|
|
t.Fatalf("at rank %d, expected docID %s, got %s", i+1, expectedID, res.Hits[i].ID)
|
|
}
|
|
if math.Abs(res.Hits[i].Score-expectedNestedScores[i]) > 0.01 {
|
|
t.Fatalf("at rank %d, expected score %.3f, got %.3f", i+1, expectedNestedScores[i], res.Hits[i].Score)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKNNNullParams(t *testing.T) {
|
|
queries := []struct {
|
|
query []byte
|
|
}{
|
|
{query: []byte(`{"knn": [{"field": "emb", "vector": [1, 2], "k": 3}]}`)},
|
|
{query: []byte(`{"knn": [{"field": "emb", "params": null, "vector": [1, 2], "k": 3}]}`)},
|
|
{query: []byte(`{"knn": [{"field": "emb","vector": [1, 2], "k": 3, "filter": null}]}`)},
|
|
{query: []byte(`{"pre_search_data": null, "knn": [{"field": "emb", "vector": [1, 2], "k": 3}]}`)},
|
|
}
|
|
|
|
for _, q := range queries {
|
|
var searchReq SearchRequest
|
|
err := json.Unmarshal(q.query, &searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse query: %v", err)
|
|
}
|
|
if len(searchReq.PreSearchData) > 0 {
|
|
t.Fatalf("expected no pre_search_data for query: %s, got %v", q.query, searchReq.PreSearchData)
|
|
}
|
|
for _, req := range searchReq.KNN {
|
|
if len(req.Params) > 0 {
|
|
t.Fatalf("expected no params for query: %s, got %v", q.query, req.Params)
|
|
}
|
|
if req.FilterQuery != nil {
|
|
t.Fatalf("expected no filter for query: %s, got %v", q.query, req.FilterQuery)
|
|
}
|
|
}
|
|
marshalled, err := json.Marshal(searchReq)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal search request: %v", err)
|
|
}
|
|
var unmarshalled SearchRequest
|
|
err = json.Unmarshal(marshalled, &unmarshalled)
|
|
if err != nil {
|
|
t.Fatalf("failed to unmarshal marshalled search request: %v", err)
|
|
}
|
|
if len(unmarshalled.PreSearchData) > 0 {
|
|
t.Fatalf("expected no pre_search_data after marshal/unmarshal for query: %s, got %v", q.query, unmarshalled.PreSearchData)
|
|
}
|
|
for _, req := range unmarshalled.KNN {
|
|
if len(req.Params) > 0 {
|
|
t.Fatalf("expected no params after marshal/unmarshal for query: %s, got %v", q.query, req.Params)
|
|
}
|
|
if req.FilterQuery != nil {
|
|
t.Fatalf("expected no filter after marshal/unmarshal for query: %s, got %v", q.query, req.FilterQuery)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVectorIndexExhaustion(t *testing.T) {
|
|
for optimization := range index.SupportedVectorIndexOptimizations {
|
|
t.Run(optimization, func(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
const dims = 8
|
|
|
|
vecFieldMapping := mapping.NewVectorFieldMapping()
|
|
vecFieldMapping.Dims = dims
|
|
vecFieldMapping.VectorIndexOptimizedFor = optimization
|
|
|
|
indexMapping := NewIndexMapping()
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vec", vecFieldMapping)
|
|
|
|
idx, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
err := idx.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
docs := []struct {
|
|
id string
|
|
vec []float32
|
|
}{
|
|
{"doc1", []float32{1, 0, 0, 0, 0, 0, 0, 0}},
|
|
{"doc2", []float32{0, 1, 0, 0, 0, 0, 0, 0}},
|
|
{"doc3", []float32{0, 0, 1, 0, 0, 0, 0, 0}},
|
|
}
|
|
|
|
batch := idx.NewBatch()
|
|
for _, doc := range docs {
|
|
err = batch.Index(doc.id, map[string]interface{}{
|
|
"vec": doc.vec,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
err = idx.Batch(batch)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
searchReq := NewSearchRequest(query.NewMatchNoneQuery())
|
|
searchReq.AddKNN("vec", []float32{1, 0, 0, 0, 0, 0, 0, 0}, 10, 1.0)
|
|
res, err := idx.Search(searchReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Hits) != 3 {
|
|
t.Fatalf("expected 3 hits, got %d", len(res.Hits))
|
|
}
|
|
if res.Hits[0].ID != "doc1" {
|
|
t.Fatalf("expected doc1 as top hit, got %s", res.Hits[0].ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHybridExplainNotDropped verifies that a doc matching BOTH the text query
|
|
// and the KNN branch keeps a merged explanation (text + vector) whose value
|
|
// equals the summed score, i.e. the KNN part isn't dropped from the explain tree.
|
|
func TestHybridExplainNotDropped(t *testing.T) {
|
|
tmpIndexPath := createTmpIndexPath(t)
|
|
defer cleanupTmpIndexPath(t, tmpIndexPath)
|
|
|
|
// vdocBoth matches the text query (category=health) AND is the nearest
|
|
// vector to the query vector, so it exercises the hybrid merge path.
|
|
docs := map[string]map[string]interface{}{
|
|
"vdocBoth": {"category": "health", "vector": []float32{0.1, 0.9, 0.2, 0.5}},
|
|
"vdocVecOnly": {"vector": []float32{0.1, 0.9, 0.2, 0.5}},
|
|
"vdocTextOnly": {"category": "health", "vector": []float32{0.1, 0.2, 0.9, 0.1}},
|
|
"vdocNeither": {"category": "finance", "vector": []float32{0.8, 0.1, 0.1, 0.9}},
|
|
}
|
|
|
|
indexMapping := NewIndexMapping()
|
|
catFM := NewTextFieldMapping()
|
|
catFM.Analyzer = "keyword"
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("category", catFM)
|
|
|
|
vecFM := mapping.NewVectorFieldMapping()
|
|
vecFM.Index = true
|
|
vecFM.Dims = 4
|
|
vecFM.Similarity = "l2_norm"
|
|
indexMapping.DefaultMapping.AddFieldMappingsAt("vector", vecFM)
|
|
|
|
idx, err := New(tmpIndexPath, indexMapping)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if err := idx.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
batch := idx.NewBatch()
|
|
for id, doc := range docs {
|
|
if err := batch.Index(id, doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := idx.Batch(batch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Root-level text query (matches vdocBoth and vdocTextOnly) + KNN branch
|
|
// whose nearest neighbours are vdocBoth and vdocVecOnly.
|
|
tq := query.NewTermQuery("health")
|
|
tq.SetField("category")
|
|
req := NewSearchRequest(tq)
|
|
req.AddKNN("vector", []float32{0.1, 0.9, 0.2, 0.4}, 2, 1.0)
|
|
req.Explain = true
|
|
req.Fields = []string{"category"}
|
|
|
|
res, err := idx.Search(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var checkedBoth bool
|
|
for _, hit := range res.Hits {
|
|
if hit.Expl == nil {
|
|
t.Fatalf("hit %s has no explanation", hit.ID)
|
|
}
|
|
// The invariant the bug violated: the explanation value must equal the
|
|
// score it is attached to.
|
|
if diff := hit.Expl.Value - hit.Score; diff > 1e-6 || diff < -1e-6 {
|
|
t.Errorf("hit %s: explanation.value=%v != score=%v (vector explanation dropped)",
|
|
hit.ID, hit.Expl.Value, hit.Score)
|
|
}
|
|
|
|
if hit.ID == "vdocBoth" {
|
|
checkedBoth = true
|
|
// A both-branches hit must be a merged explanation carrying both
|
|
// the text and the vector sub-explanations.
|
|
if len(hit.Expl.Children) < 2 {
|
|
t.Errorf("hit vdocBoth: expected merged explanation with >=2 children "+
|
|
"(text + vector), got message=%q value=%v children=%d",
|
|
hit.Expl.Message, hit.Expl.Value, len(hit.Expl.Children))
|
|
}
|
|
}
|
|
}
|
|
if !checkedBoth {
|
|
t.Fatal("vdocBoth was not returned by the hybrid query")
|
|
}
|
|
}
|