Okay, check this out. This search updates in two directions while I type and whenever a new matching message gets added. So, I've got this list of messages here, right? So, if I search for Mike, you can see that it shows me in the list of messages where Mike appears in the message body. And that's cool, but it's not the really cool part because if I add another message here that doesn't contain Mike, 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. 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, rerunning and delivering the updated list of results to the client automatically. Now, let's be honest. There is a very good chance that an AI coding agent is going to write most of this code for you. And 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. But I'm getting a bit ahead of myself here. So, make sure you grabbed yourself a lovely cup of tea, drop me a like and sub, and let's dive into full text search on Convex. Okay, so as you saw in the intro, we're going to be building this uh little chat app here with this nice little full text search feature. So to begin with, let's define a schema for our message table to look something like this. It's got a body field. It's got a channel field which are both strings. Then you can see here we're adding this special search index to [music] this table. It's got name search body. And we can see that it's going to be searching over the body field. We also have this optional array of filter fields. And you can think of these as like additional index indices that we can use to efficiently filter our results using exact equality matches. And by the way, if you weren't aware, you can actually use dotbased syntax for the search field and the filter fields like you can on other indices. One other useful option here is staged true. So if you're adding a search index to a really really large existing table then this lets convex back fill it asynchronously instead of making uh the deploy weight and then once it's finished building the index you can just remove the stage flag to enable the index before actually querying it. Okay, now we defined our table. Let's see how we go about actually using this new special index by writing a query. So in our search.ts ts file we add a new convex query. [music] It's going to take in a body string and optionally an author name and channel to filter by. We're then going to do a query into our meshes table with our search index and do our search. And finally, we're going to take 10 and then just return them. The other important bit is on the front end. So on the front end, we're going to subscribe to this query using query like so. And 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 actually manually refresh anywhere. It just happens automatically. Now I'm going to apply a bit of a movie magic here and write the rest of the app. So uh it's going to allow us to create channels and send messages and interact and all that kind of stuff. All right, movie magic applied. Let's take this for a spin now. [music] So I can create this channel general and post a message in there from Mike with a body. Hello, this is Mike here. Then on the right hand side I can search for hello, hi and you'll see that the message shows up even though it only contains hello. This is an important detail because the terms work like an or in like SQL or something. So the message can match hello or hi and it doesn't need both of those. But stronger matches will rank higher. Now, if I add another message from Mike, for example, like and sub, then you'll see that nothing happens because it doesn't match any of the search terms. But if I add another message, hi there, like and sub, then it will show up because it contains the word hi. We can also add an exact filter by author. So, if I set it to Mike, then we'll see the same message. But if I change it something like Jamie, then no, we don't see anything. But then I can add a message from Jamie, something like I also said hi. And then we'll see it show up. Very nice. And of course, if I don't select a channel filter, this is going to work across, this search is going to work across channels. So I can add another message in another channel like this, and it's going to show up in the list. By the way, you'll notice that this search query is case insensitive. So if I add a message with some capital letters in hello, then it still shows up in the search. So although the full text search is case insensitive, those author and channel filters are exact matches. So Mike and Mike are going to be different filter values. And also by the way, note that this is keyword search filtering, not semantic searching. 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. And more on that in a little bit. And if you're still watching this point, by the way, you might be interested in how this actually works underneath the hood. So, it's using a Rust library called Tantivi. I think that's how you pronounce it. And it's designed for really high performance type search, which is what we're doing here. It splits text into terms using spaces, punctuation, and it works best with English and other Latin script languages. Um, it isn't fuzzy search, so it won't automatically correct your mistaken typos for you. Now, one other thing to also note is that the results that we get back are based upon a combination of BM25 score and some other criteria such as proximity matches and how many times the word matched, etc. So, if multiple documents have the same score, then the newest document is going to be returned first. The exact rules that rank these things um could well change over time. They haven't for a while, but as Convex improves at search, it may well do. If however you do want to get super fancy and you want to combine full text search and vector search, then you can combine them together using something called reciprocal rank fusion. It sounds fancy. It's not really that fancy, but it's a little bit out of the scope of this video. So, do let me know down below if it's something you would like me to to talk more about. Okay, just before we wrap up here, there's a couple of limits that you should be aware of when using full text search. Firstly, each search index can have exactly one string field, and you can have four search indices on a single table. And each index can have up to 16 filter fields, but an individual search query can only have up to eight equality filters. Search queries can have up to 16 terms, and each term can be a maximum of 32 bytes long. So that should cover most circumstances. And finally, a search result can contain at most 1,024 matches. And also, Convex tracks search storage and search query usage uh separately with different allowances and prices depending on your plan. So I've linked the current details down below rather than putting all the numbers in this video. That will probably age badly and you'll be bored of anyway. All right, so I hope this one was useful for you. I've left links to all the demos and everything down below. And I hope you enjoyed this video. And if you did, well, you might want to check out this video is one I did a few months ago about vector search. It's more of the one of the more creative videos I've done. So, watch it and you'll see what I mean. [music] Anyways, until next time. Thanks for watching. Cheerio.
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:
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:
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:
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 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.