Mike Cann's avatar
Mike Cann
3 months ago

This search updates itself

Okay check this out, this search updates in two directions, while I type and whenever a new matching message gets added.

So in this little chat app the messages are on the left and the search is on the right. I search for nice and get one result, then I post a message with "nice" in it from the box at the bottom, and it turns up in the results by itself. Then I change the search to mike:

0:00 / 0:00

That's cool, but it's not the really cool part. If I add another message that doesn't contain "mike" then as you'd expect nothing happens, but if I add a message that does contain "mike" then bam, the search results instantly update to show the new message. Keep an eye on the results on the right here:

0:00 / 0:00

What you just saw was the power of reactive full text search on Convex. You declare a Convex query to search for something, subscribe to it from the client, and then Convex just takes care of the rest, re-running it and delivering the updated list of results to the client automatically.

Now let's be honest, there's a very good chance that an AI coding agent is going to write most of this code for you. That's fine, but I would argue that you probably still need to know what it is exactly you're building and what its limits are.

So make sure you've grabbed yourself a lovely cup of tea and let's dive in.

Defining a search index

So to begin with let's define a schema for our messages table, to look something like this:

1// convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export default defineSchema({
6  messages: defineTable({
7    body: v.string(),
8    author: v.object({
9      name: v.string(),
10    }),
11    channel: v.string(),
12  }).searchIndex("search_body", {
13    searchField: "body",
14    filterFields: ["channel", "author.name"],
15  }),
16});
17

It's a normal table, and then you can see here we're adding this special search index to it. It's got the name search_body, and it's going to be searching over the body field.

We've also got this optional array of filter fields, and you can think of these as additional indexes that we can use to efficiently filter our results using exact equality matches. By the way, if you weren't aware, you can use dot-based syntax like author.name for the search field and the filter fields, just like you can on other indexes. It's all type-safe too, so something like author.nam is a compile error, which is really nice for preventing mistakes by you or your AI agent 🙂

One other useful option here is staged: true. If you're adding a search index to a really large existing table then this lets Convex backfill it asynchronously, instead of making the deploy wait. Once it's finished building you remove the staged flag to enable the index, and until you do Convex won't let you query it:

1Uncaught Error: Index messages.search_body is currently staged and not available to query until it is enabled.
2

Writing the query

Okay now let's see how we actually use this special index by writing a query. In our search.ts file we add a new Convex query:

1// convex/search.ts
2import { v } from "convex/values";
3import { query } from "./_generated/server";
4
5export const searchInMessages = query({
6  args: {
7    query: v.string(),
8    channel: v.optional(v.string()),
9    authorName: v.optional(v.string()),
10  },
11  handler: async (ctx, args) => {
12    const queryBuilder = ctx.db
13      .query("messages")
14      .withSearchIndex("search_body", (q) => {
15        let searchQuery = q.search("body", args.query);
16
17        if (args.channel) searchQuery = searchQuery.eq("channel", args.channel);
18
19        if (args.authorName)
20          searchQuery = searchQuery.eq("author.name", args.authorName);
21
22        return searchQuery;
23      });
24
25    const messages = await queryBuilder.take(10);
26    return messages;
27  },
28});
29

It takes in a search string and optionally an author name and channel to filter by. We then query our messages table with our search index and do our search, adding the filters if we were given them, and finally we take 10 and just return them.

The other important bit is on the frontend, where we subscribe to this query using useQuery:

1const searchResults = useQuery(
2  api.search.searchInMessages,
3  searchQuery
4    ? {
5        query: searchQuery,
6        channel: searchChannel || undefined,
7        authorName: searchAuthor || undefined,
8      }
9    : "skip",
10);
11

searchQuery, searchChannel and searchAuthor are just whatever's in the input boxes, and "skip" means don't run the query while the search box is empty.

That subscription is why the results update whenever the search changes or a new matching message is added. This almost works like magic because we don't manually refresh anywhere, it just happens automatically. Super cool!

Let's take this for a spin

The rest of the app is just the bits that let us create channels and send messages, so I'll skip over that. You can have a go on the demo yourself and the source is on GitHub.

So I've got one message in a #general channel, from mike, saying "hello this is mike here". Then in the search box on the right I can search for hello hi, and you'll see the message shows up even though it only contains "hello" (that's me in the corner by the way):

0:00 / 0:00

This is an important detail, because the terms work like an OR in SQL or something. So a message can match hello or hi, it doesn't need both of them, but stronger matches will rank higher.

I'd just added a message with neither of those words in it, and it doesn't show up in the results because it doesn't match any of the search terms. But if I add a message starting "hi there" then it shows up, because it contains the word hi:

0:00 / 0:00

We can also add an exact filter by author. So if I set the author box to mike we see the same messages, but if I change it to jamie then we don't see anything. Then I can post a message as jamie, something like "i also said hi", and we see it show up. Very nice!

0:00 / 0:00

And of course if you don't select a channel filter then the search works across all channels.

By the way, you'll notice that the search is case insensitive. So if I add a message with some capital letters in it, like HeLLO, then it still shows up in the search:

0:00 / 0:00

Although the full text search is case insensitive, those author and channel filters are exact matches, so jamie and jaMie are different filter values. Watch the author box here, and as soon as I change it there are no matching messages:

0:00 / 0:00

Also note that this is keyword search and not semantic search. So it knows Hello and hello are the same term, but it won't magically know that "car" and "automobile" are the same thing. That's where vector search comes in.

How it works underneath

If you're still reading at this point you might be interested in how this actually works underneath the hood. It's using a Rust library called Tantivy (I think that's how you pronounce it), and it's designed for really high performance typeahead search, which is what we're doing here.

It splits text into terms using spaces and punctuation, and it works best with English and other Latin-script languages. It isn't fuzzy search, so it won't automatically correct your typos for you.

For typeahead, only the final term in your search gets prefix matching. So if I type just h then it's the final and only term, which means it can match words that start with "h" such as "hi" or "hello".

The results you get back are ordered by relevance, based on a combination of BM25 score and some other criteria such as the proximity of matches and how many times the word matched. If multiple documents have the same score then the newest one comes first.

You can't ask the search index for a completely different order, like newest first, it's always going to be relevance order. The exact rules could well change over time as Convex gets better at search too, so don't build anything that relies on two particular matches always coming back in the same order.

If you do want to get super fancy you can combine full text search and vector search using something called reciprocal rank fusion. It sounds fancy, it's not really that fancy, but it's a little bit out of scope here.

Limits

Okay, just before we wrap up, there's a couple of limits you should be aware of when using full text search. For a search index:

  • It searches exactly one string field, and you can't have two search indexes on the same field.
  • It can have up to 16 filter fields.
  • It counts towards the limit of 32 indexes per table.

And for a search query:

  • It can use up to 8 equality filters.
  • It can have up to 16 terms. When I had my agent try 17 there was no error, the extra one just got ignored.
  • Each term needs to be shorter than 32 bytes. My agent found that longer ones get silently dropped rather than throwing an error too.
  • It can scan up to 1,024 results from the search index.

That last one will throw an error if you go past it. So if you've got more than 1,024 matches then .take(1025) or a .collect() gets you this:

1Uncaught Error: Search query scanned too many documents (fetched 1024). Consider using a smaller limit, paginating the query, or using a filter field to limit the number of documents pulled from the search index.
2

These limits should cover most circumstances tho, and a .take(10) like we did above is absolutely fine even when there are more matches than that.

Convex also tracks search storage and search query usage separately, with different allowances and prices depending on your plan. I've linked the current details rather than putting all the numbers in here, as they'll probably age badly and you'd be bored of them anyway. The one thing worth knowing is that each search query is billed for the full size of the index it runs against, regardless of how many results it returns or which filters it uses.

Conclusion

So just to recap: you add a search index to your table, query it with withSearchIndex, subscribe to that query with useQuery, and Convex keeps the results up to date both as you type and as new messages come in. The terms work like an OR, the filters are exact matches, and it's keyword search rather than semantic search.

I think what I like most is that it's just a normal Convex query, so you get the reactive bit without having to wire anything up or keep a separate search service in sync.

If you liked this one you might want to check out the video I did on vector search. It's one of the more creative videos I've done, so watch it and you'll see what I mean.

Until next time, cheerio! 👋

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started