So you've got some Embeddings persisted in your Milvus database? Great, now it's time to index them.

Then, once you've indexed them, searching will go a whole lot faster.

In this guide, I'll show you how to index persisted Embeddings in a Milvus collection. After that, I'll walk you through some code that will handle a little semantic search.

You can check out the full code on GitHub. Or you can just read along.

Remember, though: this tutorial is part of a series. It's assumed that you completed the other tutorials up to this point.

If not, I hate to add more distance to your learning curve but you really should go check out the whole series.

Why Indexing?

First up: let me answer the question some of you might be asking: why indexing?

Once you've persisted your Embeddings, why do you need to index them?

For the same reason you do indexing on any other kind of database. Indexes make queries return results more quickly.

But there's a difference between indexing on other databases and indexing with Milvus. 

Milvus uses a more complex algorithm to index vectors (or Embeddings) because it has to.

Think about it: other kinds of databases often create indices by simply putting things in alphabetical or numerical order. But when it comes to indexing Embeddings, the database needs to create an index for whole lists of floating point numbers.

That's where things get a little hairy.

Fortunately, you don't need to worry about the complexity that goes into that. Milvus handles it for you.

Oh, By the Way...

Even though you'll be going through the motions of how you create an index here, there's a caveat.

Milvus won't index anything with less than 1,024 rows. And we've only got 17 rows with this data.

Hey, I don't want to burn through your tokens. So I kept the data set small.

Anyway, you'll want to try this stuff later with a larger universe of information.

Some Code

With that intro out of the way, it's time to start coding. Here's a little class that handles indexing the Embeddings you persisted in the last guide.

public class IndexEmbeddings {

    private static Logger LOG = LoggerFactory.getLogger(IndexEmbeddings.class);

    private static final IndexType INDEX_TYPE = IndexType.IVF_FLAT;
    private static final String INDEX_PARAM = "{\"nlist\":1024}";

    private static final String INDEX_NAME = "amazon_food_reviews_index";

    private static final String COLLECTION_NAME = "amazon_food_reviews";
    private static final String EMBEDDING_FIELD = "content_embedding";

    public static void main(String[] args) {
        final MilvusServiceClient client = MilvusServiceClientHelper.getClient();

        final R<RpcStatus> response = client.createIndex(
                CreateIndexParam.newBuilder()
                        .withCollectionName(COLLECTION_NAME)
                        .withFieldName(EMBEDDING_FIELD)
                        .withIndexType(INDEX_TYPE)
                        .withMetricType(MetricType.L2)
                        .withExtraParam(INDEX_PARAM)
                        .withIndexName(INDEX_NAME)
                        .withSyncMode(Boolean.TRUE)
                        .build()
        );

        System.out.println(response);

        client.close();
    }
}

Start by honing in on those constants at the top.

You might notice that the index type the code will create is called IVF_FLAT. But what the heck is that?

It's an inverted file index (IVF) that reduces search scope via clustering. It's a great choice for beginners and people who aren't familiar with various indexing methodologies.

Next is the INDEX_PARAM constant. You'll notice it's hardcoded JSON.

In this case, the JSON object includes only a single property: "nlist". But what's that all about?

Remember above when I said that IVF reduces search scope via clustering? That "nlist" is the number of cluster units.

I'm keeping it at 1024 for scalability purposes. But you should fiddle around with much lower numbers.

By the way: Milvus recommends you set nlist to 4 x sqrt(n) where n is the number of entities in a segment.

The INDEX_NAME constant specifies the name of the index. Nothing too complicated going on there.

The last two constants should be familiar from the previous guide. The first one identifies the name of the collection. The second identifies the field that contains the Embeddings.

Within the main() method, the first line instantiates MilvusServiceClient. You've seen that code before.

And the next line of code creates the index. But there's a lot going on there.

Note that it's using the collection name as specified in the constant. And remember: the code in these guides always use the default database.

That withFieldName() method specifies the field that's getting indexed. Here, it's indexing the field with the Embeddings.

The next method specifies the index type. I've covered that above.

And then there's the metric type. What's that?

Basically, it's a means of determining similarity. The L2 you see above refers to Euclidean distance.

It's akin to that cosine similarity function you looked at a couple of guides back.

Then there's the withExtraParam() method. That gets stuffed with the INDEX_PARAM JSON that you saw above.

And the withIndexName() method specifies the name of the index.

Finally, the withSyncMode() method sets a TRUE boolean. That means the application will wait until all segments of the collection are successfully created.

Now going back to the top of the code, the client.createIndex() method, the code there actually creates the index with the specifications that I've already covered. Then it returns my favorite generic: R.

In this case, R is a type of RpcStatus. That represents the status of the attempt to create an index.

Towards the bottom of the method, the code spits out that response to the console. You should see something like this:

R{status=0, data=RpcStatus{msg='Success'}}

And if that's what you get when you run the code, you're in great shape.

Some More Code

Up to this point, you've not only persisted your Embeddings but you've also indexed them. You're ready to move on to the final step.

Create a new class called SemanticSearchInDatabase. You'll use that to handle the actual searching.

For starters, get your constants defined:

    private static final String COLLECTION_NAME = "amazon_food_reviews";
    private static final String EMBEDDING_FIELD = "content_embedding";
    private static final String CONTENT_FIELD = "content";
    private static final Integer SEARCH_K = 2;
    private static final String SEARCH_PARAM = "{\"nprobe\":10}";

    private static final String SEARCH_TERM = "cats like the weight loss food";

The first few constants you've seen before if you've been following along with these tutorials.

The COLLECTION_NAME constant defines the name of the collection where the info you're searching is persisted.

The EMBEDDING_FIELD constant defines the name of the field in the collection where you've persisted your Embeddings.

The CONTENT_FIELD constant defines the name of the field where the actual text is located. In other words, it's the field that contains info that people are searching through.

The SEARCH_K field defines the number of results to return. It's set to 2 here but you can define it as you see fit.

Next up is SEARCH_PARAM. That's in JSON format like the INDEX_PARAM constant you saw earlier.

The only property defined in that JSON is "nprobe".  But what's that?

It's the number of buckets closest to the target vector that Milvus will search.

The higher the number, the slower the search but you'll get more precise results. The lower the number, the faster the search but with less precise results.

So it's a trade-off. It's set to 10 in the code above but feel free to mess around with it.

And that last constant is the search term.

Thanks for the Memory

Next, add this method:

    private static void loadCollectionInMemory(final MilvusServiceClient client) {
        final R<RpcStatus> response = client.loadCollection(
                LoadCollectionParam.newBuilder()
                        .withCollectionName(COLLECTION_NAME)
                        .build()
        );

        System.out.println(response);
    }

The reason you need that is because Milvus handles all searches in memory. So what the code is doing there is you're loading the collection into memory.

Translating the Search Term

As you may recall from previous lessons, when you do a semantic search, you not only need to translate the content you're searching to Embeddings, but you also need to translate the search term.

Here's a method that does that:

    @NotNull
    private static List<List<Float>> getSearchEmbeddings() {
        final List<Float> searchEmbedding = SearchTermHelper.getEmbeddingForSingleSearchTermAsFloats(SEARCH_TERM);
        final List<List<Float>> searchEmbeddings = new ArrayList<>();
        searchEmbeddings.add(searchEmbedding);

        return searchEmbeddings;
    }

That method returns a List of List<Float> objects that repesents the search term Embeddings.

It also makes use of a helper class, SearchTermHelper, which you can check out on GitHub.

Handling the Search

Next, create a method that handles the actual search:

    private static List<String> search(MilvusServiceClient client) {
        final SearchParam searchParam = getSearchParam();
        final R<SearchResults> respSearch = client.search(searchParam);

        final List<String> results = new ArrayList<>();

        final StringArray array = respSearch
                .getData()
                .getResults()
                .getFieldsData(0)
                .getScalars()
                .getStringData();

        for (int j=0; j<array.getDataCount(); j++) {
            final String res = array.getData(j);
            results.add(res);
        }

        return results;
    }

That method kicks off by calling another method: getSearchParam(). I'll share that with you in a moment.

Once the SearchParam object is instatiated, the code uses the Milvus client to handle the search. The results come back as the R generic with SearchResults stereotype.

Then, the code steps through the results to get the scalars (in this case, readable text data) as arrays of strings. 

Next, the code goes through a loop to add each element in the array and get the actual string that matches the search query. That gets added to the results list.

Finally, the method returns the results list.

Instantiating the SearchParam Object

As promised, here's how you instantiate the SearchParam object:

    private static SearchParam getSearchParam() {
        final List<List<Float>> searchEmbeddings = getSearchEmbeddings();

        final List<String> outputFields = List.of(CONTENT_FIELD);

        final SearchParam searchParam = SearchParam.newBuilder()
                .withCollectionName(COLLECTION_NAME)
                .withConsistencyLevel(ConsistencyLevelEnum.STRONG)
                .withMetricType(MetricType.L2)
                .withOutFields(outputFields)
                .withTopK(SEARCH_K)
                .withVectors(searchEmbeddings)
                .withVectorFieldName(EMBEDDING_FIELD)
                .withParams(SEARCH_PARAM)
                .build();

        return searchParam;
    }

The first line in that method invokes getSearchEmbeddings(). You've already seen that method above.

The next line defines the output fields. That's what the code will actually display in the console.

For that, you need to display text that's related to the user's search term. So that comes from "content" (defined by the constant CONTENT_FIELD).

Then the code instantiates the SearchParam object with quite a few methods. I'll cover the less obvious ones.

First, take note of the consistency level. It's set to STRONG. That means MIlvus will read the most updated data view when the request comes in. You can read more about consistency level here.

Next up: the metric type. Specifically, it's a similarity metric type.

Here, it's set to L2. I've explained that above.

The withVectors() method specifies the vectors of the search term. 

The withVectorFieldName() method specifies the field that contains the persisted vectors.

Once the code instanties SearchParam, it returns the object.

Releasing the Collection

I mentioned earlier that Milvus conducts all of its searches in memory. So once the code is finished with its search, it should release the collection to free up memory.

Here's the method that does that:

    private static void releaseCollection(MilvusServiceClient client) {
        final ReleaseCollectionParam rparam = ReleaseCollectionParam.newBuilder()
                .withCollectionName(COLLECTION_NAME)
                .build();
        final R<RpcStatus> response = client.releaseCollection(rparam);
        System.out.println(response);
    }

Putting It All Together

Finally, here's the code that conducts the search using all of the methods you just saw:

    public static void main(String[] args) {
        final MilvusServiceClient client = MilvusServiceClientHelper.getClient();

        //Milvus handles searches in memory, so we have to load the collection into memory first
        loadCollectionInMemory(client);

        //do the actual search
        final List<String> results = search(client);

        //spit out the results
        results.forEach(r -> {
            System.out.println(r);
        });

        //release the collection
        releaseCollection(client);

        //close the client
        client.close();
    }

The first line instantiates the client. You've seen that before.

The next line loads the collection into memory with the assistance of the intuitively named loadCollectionInMemory() method.

The next line handles the actual search and gets the results.

The next few lines spit out the search results to the console.

The next line releases the in-memory collection.

And, finally, the last line closes the client.

Testing It Out

Now that you've got your code in place, it's time to run a test.

If everything goes as expected, you should see these two results on your console:

Title: My cats LOVE this "diet" food better than their regular food; Content: One of my boys needed to lose some weight and the other didn't.  I put this food on the floor for the chubby guy, and the protein-rich, no by-product food up higher where only my skinny boy can jump.  The higher food sits going stale.  They both really go for this food.  And my chubby boy has been losing about an ounce a week.
Title: My Cats Are Not Fans of the New Food; Content: My cats have been happily eating Felidae Platinum for more than two years. I just got a new bag and the shape of the food is different. They tried the new food when I first put it in their bowls and now the bowls sit full and the kitties will not touch the food. I've noticed similar reviews related to formula changes in the past. Unfortunately, I now need to find a new food that my cats will eat.

And that makes sense because that first line is the best match for the "cats like the weight loss food" search term.

Wrapping It Up

Congrats! You've now persisted, indexed, and searched with Embeddings!

Now it's up to you to take your knowledge to the next level. Try a larger set of content. Experiment with different search terms. Fiddle around with different settings.

And be sure to check out the code on GitHub.

Have fun!

Photo by Keval Gaikar: https://www.pexels.com/photo/serious-ethnic-man-showing-fingers-sign-against-white-background-4624893/