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.
Why No Semantic Search?
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.
Related Pages
- Query Pipeline Overview -- all search entry points
- Keyword Search -- the full BM25 search that type-ahead is derived from