🚀 Попробуйте Zilliz Cloud, полностью управляемый Milvus, бесплатно — ощутите 10-кратное увеличение производительности! Попробовать сейчас>

milvus-logo
LFAI
Главная
  • Home
  • Docs
  • Учебники

  • Поиск воронки с помощью вкраплений "Матрешки

Поиск по воронке с вкраплениями "Матрешки

При создании эффективных систем векторного поиска одной из ключевых задач является управление затратами на хранение данных при сохранении приемлемой задержки и запоминания. Современные модели встраивания выдают векторы с сотнями или тысячами измерений, что создает значительные затраты на хранение и вычисления для исходного вектора и индекса.

Традиционно требования к хранению снижаются путем применения метода квантования или уменьшения размерности непосредственно перед построением индекса. Например, мы можем сэкономить место, снизив точность с помощью Product Quantization (PQ) или количество измерений с помощью Principal Component Analysis (PCA). Эти методы анализируют весь набор векторов, чтобы найти более компактный, сохраняющий семантические связи между векторами.

Несмотря на свою эффективность, эти стандартные подходы снижают точность или размерность только один раз и в одном масштабе. Но что, если бы мы могли одновременно поддерживать несколько уровней детализации, подобно пирамиде все более точных представлений?

Появились вкрапления "Матрешки". Названные в честь русской матрешки (см. иллюстрацию), эти умные конструкции встраивают несколько масштабов представления в один вектор. В отличие от традиционных методов постобработки, вкрапления "Матрешки" учатся этой многомасштабной структуре в процессе начального обучения. Результат поразителен: не только полное вложение отражает семантику входных данных, но и каждый вложенный префикс подмножества (первая половина, первая четверть и т. д.) обеспечивает целостное, хотя и менее детальное представление.

В этом блокноте мы рассмотрим, как использовать вкрапления "Матрешки" с Milvus для семантического поиска. Мы проиллюстрируем алгоритм, называемый "поиском по воронке", который позволяет нам выполнять поиск по сходству в небольшом подмножестве измерений вкраплений без резкого снижения запоминания.

import functools

from datasets import load_dataset
import numpy as np
import pandas as pd
import pymilvus
from pymilvus import MilvusClient
from pymilvus import FieldSchema, CollectionSchema, DataType
from sentence_transformers import SentenceTransformer
import torch
import torch.nn.functional as F
from tqdm import tqdm

Модель встраивания "Матрешка

Вместо того чтобы использовать стандартную модель встраивания, такую как sentence-transformers/all-MiniLM-L12-v2мы используем модель от Nomic, специально обученную для создания вкраплений "Матрешки".

model = SentenceTransformer(
    # Remove 'device='mps' if running on non-Mac device
    "nomic-ai/nomic-embed-text-v1.5",
    trust_remote_code=True,
    device="mps",
)
<All keys matched successfully>

Загрузка массива данных, встраивание элементов и создание базы векторов

Следующий код является модификацией кода со страницы документации "Поиск фильмов с помощью Sentence Transformers и Milvus". Сначала мы загружаем набор данных из HuggingFace. Он содержит около 35 тысяч записей, каждая из которых соответствует фильму, имеющему статью в Википедии. В этом примере мы будем использовать поля Title и PlotSummary.

ds = load_dataset("vishnupriyavr/wiki-movie-plots-with-summaries", split="train")
print(ds)
Dataset({
    features: ['Release Year', 'Title', 'Origin/Ethnicity', 'Director', 'Cast', 'Genre', 'Wiki Page', 'Plot', 'PlotSummary'],
    num_rows: 34886
})

Далее мы подключаемся к базе данных Milvus Lite, задаем схему данных и создаем коллекцию с этой схемой. Мы будем хранить ненормализованное вложение и первую шестую часть вложения в отдельных полях. Причина в том, что первая 1/6 часть вкраплений "Матрешки" нужна нам для поиска сходства, а остальные 5/6 частей вкраплений - для ранжирования и улучшения результатов поиска.

embedding_dim = 768
search_dim = 128
collection_name = "movie_embeddings"

client = MilvusClient(uri="./wiki-movie-plots-matryoshka.db")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=256),
    # First sixth of unnormalized embedding vector
    FieldSchema(name="head_embedding", dtype=DataType.FLOAT_VECTOR, dim=search_dim),
    # Entire unnormalized embedding vector
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=embedding_dim),
]

schema = CollectionSchema(fields=fields, enable_dynamic_field=False)
client.create_collection(collection_name=collection_name, schema=schema)

В настоящее время Milvus не поддерживает поиск по подмножествам вкраплений, поэтому мы разбиваем вкрапления на две части: голова представляет собой начальное подмножество вектора для индексации и поиска, а хвост - оставшуюся часть. Модель обучена для поиска сходства по косинусному расстоянию, поэтому мы нормализуем вкрапления головы. Однако, чтобы в дальнейшем вычислять сходство для больших подмножеств, нам нужно хранить норму вкраплений головы, чтобы ненормализовать их перед присоединением к хвосту.

Чтобы выполнить поиск по первой 1/6 части вкрапления, нам потребуется создать индекс векторного поиска по полю head_embedding. Позже мы сравним результаты "воронкообразного поиска" с обычным векторным поиском, поэтому построим индекс поиска и по полному вкраплению.

Важно отметить, что мы используем метрику расстояния COSINE, а не IP, поскольку в противном случае нам пришлось бы следить за нормами вкраплений, что усложнило бы реализацию (это станет более понятным после описания алгоритма поиска воронки).

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="head_embedding", index_type="FLAT", metric_type="COSINE"
)
index_params.add_index(field_name="embedding", index_type="FLAT", metric_type="COSINE")
client.create_index(collection_name, index_params)

Наконец, мы кодируем краткие описания сюжетов для всех 35 тысяч фильмов и вводим соответствующие вкрапления в базу данных.

for batch in tqdm(ds.batch(batch_size=512)):
    # This particular model requires us to prefix 'search_document:' to stored entities
    plot_summary = ["search_document: " + x.strip() for x in batch["PlotSummary"]]

    # Output of embedding model is unnormalized
    embeddings = model.encode(plot_summary, convert_to_tensor=True)
    head_embeddings = embeddings[:, :search_dim]

    data = [
        {
            "title": title,
            "head_embedding": head.cpu().numpy(),
            "embedding": embedding.cpu().numpy(),
        }
        for title, head, embedding in zip(batch["Title"], head_embeddings, embeddings)
    ]
    res = client.insert(collection_name=collection_name, data=data)
100%|██████████| 69/69 [05:57<00:00,  5.18s/it]

Теперь давайте осуществим "воронкообразный поиск", используя первую 1/6 часть размеров вкраплений "Матрешки". У меня есть три фильма, которые я хочу найти, и я подготовил собственную сводку сюжетов для запросов к базе данных. Мы встраиваем запросы, затем выполняем векторный поиск по полю head_embedding, получая 128 кандидатов на результат.

queries = [
    "An archaeologist searches for ancient artifacts while fighting Nazis.",
    "A teenager fakes illness to get off school and have adventures with two friends.",
    "A young couple with a kid look after a hotel during winter and the husband goes insane.",
]


# Search the database based on input text
def embed_search(data):
    embeds = model.encode(data)
    return [x for x in embeds]


# This particular model requires us to prefix 'search_query:' to queries
instruct_queries = ["search_query: " + q.strip() for q in queries]
search_data = embed_search(instruct_queries)

# Normalize head embeddings
head_search = [x[:search_dim] for x in search_data]

# Perform standard vector search on first sixth of embedding dimensions
res = client.search(
    collection_name=collection_name,
    data=head_search,
    anns_field="head_embedding",
    limit=128,
    output_fields=["title", "head_embedding", "embedding"],
)

На этом этапе мы выполнили поиск по гораздо меньшему векторному пространству, и поэтому, скорее всего, снизили задержку и требования к хранению индекса по сравнению с поиском по всему пространству. Давайте рассмотрим 5 лучших совпадений для каждого запроса:

for query, hits in zip(queries, res):
    rows = [x["entity"] for x in hits][:5]

    print("Query:", query)
    print("Results:")
    for row in rows:
        print(row["title"].strip())
    print()
Query: An archaeologist searches for ancient artifacts while fighting Nazis.
Results:
"Pimpernel" Smith
Black Hunters
The Passage
Counterblast
Dominion: Prequel to the Exorcist

Query: A teenager fakes illness to get off school and have adventures with two friends.
Results:
How to Deal
Shorts
Blackbird
Valentine
Unfriended

Query: A young couple with a kid look after a hotel during winter and the husband goes insane.
Results:
Ghostkeeper
Our Vines Have Tender Grapes
The Ref
Impact
The House in Marsh Road

Как мы видим, в результате усечения вкраплений во время поиска ухудшилась запоминаемость. Воронкообразный поиск исправляет это с помощью хитрого трюка: мы можем использовать оставшиеся размеры вкраплений для ранжирования и обрезки списка кандидатов, чтобы восстановить производительность поиска без дополнительных дорогостоящих векторных поисков.

Для простоты изложения алгоритма воронкообразного поиска мы преобразуем результаты поиска Milvus для каждого запроса в кадр данных Pandas.

def hits_to_dataframe(hits: pymilvus.client.abstract.Hits) -> pd.DataFrame:
    """
    Convert a Milvus search result to a Pandas dataframe. This function is specific to our data schema.

    """
    rows = [x["entity"] for x in hits]
    rows_dict = [
        {"title": x["title"], "embedding": torch.tensor(x["embedding"])} for x in rows
    ]
    return pd.DataFrame.from_records(rows_dict)


dfs = [hits_to_dataframe(hits) for hits in res]

Теперь, чтобы выполнить поиск по воронке, мы перебираем все более крупные подмножества вкраплений. На каждой итерации мы ранжируем кандидатов в соответствии с новыми сходствами и отсеиваем некоторую часть кандидатов с наименьшим рейтингом.

Для наглядности, из предыдущего шага мы получили 128 кандидатов, используя 1/6 размерности вкраплений и запроса. Первым шагом при выполнении воронкообразного поиска является пересчет сходства между запросами и кандидатами с использованием первой 1/3 измерений. Нижние 64 кандидата отсеиваются. Затем мы повторяем этот процесс с первыми 2/3 измерений, а затем со всеми измерениями, последовательно обрезая до 32 и 16 кандидатов.

# An optimized implementation would vectorize the calculation of similarity scores across rows (using a matrix)
def calculate_score(row, query_emb=None, dims=768):
    emb = F.normalize(row["embedding"][:dims], dim=-1)
    return (emb @ query_emb).item()


# You could also add a top-K parameter as a termination condition
def funnel_search(
    df: pd.DataFrame, query_emb, scales=[256, 512, 768], prune_ratio=0.5
) -> pd.DataFrame:
    # Loop over increasing prefixes of the embeddings
    for dims in scales:
        # Query vector must be normalized for each new dimensionality
        emb = torch.tensor(query_emb[:dims] / np.linalg.norm(query_emb[:dims]))

        # Score
        scores = df.apply(
            functools.partial(calculate_score, query_emb=emb, dims=dims), axis=1
        )
        df["scores"] = scores

        # Re-rank
        df = df.sort_values(by="scores", ascending=False)

        # Prune (in our case, remove half of candidates at each step)
        df = df.head(int(prune_ratio * len(df)))

    return df


dfs_results = [
    {"query": query, "results": funnel_search(df, query_emb)}
    for query, df, query_emb in zip(queries, dfs, search_data)
]
for d in dfs_results:
    print(d["query"], "\n", d["results"][:5]["title"], "\n")
An archaeologist searches for ancient artifacts while fighting Nazis. 
 0           "Pimpernel" Smith
1               Black Hunters
29    Raiders of the Lost Ark
34             The Master Key
51            My Gun Is Quick
Name: title, dtype: object 

A teenager fakes illness to get off school and have adventures with two friends. 
 21               How I Live Now
32     On the Edge of Innocence
77             Bratz: The Movie
4                    Unfriended
108                  Simon Says
Name: title, dtype: object 

A young couple with a kid look after a hotel during winter and the husband goes insane. 
 9         The Shining
0         Ghostkeeper
11     Fast and Loose
7      Killing Ground
12         Home Alone
Name: title, dtype: object 

Нам удалось восстановить запоминание без дополнительного поиска векторов! Качественно, эти результаты кажутся более запоминающимися для "Raiders of the Lost Ark" и "The Shining", чем стандартный векторный поиск в учебнике "Movie Search using Milvus and Sentence Transformers", который использует другую модель встраивания. Однако он не смог найти "Ferris Bueller's Day Off", к которому мы вернемся позже в этом блокноте. (Более подробные количественные эксперименты и бенчмарки см. в статье Matryoshka Representation Learning ).

Давайте сравним результаты нашего воронкообразного поиска со стандартным векторным поиском на том же наборе данных с той же моделью вкрапления. Мы выполняем поиск по полным вкраплениям.

# Search on entire embeddings
res = client.search(
    collection_name=collection_name,
    data=search_data,
    anns_field="embedding",
    limit=5,
    output_fields=["title", "embedding"],
)
for query, hits in zip(queries, res):
    rows = [x["entity"] for x in hits]

    print("Query:", query)
    print("Results:")
    for row in rows:
        print(row["title"].strip())
    print()
Query: An archaeologist searches for ancient artifacts while fighting Nazis.
Results:
"Pimpernel" Smith
Black Hunters
Raiders of the Lost Ark
The Master Key
My Gun Is Quick

Query: A teenager fakes illness to get off school and have adventures with two friends.
Results:
A Walk to Remember
Ferris Bueller's Day Off
How I Live Now
On the Edge of Innocence
Bratz: The Movie

Query: A young couple with a kid look after a hotel during winter and the husband goes insane.
Results:
The Shining
Ghostkeeper
Fast and Loose
Killing Ground
Home Alone

За исключением результатов по запросу "Подросток симулирует болезнь, чтобы не ходить в школу...", результаты воронкообразного поиска практически идентичны полному поиску, хотя воронкообразный поиск выполнялся в пространстве поиска 128 измерений против 768 измерений для обычного поиска.

Расследование неудачи поиска по воронке в фильме "Выходной день Ферриса Бьюллера

Почему воронкообразный поиск не смог найти фильм Ferris Bueller's Day Off? Давайте разберемся, был ли он в первоначальном списке кандидатов или был ошибочно отфильтрован.

queries2 = [
    "A teenager fakes illness to get off school and have adventures with two friends."
]


# Search the database based on input text
def embed_search(data):
    embeds = model.encode(data)
    return [x for x in embeds]


instruct_queries = ["search_query: " + q.strip() for q in queries2]
search_data2 = embed_search(instruct_queries)
head_search2 = [x[:search_dim] for x in search_data2]

# Perform standard vector search on subset of embeddings
res = client.search(
    collection_name=collection_name,
    data=head_search2,
    anns_field="head_embedding",
    limit=256,
    output_fields=["title", "head_embedding", "embedding"],
)
for query, hits in zip(queries, res):
    rows = [x["entity"] for x in hits]

    print("Query:", queries2[0])
    for idx, row in enumerate(rows):
        if row["title"].strip() == "Ferris Bueller's Day Off":
            print(f"Row {idx}: Ferris Bueller's Day Off")
Query: A teenager fakes illness to get off school and have adventures with two friends.
Row 228: Ferris Bueller's Day Off

Мы видим, что проблема заключалась в том, что первоначальный список кандидатов был недостаточно велик, или, скорее, желаемое попадание не было достаточно похоже на запрос на самом высоком уровне детализации. Замена 128 на 256 приводит к успешному поиску. Мы должны выработать правило, по которому количество кандидатов в удерживаемом наборе будет определяться эмпирически, чтобы оценить компромисс между запоминанием и задержкой.

dfs = [hits_to_dataframe(hits) for hits in res]

dfs_results = [
    {"query": query, "results": funnel_search(df, query_emb)}
    for query, df, query_emb in zip(queries2, dfs, search_data2)
]

for d in dfs_results:
    print(d["query"], "\n", d["results"][:7]["title"].to_string(index=False), "\n")
A teenager fakes illness to get off school and have adventures with two friends. 
       A Walk to Remember
Ferris Bueller's Day Off
          How I Live Now
On the Edge of Innocence
        Bratz: The Movie
              Unfriended
              Simon Says 

Имеет ли значение порядок? Префиксные и суффиксные вкрапления.

Модель была обучена хорошо сопоставлять рекурсивно меньшие префиксы вкраплений. Имеет ли значение порядок используемых нами измерений? Например, можем ли мы также взять подмножества вкраплений, которые являются суффиксами? В этом эксперименте мы изменили порядок размерностей в эмбеддингах "Матрешки" и выполнили поиск по воронке.

client = MilvusClient(uri="./wikiplots-matryoshka-flipped.db")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=256),
    FieldSchema(name="head_embedding", dtype=DataType.FLOAT_VECTOR, dim=search_dim),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=embedding_dim),
]

schema = CollectionSchema(fields=fields, enable_dynamic_field=False)
client.create_collection(collection_name=collection_name, schema=schema)

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="head_embedding", index_type="FLAT", metric_type="COSINE"
)
client.create_index(collection_name, index_params)
huggingface/tokenizers: The current process just got forked, after parallelism has already been used. Disabling parallelism to avoid deadlocks...
To disable this warning, you can either:
    - Avoid using `tokenizers` before the fork if possible
    - Explicitly set the environment variable TOKENIZERS_PARALLELISM=(true | false)
for batch in tqdm(ds.batch(batch_size=512)):
    plot_summary = ["search_document: " + x.strip() for x in batch["PlotSummary"]]

    # Encode and flip embeddings
    embeddings = model.encode(plot_summary, convert_to_tensor=True)
    embeddings = torch.flip(embeddings, dims=[-1])
    head_embeddings = embeddings[:, :search_dim]

    data = [
        {
            "title": title,
            "head_embedding": head.cpu().numpy(),
            "embedding": embedding.cpu().numpy(),
        }
        for title, head, embedding in zip(batch["Title"], head_embeddings, embeddings)
    ]
    res = client.insert(collection_name=collection_name, data=data)
100%|██████████| 69/69 [05:50<00:00,  5.08s/it]
# Normalize head embeddings

flip_search_data = [
    torch.flip(torch.tensor(x), dims=[-1]).cpu().numpy() for x in search_data
]
flip_head_search = [x[:search_dim] for x in flip_search_data]

# Perform standard vector search on subset of embeddings
res = client.search(
    collection_name=collection_name,
    data=flip_head_search,
    anns_field="head_embedding",
    limit=128,
    output_fields=["title", "head_embedding", "embedding"],
)
dfs = [hits_to_dataframe(hits) for hits in res]

dfs_results = [
    {"query": query, "results": funnel_search(df, query_emb)}
    for query, df, query_emb in zip(queries, dfs, flip_search_data)
]

for d in dfs_results:
    print(
        d["query"],
        "\n",
        d["results"][:7]["title"].to_string(index=False, header=False),
        "\n",
    )
An archaeologist searches for ancient artifacts while fighting Nazis. 
       "Pimpernel" Smith
          Black Hunters
Raiders of the Lost Ark
         The Master Key
        My Gun Is Quick
            The Passage
        The Mole People 

A teenager fakes illness to get off school and have adventures with two friends. 
                       A Walk to Remember
                          How I Live Now
                              Unfriended
Cirque du Freak: The Vampire's Assistant
                             Last Summer
                                 Contest
                                 Day One 

A young couple with a kid look after a hotel during winter and the husband goes insane. 
         Ghostkeeper
     Killing Ground
Leopard in the Snow
              Stone
          Afterglow
         Unfaithful
     Always a Bride 

Как и ожидалось, запоминание гораздо хуже, чем поиск по воронке или обычный поиск (модель встраивания была обучена методом контрастного обучения на префиксах измерений встраивания, а не на суффиксах).

Резюме

Вот сравнение результатов поиска по разным методам:

Мы показали, как использовать вкрапления "Матрешки" вместе с Milvus для выполнения более эффективного алгоритма семантического поиска, названного "поиском по воронке". Мы также изучили важность этапов переранжирования и обрезки алгоритма, а также режим отказа, когда начальный список кандидатов слишком мал. Наконец, мы обсудили, насколько важен порядок размерностей при формировании подсемейств - он должен быть таким же, для которого обучалась модель. Вернее, только потому, что модель была обучена определенным образом, префиксы вкраплений имеют смысл. Теперь вы знаете, как реализовать вкрапления "Матрешки" и воронкообразный поиск, чтобы снизить затраты на хранение данных при семантическом поиске без ущерба для производительности поиска!