Гибридный поиск

Reindexer поддерживает гибридный поиск по полнотекстовому и векторному индексам (KNN-поиск) в одном запросе. Условия разных поисков могут быть объединены логическими операторами AND или OR.

Примеры запросов:

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

db.Query("test_namespace").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams)
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
        .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
        .where_knn("ivf_idx", [2.4, 3.5, ...], param)
)
SELECT * FROM test_namespace WHERE ft_idx = 'my search~ phrase~*' AND KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "cond": "eq",
      "field": "ft_idx",
      "value": "my search~ phrase~*"
    },
    {
      "op": "and",
      "cond": "knn",
      "field": "ivf_idx",
      "value": [2.4, 3.5, ...],
      "params": {
        "k": 100,
        "nprobe": 10
      }
    }
  ]
}
'

Запрос должен содержать ровно по одному условию для каждой разновидности поиска. При этом они должны быть внутри одной скобки либо вне скобок:

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

db.Query("test_namespace").
   	OpenBracket().
        Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
        Where("id", reindexer.GT, 50).
        WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams).
   	CloseBracket().
    Where("id", reindexer.LT, 10000)
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
        .open_bracket().
            .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
            .where("id", CondType.CondGt, 50)
            .where_knn("ivf_idx", [2.4, 3.5, ...], param)
        .close_bracket().
        .where("id", CondType.CondLt, 10000)
)
SELECT * FROM test_namespace
WHERE
    (
        ft_idx = 'my search~ phrase~*'
        AND id > 50
        AND KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
    )
    AND id < 10000
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "filters": [
        {
          "op": "and",
          "cond": "eq",
          "field": "ft_idx",
          "value": "my search~ phrase~*"
        },
        {
          "op": "and",
          "cond": "knn",
          "field": "ivf_idx",
          "value": [2.4, 3.5, ...],
          "params": {
            "k": 100,
            "nprobe": 10
          }
        },
        {
          "op": "and",
          "field": "id",
          "cond": "GT",
          "value": 50
        }
      ]
    },
    {
      "op": "and",
      "field": "id",
      "cond": "LT",
      "value": 10000
    }
  ]
}
'

Важно

Нельзя помещать условия, относящиеся к разным методам поиска, в разные скобки.

При гибридном поиске можно использовать автовекторизацию (требует настройки):

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

it := db.
    Query("test_namespace").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnnString("ivf_idx", "my search phrase", ivfSearchParams).
    Exec()

defer it.Close()
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn_string("ivf_idx", "my search phrase", param)
    .execute()
)
SELECT * FROM test_namespace
WHERE ft_idx = 'my search~ phrase~*' AND KNN(ivf_idx, 'my search phrase', k=100, nprobe=10)
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "cond": "eq",
      "field": "ft_idx",
      "value": "my search~ phrase~*"
    },
    {
      "op": "and",
      "cond": "knn",
      "field": "ivf_idx",
      "value": "my search phrase",
      "params": {
        "k": 100,
        "nprobe": 10
      }
    }
  ]
}
'

Если сервис векторизации не отвечает, запрос KNN будет проигнорирован.

Повторное ранжирование

При гибридном поиске происходит повторное ранжирование, которое учитывает ранги результатов полнотекстового поиска и KNN-поиска. Для этого используется оператор ORDER BY с указанием функции ранжирования. Если выражение не указано, по умолчанию будет использована функция RRF().

Reciprocal rank fusion (RRF)

Reciprocal rank fusion (RRF) — это алгоритм объединения результатов ранжирования нескольких методов поиска. В качестве исходных данных алгоритм использует обратные ранги результатов поиска, т. е. результаты с первых позиций в нескольких поисковых выдачах имеют наивысший ранг. RRF вычисляется следующим образом:

R = 1 / (rankConst + posFt) + 1 / (rankConst + posKnn)
  • rankConst (rank_const) — сглаживающий коэффициент, который позволяет избежать слишком сильного влияния метода одного поиска на финальный ранг. По умолчанию равен 60;
  • posFt, posKnn — позиции документов в поисковых выдачах (наименьшее значение соответствует наилучшему совпадению).

Если условия объединены оператором OR и только одно из них дает совпадения, сортировка учитывает выдачу только по этому условию. Например, если документ попал только в полнотекстовую выдачу, то слагаемое 1 / (rankConst + posKnn) отбрасывается.

Примеры запросов с произвольным rank_const:

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

db.Query("test_namespace").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams).
    Sort("RRF(rank_const=120)", false)
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn("ivf_idx", [2.4, 3.5, ...], param)
    .sort("RRF(rank_const=120)", False)
)
SELECT * FROM test_namespace
WHERE
    ft_idx = 'my search~ phrase~*'
    AND KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
ORDER BY
    'RRF(rank_const=120)'
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "cond": "eq",
      "field": "ft_idx",
      "value": "my search~ phrase~*"
    },
    {
      "op": "and",
      "cond": "knn",
      "field": "ivf_idx",
      "value": [2.4, 3.5, ...],
      "params": {
        "k": 100,
        "nprobe": 10
      }
    }
  ],
  "sort": {
    "field": "RRF(rank_const=120)"
  }
}
'

Линейное ранжирование

Для результатов гибридного поиска возможно повторное ранжирование по линейной комбинации рангов в разных поисковых выдачах. Примеры запросов:

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

db.Query("test_namespace").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams).
    Sort("30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100", false)
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn("ivf_idx", [2.4, 3.5, ...], param)
    .sort("30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100", False)
)
SELECT * FROM test_namespace WHERE ft_idx = 'my search~ phrase~*' AND KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
ORDER BY '30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100'
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "cond": "eq",
      "field": "ft_idx",
      "value": "my search~ phrase~*"
    },
    {
      "op": "and",
      "cond": "knn",
      "field": "ivf_idx",
      "value": [2.4, 3.5, ...],
      "params": {
        "k": 100,
        "nprobe": 10
      }
    }
  ],
  "sort": {
    "field": "30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100"
  }
}
'

Здесь rank(index, [defaultRank]) — это ранг в полнотекстовом поиске или в поиске по векторному индексу. Опциональный параметр defaultRank определяет ранг, возвращаемый в случае отсутствия результатов в выдаче (по умолчанию 0).

Общая форма выражения для линейного пересчета ранга выглядит следующим образом:

R = a * rank(ft_idx, [ftDefault]) + b * rank(ivf_idx, [knnDefault]) + c
  • rank(ft_idx, [ftDefault]) — ранг в выдаче полнотекстового поиска в интервале от 1 до 255;
  • rank(ivf_idx, [knnDefault]) — ранг в выдаче KNN-поиска, зависящий от выбранной метрики. Например, значения метрики cosine находятся в пределах от -1.0 до 1.0, где -1.0 соответствует наименее релевантному вектору и 1.0 — наиболее релевантному;
  • a, b, c — произвольные числа с плавающей точкой.

Выборка из нескольких неймспейсов

Допускается объединение результатов выборок из нескольких неймспейсов и повторное ранжирование финального результата. Примеры запросов:

knnBaseSearchParams := reindexer.BaseKnnSearchParam{}.SetK(100)
ivfSearchParams, err := reindexer.NewIndexIvfSearchParam(10, knnBaseSearchParams)
if err != nil {
    panic(err)
}

query := db.Query("test_namespace").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams).
    Sort("30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100", false)
query_1 := db.Query("test_namespace_1").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams).
    Sort("RRF(rank_const=120)", false)
query_2 := db.Query("test_namespace_2").
    Where("ft_idx", reindexer.EQ, "my search~ phrase~*").
    WhereKnn("ivf_idx", []float32{2.4, 3.5, ...}, ivfSearchParams)
query.
    Merge(query_1).
    Merge(query_2)
param = IndexSearchParamIvf(100, 10)

query = (
    db.new_query("test_namespace")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn("ivf_idx", [2.4, 3.5, ...], param)
    .sort("30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100", False)
)
query_1 = (
    db.new_query("test_namespace_1")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn("ivf_idx", [2.4, 3.5, ...], param)
    .sort("RRF(rank_const=120)", False)
)
query_2 = (
    db.new_query("test_namespace_2")
    .where("ft_idx", CondType.CondEq, "my search~ phrase~*")
    .where_knn("ivf_idx", [2.4, 3.5, ...], param)
)

(
    query
        .merge(query_1)
        .merge(query_2)
)
SELECT * FROM test_namespace
WHERE ft_idx = 'my search~ phrase~*' OR KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
ORDER BY '30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100'
MERGE(
	SELECT * FROM test_namespace_1
	WHERE ft_idx = 'my search~ phrase~*' AND KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
	ORDER BY 'RRF(rank_const=120)'
)
MERGE(
	SELECT * FROM test_namespace_2
	WHERE ft_idx = 'my search~ phrase~*' OR KNN(ivf_idx, [2.4, 3.5, ...], k=100, nprobe=10)
)
curl \
  --location \
  --request POST \
  http://127.0.0.1:9088/api/v1/db/testdb/query \
  --header 'Content-Type: application/json' \
  --data-raw \
'
{
  "namespace": "test_namespace",
  "type": "select",
  "filters": [
    {
      "op": "and",
      "cond": "eq",
      "field": "ft_idx",
      "value": "my search~ phrase~*"
    },
    {
      "op": "and",
      "cond": "knn",
      "field": "ivf_idx",
      "value": [2.4, 3.5, ...],
      "params": {
        "k": 100,
        "nprobe": 10
      }
    }
  ],
  "sort": {
    "field": "30 * rank(ft_idx) + 50 * rank(ivf_idx, 100.0) + 100"
  },
  "merge_queries": [
    {
      "namespace": "test_namespace_1",
      "type": "select",
      "filters": [
        {
            "op": "and",
            "cond": "eq",
            "field": "ft_idx",
            "value": "my search~ phrase~*"
        },
        {
            "op": "and",
            "cond": "knn",
            "field": "ivf_idx",
            "value": [2.4, 3.5, ...],
            "params": {
                "k": 100,
                "nprobe": 10
            }
        }
      ],
      "sort": {
        "field": "RRF(rank_const=120)"
      }
    },
    {
      "namespace": "test_namespace_2",
      "type": "select",
      "filters": [
        {
            "op": "and",
            "cond": "eq",
            "field": "ft_idx",
            "value": "my search~ phrase~*"
        },
        {
            "op": "and",
            "cond": "knn",
            "field": "ivf_idx",
            "value": [2.4, 3.5, ...],
            "params": {
                "k": 100,
                "nprobe": 10
            }
        }
      ]
    }
  ]
}
'