mirror of
https://github.com/qdrant/landing_page.git
synced 2026-10-05 19:08:32 +02:00
Use conditional upserts
This commit is contained in:
+86
-85
@@ -1,5 +1,7 @@
|
||||
---
|
||||
title: Migrate to a New Embedding Model
|
||||
aliases:
|
||||
- /documentation/tutorials/embedding-model-migration/
|
||||
weight: 191
|
||||
---
|
||||
|
||||
@@ -12,37 +14,35 @@ disrupting users. Switching models requires re-embedding all vectors in your col
|
||||
data doesn't change, you can re-embed everything and switch to the new embeddings. However, in systems with frequent
|
||||
updates, stopping the search service to re-embed is not an option.
|
||||
|
||||
This tutorial will guide you step by step through the process of migrating to a new model, including the changes you have to make in your project. The examples will be all using the Python SDK, but the same principles apply to other languages as well.
|
||||
This tutorial will guide you step-by-step through the process of migrating to a new model, including the changes you have to make in your project. The examples all use the Python SDK, but the same principles apply to other languages as well.
|
||||
|
||||
## The Solution
|
||||
|
||||
Switching the embedding model with zero downtime is possible by using two collections in Qdrant: The first one
|
||||
Switching the embedding model with zero downtime is possible by using a blue-green deployment with two collections. The first collection
|
||||
contains the old embeddings, and the second one is used to store the new embeddings. During the migration, you will
|
||||
keep both collections available for the search, and then switch the search to use the new collection once all the
|
||||
vectors are re-embedded. **That operation requires some changes in your application code, and cannot be done using
|
||||
keep both collections available for the search and then switch the search to use the new collection once all the
|
||||
vectors are re-embedded. **That operation requires some changes in your application code and cannot be done using
|
||||
the Qdrant APIs only.**
|
||||
|
||||
There is an important assumption that we make in this tutorial: **your payloads contain all the information you need to
|
||||
re-embed the vectors**. This is usually the case, as the payload often contains the text or other data that was used to
|
||||
generate the embeddings.
|
||||
Re-embedding requires access to the original data used to create the embeddings. This data can come from a primary database, or it may be stored in the payloads of the points in Qdrant. This tutorial assumes that the necessary data is stored in the payloads. This is usually the case, as the payload often contains the text or other data that was used to generate the embeddings.
|
||||
|
||||
### Step 1: Create a New Collection
|
||||
## Step 1: Create a New Collection
|
||||
|
||||
The first step is to create a new collection in Qdrant that will be used to store the new
|
||||
embeddings, compatible with the new model in terms of the vector size and similarity function.
|
||||
embeddings, compatible with the new model in terms of vector size and similarity function.
|
||||
|
||||
```python
|
||||
from qdrant_client import QdrantClient, models
|
||||
|
||||
client = QdrantClient(...)
|
||||
client.create_collection(
|
||||
collection_name="new_collection",
|
||||
vectors_config={
|
||||
"my-new-vector": models.VectorParams(
|
||||
size=768, # Size of the new embedding vectors
|
||||
distance=models.Distance.EUCLID # Similarity function for the new model
|
||||
collection_name=NEW_COLLECTION,
|
||||
vectors_config=(
|
||||
models.VectorParams(
|
||||
size=512, # Size of the new embedding vectors
|
||||
distance=models.Distance.COSINE # Similarity function for the new model
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
@@ -52,14 +52,14 @@ The newly created collection is empty and ready to be used for storing the new e
|
||||
|
||||
<aside role="status">
|
||||
Qdrant supports <a href="/documentation/concepts/collections/#collection-aliases">collection aliases</a>, which act as
|
||||
symbolic links to collections. If you use aliases, you can simply fill the new collection with the new embeddings,
|
||||
symbolic links to collections. If you use aliases, you can simply fill the new collection with the new embeddings
|
||||
and then switch the alias to point to the new collection. This way, you can avoid changing the collection name in
|
||||
your application code. This approach may still require some changes in the code, as we also need to change the
|
||||
your application code. This approach may still require some changes in the code, as you also need to change the
|
||||
embedding model used for encoding the vectors, but it simplifies the process of switching the search to the new
|
||||
collection.
|
||||
</aside>
|
||||
|
||||
### Step 2: Enable Dual-Write Mode in Your Application
|
||||
## Step 2: Enable Dual-Write Mode in Your Application
|
||||
|
||||
To ensure that both collections are kept up-to-date, you need to modify your application code to write to both
|
||||
collections simultaneously during the transition period. Somewhere in your code, where you handle the embedding of the
|
||||
@@ -68,40 +68,38 @@ current code might look like this:
|
||||
|
||||
```python
|
||||
client.upsert(
|
||||
collection_name="old_collection",
|
||||
collection_name=OLD_COLLECTION,
|
||||
points=[
|
||||
{
|
||||
"id": 1,
|
||||
"vector": encode_with_old_model("Example document"),
|
||||
"payload": {"text": "Example document"}
|
||||
}
|
||||
]
|
||||
)
|
||||
models.PointStruct(
|
||||
id=1,
|
||||
vector=encode(text="Example document", model_name=OLD_MODEL),
|
||||
payload={"text": "Example document"}
|
||||
),
|
||||
```
|
||||
|
||||
You need to modify it to write to both collections:
|
||||
|
||||
```python
|
||||
client.upsert(
|
||||
collection_name="old_collection",
|
||||
collection_name=OLD_COLLECTION,
|
||||
points=[
|
||||
{
|
||||
"id": 1,
|
||||
"vector": encode_with_old_model("Example document"),
|
||||
"payload": {"text": "Example document"}
|
||||
}
|
||||
models.PointStruct(
|
||||
id=1,
|
||||
vector=encode(text="Example document", model_name=OLD_MODEL),
|
||||
payload={"text": "Example document"}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
client.upsert(
|
||||
collection_name="new_collection",
|
||||
collection_name=NEW_COLLECTION,
|
||||
points=[
|
||||
{
|
||||
"id": 1,
|
||||
models.PointStruct(
|
||||
id=1,
|
||||
# Use the new embedding model to encode the document
|
||||
"vector": encode_with_new_model("Example document"),
|
||||
"payload": {"text": "Example document"}
|
||||
}
|
||||
vector=encode(text="Example document", model_name=NEW_MODEL),
|
||||
payload={"text": "Example document"}
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
@@ -110,15 +108,15 @@ A good practice is to always make sure both operations succeed, and if one of th
|
||||
error appropriately, for example, by retrying the operation.
|
||||
|
||||
<aside role="status">
|
||||
The example above is an overly simplified version of the code, but it illustrates the idea. Practically, it makes a lot
|
||||
of sense to make this process controllable from outside, so you can choose which models and corresponding collections to
|
||||
This example is an overly simplified version of the code, but it illustrates the idea. Practically, it makes a lot
|
||||
of sense to make this process controllable from the outside, so you can choose which models and corresponding collections to
|
||||
use for each operation that modifies points in the collection. By making this effort now, you will save yourself a lot
|
||||
of trouble later, as you will be able to dynamically switch the model in the future without changing the application
|
||||
code again.
|
||||
</aside>
|
||||
|
||||
Please make sure to cover all the operations which can modify the data in your collections, **including updates and
|
||||
deletes**. Here are all the methods you have to modify in your application code, to make sure to cover all the
|
||||
Please make sure to cover all the operations that can modify the data in your collections, **including updates and
|
||||
deletes**. Here are all the methods you have to modify in your application code to ensure you cover all the
|
||||
operations that can modify the data in your collections:
|
||||
|
||||
- `.upsert` - inserting/updating specified points
|
||||
@@ -136,25 +134,25 @@ operations that can modify the data in your collections:
|
||||
|
||||
Please refer to the [documentation of the SDK you are using](/documentation/interfaces/), or the
|
||||
[HTTP](https://api.qdrant.tech/api-reference)/[gRPC](https://api.qdrant.tech/api-reference) definitions, for the exact
|
||||
method names, as they may vary between languages. You are likely not using all the above methods, so you only need to
|
||||
method names, as they may vary between languages. You are likely not using all these methods, so you only need to
|
||||
modify the ones that are relevant to your application.
|
||||
|
||||
After making these changes, your application will be in a **dual-write mode**, where it writes to both the old and new
|
||||
collections. This allows you to keep both collections up-to-date during the migration process, so the ongoing changes
|
||||
to your data are reflected in both collections.
|
||||
|
||||
### Step 3: Migrate the Existing Points Into a New Collection
|
||||
## Step 3: Migrate the Existing Points Into a New Collection
|
||||
|
||||
The dual-write should slowly start filling the new collection with the newly created points, but you also need to
|
||||
migrate the existing ones from the old collection to the new one. This can be done in a separate process, which can run
|
||||
in parallel with your application. The migration process will read the points from the old collection, re-embed them
|
||||
using the new model, and write them to the new collection.
|
||||
in parallel with your application. The migration process reads the points from the old collection, re-embeds them using the new model, and writes them to the new collection.
|
||||
|
||||
Qdrant's Scroll API is a way to read points from the old collection in batches. It guarantees that you will get all the
|
||||
points sorted by their IDs, which is important for the consistency of the migration process. Of course, unless you
|
||||
specify a different sorting order based on the payload attribute. It is not desirable in such a migration process
|
||||
though, as it can lead to inconsistencies in the order of points, if the payload is updated during the migration. Here
|
||||
is how to scroll through the points in the old collection, until you reach the end of the collection:
|
||||
|
||||
As the migration process writes re-embedded points to the new collection, it can use conditional upserts to avoid overwriting any points that may have been updated by the dual-write process in the meantime. In this example, the filter condition checks if the point already exists in the new collection, and only inserts it if it does not exist. You could also use more sophisticated conditions based on timestamps or versioning, depending on your application's requirements.
|
||||
|
||||
{{< figure src="/docs/embedding-model-migration.png" caption="Embedding model migration in blue-green deployment" width="80%" >}}
|
||||
|
||||
Qdrant's Scroll API enables you to read points from the old collection in batches. Here is how to scroll through the points in the old collection until you reach the end of the collection and write re-embedded points to the new collection:
|
||||
|
||||
```python
|
||||
last_offset = None
|
||||
@@ -164,7 +162,7 @@ reached_end = False
|
||||
while not reached_end:
|
||||
# Get the next batch of points from the old collection
|
||||
records, last_offset = client.scroll(
|
||||
collection_name="old_collection",
|
||||
collection_name=OLD_COLLECTION,
|
||||
limit=batch_size,
|
||||
offset=last_offset,
|
||||
# Include payloads in the response, as we need them to re-embed the vectors
|
||||
@@ -172,45 +170,50 @@ while not reached_end:
|
||||
# We don't need the old vectors, so let's save on the bandwidth
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
|
||||
# Re-embed the points using the new model
|
||||
new_points = [
|
||||
models.PointStruct(
|
||||
# Keep the original ID to ensure consistency
|
||||
id=record.id,
|
||||
# Use the new embedding model to encode the text from the payload,
|
||||
# assuming that was the original source of the embedding
|
||||
vector={
|
||||
"my-new-vector": encode_with_new_model(record.payload["text"]),
|
||||
},
|
||||
# Keep the original payload
|
||||
payload=record.payload,
|
||||
upsert_operations = [
|
||||
models.UpsertOperation(
|
||||
upsert=models.PointsList(
|
||||
points=[models.PointStruct(
|
||||
# Keep the original ID to ensure consistency
|
||||
id=record.id,
|
||||
# Use the new embedding model to encode the text from the payload,
|
||||
# assuming that was the original source of the embedding
|
||||
vector=encode(record.payload.get("text"), model_name=NEW_MODEL),
|
||||
# Keep the original payload
|
||||
payload=record.payload
|
||||
)],
|
||||
update_filter=models.Filter(
|
||||
must_not=[
|
||||
models.HasIdCondition(has_id=[record.id]),
|
||||
],
|
||||
)
|
||||
)
|
||||
for record in records
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
|
||||
|
||||
# Upsert the re-embedded points into the new collection
|
||||
client.upsert(
|
||||
collection_name="new_collection",
|
||||
points=new_points,
|
||||
client.batch_update_points(
|
||||
collection_name=NEW_COLLECTION,
|
||||
update_operations=upsert_operations
|
||||
)
|
||||
|
||||
# Check if we reached the end of the collection
|
||||
reached_end = len(records) < batch_size
|
||||
reached_end = len(records) < batch_size
|
||||
```
|
||||
|
||||
This kind of migration process can take some time, and **the offset should be stored in a persistent way, so you can
|
||||
resume the migration process in case of a failure**. You can use a database, a file, or any other persistent storage
|
||||
to keep track of the last offset.
|
||||
This kind of migration process can take some time, and **the offset should be stored in a persistent way, so you can resume the migration process in case of a failure**. You can use a database, a file, or any other persistent storage to keep track of the last offset. Having said that, because the conditional upserts would not overwrite any points in the new collection, you could safely restart the migration process from the beginning if needed.
|
||||
|
||||
### Step 4: Switch the Search to the New Collection
|
||||
## Step 4: Switch the Search to the New Collection
|
||||
|
||||
Once the migration process is complete, and all the points from the old collection are re-embedded and stored in
|
||||
the new collection, you can switch the search to use the new collection. This is the point where you need to change
|
||||
your application code again. You need to modify the code that performs the search to use the new collection instead
|
||||
of the old one. There are three key changes you have to make:
|
||||
|
||||
1. **The collection name**, so you don't search in the old collection anymore.
|
||||
1. **The collection name**, if you're not using an alias, so you don't search in the old collection anymore.
|
||||
2. **The vector name**, if you use named vectors and the name of the vector has changed.
|
||||
3. **The embedding model** that transforms the query into a vector.
|
||||
|
||||
@@ -218,9 +221,8 @@ If your old code looked like this:
|
||||
|
||||
```python
|
||||
results = client.query_points(
|
||||
collection_name="old_collection",
|
||||
query=encode_with_old_model("my query"), # Old query vector
|
||||
using="my-old-vector", # Old vector name
|
||||
collection_name=OLD_COLLECTION,
|
||||
query=encode(text="my query", model_name=OLD_MODEL), # Old query vector
|
||||
limit=10,
|
||||
)
|
||||
```
|
||||
@@ -229,23 +231,22 @@ You need to change it in the following way:
|
||||
|
||||
```python
|
||||
results = client.query_points(
|
||||
collection_name="new_collection",
|
||||
query=encode_with_new_model("my query"), # New query vector
|
||||
using="my-new-vector", # New vector name
|
||||
collection_name=NEW_COLLECTION,
|
||||
query=encode(text="my query", model_name=NEW_MODEL), # New query vector
|
||||
limit=10,
|
||||
)
|
||||
```
|
||||
|
||||
<aside role="status">
|
||||
Perfectly, controlling the embedding model used for encoding the query is a part of the application logic you can
|
||||
Ideally, controlling the embedding model used for encoding the query is a part of the application logic you can
|
||||
control from outside, so you can switch the model without changing the application code. If you went the extra mile
|
||||
to have it dynamic, now it's time to enjoy the benefits of that effort. It might be also achieved by spinning up a
|
||||
new version of the service that uses the new model, and then switching the traffic to the new service.
|
||||
to have it dynamic, now it's time to enjoy the benefits of that effort. It might also be achieved by spinning up a
|
||||
new version of the service that uses the new model and then switching the traffic to the new service.
|
||||
</aside>
|
||||
|
||||
Please do not forget the dual-write mode you implemented in Step 2. Once your application is switched to the new
|
||||
collection, you should disable it completely, so the application only writes to the new collection.
|
||||
|
||||
At this point, your application is fully switched to the new collection, and all the searches will be performed
|
||||
using the new embeddings. You can now safely remove the old collection, if you no longer need it, but keeping at least
|
||||
using the new embeddings. You can now safely remove the old collection if you no longer need it, but keeping at least
|
||||
a snapshot of it is a good idea.
|
||||
|
||||
Reference in New Issue
Block a user