Full-text search

Declare a search index on a table and run relevance-ordered full-text queries.

Last updated:

A searchIndex adds a full-text index over one string column so you can match documents by their words rather than by an exact key. Results come back ordered by relevance, with optional exact-match filtering on declared filter fields.

Declaring a search index

Add .searchIndex(name, { field, filterFields }) to a table. field is the column the full-text index covers; filterFields lists columns you can narrow by with an exact match inside the search query.

// lunora/schema.ts
import { defineSchema, defineTable, v } from "lunorash/server";

export default defineSchema({
    messages: defineTable({
        channelId: v.id("channels"),
        userId: v.id("users"),
        text: v.string(),
    })
        .index("by_channel", ["channelId", "_creationTime"])
        .searchIndex("search_text", {
            field: "text",
            filterFields: ["channelId"],
        }),
});

Running a search query

Use .withSearchIndex(name, q => …). The builder's .search(field, query) runs the full-text match against the index's searchable field (call it exactly once), and .eq(field, value) narrows by a declared filter field.

import { query, v } from "@/lunora/_generated/server";

export const searchMessages = query.input({ channelId: v.id("channels"), term: v.string() }).query(async ({ ctx, args: { channelId, term } }) => {
    return ctx.db
        .query("messages")
        .withSearchIndex("search_text", (q) => q.search("text", term).eq("channelId", channelId))
        .take(20);
});

Filter fields

Every column you want to filter by inside a search must be declared in filterFields — only then can you call .eq(field, value) on it in the search builder. They narrow the candidate set by an exact match before relevance scoring, so a per-channel or per-tenant search stays scoped.

Relevance ordering and .take(n)

A search query returns rows ordered by relevance to the search term, best match first. You cannot re-.order() it, and .paginate() is not supported on a search query. Bound the result set with .take(n) instead:

.withSearchIndex("search_text", (q) => q.search("text", term))
.take(20);

.collect() and .first() also work; reach for .take(n) to cap how many top-ranked hits you return.

See also