Trovella Wiki

Type-Ahead Search

Prefix-based autocomplete with typo tolerance and source-level deduplication via Typesense.

Type-ahead search provides real-time autocomplete suggestions as the user types. It runs entirely through Typesense (no vector search) and returns one result per source document, making it suitable for fast, low-latency suggestion lists.

How It Works

The typeAheadSearch() function in packages/search/src/keyword-search.ts sends a prefix search to Typesense with typo tolerance and source-level grouping:

const response = await client
  .collections<ChunkDocument>(CHUNKS_COLLECTION)
  .documents()
  .search({
    q: opts.query,
    query_by: "title,embedded_text",
    filter_by: `organization_id:=${opts.organizationId}`,
    per_page: opts.limit ?? 10,
    prefix: "true,false",
    num_typos: "2,0",
    group_by: "source_id",
    group_limit: 1,
  });

Prefix Matching

prefix: "true,false";

This is a per-field setting matching the query_by field order. title gets prefix matching (true) so typing "comp" matches "competitor analysis". embedded_text does not get prefix matching (false) to avoid noisy partial matches in long text bodies.

Typo Tolerance

num_typos: "2,0";

Again per-field: title allows up to 2 typos, so "compititor" still matches "competitor". embedded_text requires exact matches (0 typos) to keep results precise.

Source-Level Deduplication

group_by: "source_id",
group_limit: 1,

A single source document may have many chunks in the index. Grouping by source_id with group_limit: 1 ensures each source document appears at most once in the suggestions. The result mapper extracts the first hit from each group:

return (response.grouped_hits ?? []).flatMap((group) => {
  const hit = group.hits[0];
  if (!hit) return [];
  const doc = hit.document;
  return [
    {
      id: doc.id,
      sourceTable: doc.source_table,
      sourceId: doc.source_id,
      title: doc.title,
    },
  ];
});

tRPC Endpoint

The hybridSearch.typeAhead procedure at packages/api/src/routers/hybrid-search.ts:

typeAhead: authorizedProcedure
  .input(
    z.object({
      query: z.string().min(1).max(200),
      limit: z.number().min(1).max(20).default(10),
    }),
  )
  .query(async ({ ctx, input }) => {
    if (ctx.ability.cannot("read", "ResearchArtifact")) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    return typeAheadSearch({
      query: input.query,
      organizationId: ctx.organizationId,
      limit: input.limit,
    });
  });

The endpoint requires read permission on ResearchArtifact via CASL. The input is limited to 200 characters (shorter than the full search endpoint's 1000 character limit) since type-ahead queries are typically short.

Response Shape

interface TypeAheadResult {
  id: string; // Chunk ID (of the best-matching chunk)
  sourceTable: string; // "research_artifact" | "research_output" | "extraction_result"
  sourceId: string; // ID of the source record
  title: string; // Document title for display
}

The response is intentionally minimal -- just enough to display a suggestion and navigate to the source document. No text snippets or scores are included.

Type-ahead optimizes for speed. The prefix matching and typo tolerance in Typesense already handle the "user is still typing" use case well. Adding a vector search would require an embedding call per keystroke (or per debounced input), adding latency and cost with diminishing relevance gains for short, incomplete queries.

On this page