Поиск по воронке с вкраплениями "Матрешки
При создании эффективных систем векторного поиска одной из ключевых задач является управление затратами на хранение данных при сохранении приемлемой задержки и запоминания. Современные модели встраивания выдают векторы с сотнями или тысячами измерений, что создает значительные затраты на хранение и вычисления для исходного вектора и индекса.
Традиционно требования к хранению снижаются путем применения метода квантования или уменьшения размерности непосредственно перед построением индекса. Например, мы можем сэкономить место, снизив точность с помощью 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
Как и ожидалось, запоминание гораздо хуже, чем поиск по воронке или обычный поиск (модель встраивания была обучена методом контрастного обучения на префиксах измерений встраивания, а не на суффиксах).
Резюме
Вот сравнение результатов поиска по разным методам:


