Skip to content

Hybrid search — Vectorize → FTS5 → substring → RRF → rerank → ACL

Source: platform/apps/app/src/lib/rag/search.ts · rendered from main on every deploy — edit in the repo, not here

platform/apps/app/src/lib/rag/search.ts
/**
* Hybrid search pipeline: Vectorize (semantic) + FTS5 (lexical) + RRF + Rerank.
* Gracefully degrades when bindings unavailable.
*/
import type { Database } from '@puccha/db';
import { chunks, documents } from '@puccha/db';
import type { ACL, PublicSurface, Role, TenantContext } from '@puccha/types';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { containsPattern } from '$lib/db/like.js';
import { citationTitle, sourceUrlFromRef } from './citation-title.js';
import {
buildEmbeddingCacheKey,
type EmbeddingCacheLike,
readEmbedding,
writeEmbedding,
} from './embedding-cache.js';
import { packSemanticSearch } from './pack-search.js';
import type { Reranker } from './rerank.js';
import { segmentForIndex } from './thai-segment.js';
export interface SearchResult {
chunkId: string;
docId: string;
/**
* Display title for the LLM context header and the citation. After
* `hybridSearch` this is the document title (plus ` › section` when the
* section heading means something on its own) — see citation-title.ts.
* Inside the search stages it is still the raw chunk/section title.
*/
title: string;
/** Document title as stored; absent for pack chunks / deleted docs. */
docTitle?: string;
/** Public page URL for crawled / linked documents. */
url?: string;
text: string;
anchor: string;
score: number;
source: 'vectorize' | 'fts5' | 'substring';
/**
* ADR-0101 §8. Pack provenance — present only when the chunk came
* from an L1 compliance pack (i.e. via `packSemanticSearch`). The
* citation renderer keys off this field to show pack badge + version
* + effective date + deep-link. Tenant chunks leave it undefined.
*
* Stored verbatim onto `messages.sourcesJson` so historical citations
* remain defensibly authoritative after pack versions bump
* (subscribers may unsubscribe / upgrade — the prior turn's citation
* must still resolve to what the agent saw at the time).
*/
pack?: {
slug: string;
version: string;
majorVersion: number;
effectiveDate: string;
sourceRef: string;
sourceUrl: string;
};
}
export interface SearchOptions {
db: Database;
tenant: TenantContext;
query: string;
topK?: number;
vectorize?: VectorizeIndex;
ai?: Ai;
reranker?: Reranker;
/** Filter to specific source IDs (from persona config). Empty = all sources. */
sourceIds?: string[];
/**
* ADR-0183. Restrict retrieval to documents whose ACL is
* `{ kind: 'public', surfaces: [<this surface>] }` — the ADR-0091 surface
* gate. Used for the no-token tier on an interlocked tenant: a visitor
* with no learner session sees the FAQ layer and nothing else. Applied
* before every phase, so nothing outside the layer reaches the reranker or
* the model; compliance packs are skipped on this tier too.
*/
publicSurface?: PublicSurface;
/**
* ADR-0039 Stage 3. Restrict to a single workspace within the tenant.
* Optional during the migration window — when omitted, behaves like the
* pre-workspaces code (matches all of the tenant's workspaces). Callers
* with a known workspace context (chat, MCP, playground) should pass
* `locals.workspace.id`; future stages will tighten this to required.
*/
workspaceId?: string;
/**
* KV namespace for the bge-m3 embedding cache. When passed, semantic
* search will skip the AI.run() call on a hit. Vectors are tenant-
* agnostic so the cache is shared globally — same query across tenants
* pays the AI cost once.
*/
embeddingCache?: EmbeddingCacheLike;
/**
* Cloudflare Worker `ctx.waitUntil` — used to write embedding cache
* entries without blocking the response. Pass `platform.context.waitUntil`
* from the route handler.
*/
waitUntil?: (p: Promise<unknown>) => void;
/**
* Optional observability hook — invoked once per call with the
* embedding-cache outcome. Routes pass a setter that captures the
* value into a closure so it ends up in query_log via logQuery's
* embeddingCacheStatus field. NB: only fires when semanticSearch
* actually runs (skips when vectorize/ai bindings are missing).
*/
onEmbeddingCacheStatus?: (status: 'hit' | 'miss' | 'skipped') => void;
/**
* Fired once per call with whether the reranker actually ran. Rerank
* only fires when a reranker is present AND there are enough candidates
* to benefit (>5), so small KBs return un-calibrated RRF/FTS scores.
* The scope gate uses this to decide whether the top score is on the
* reranker's calibrated scale — without it, a 3-chunk KB is compared
* against the Cohere threshold and falsely refused as out-of-scope.
*/
onReranked?: (ran: boolean) => void;
/**
* ADR-0101 §4. Subscribed compliance packs to include alongside the
* tenant's private KM in the candidate set. Each pack contributes
* one parallel Vectorize query against `platform:pack:<slug>:v<major>`.
* Resolution from `personas.packSlugs` → active subscriptions →
* this list happens in the chat orchestrator; this surface accepts
* only already-authorized packs (subscription IS the ACL).
*/
packs?: ReadonlyArray<{ slug: string; majorVersion: number }>;
}
// ── Main entry point ────────────────────────────────────────────────────
// Upper bound on how long we'll wait for the reranker before giving up and
// serving RRF order. Cohere rerank-v3.5 is normally 200–500ms; this only
// trips on the network tail / provider stall, where blocking the whole chat
// response is worse than slightly-degraded ranking for that one turn.
const RERANK_TIMEOUT_MS = 1500;
export async function hybridSearch(opts: SearchOptions): Promise<SearchResult[]> {
const { db, tenant, query, topK = 5, sourceIds } = opts;
const candidates: SearchResult[] = [];
// Resolve allowed docIds ONCE when persona restricts sources (ADR-0023).
// Passed into each retrieval phase so we don't run expensive queries
// and then discard 90% of the results post-hoc — which would silently
// return 0 hits if the top-K happens to be all out-of-scope.
let allowedDocIds: string[] | null = null;
if (sourceIds && sourceIds.length > 0) {
const wsFilter = opts.workspaceId ? eq(documents.workspaceId, opts.workspaceId) : undefined;
const allowedDocs = await db
.select({ id: documents.id })
.from(documents)
.where(
wsFilter
? and(eq(documents.tenantId, tenant.id), wsFilter, inArray(documents.sourceId, sourceIds))
: and(eq(documents.tenantId, tenant.id), inArray(documents.sourceId, sourceIds)),
);
allowedDocIds = allowedDocs.map((d) => d.id);
// No allowed docs → short-circuit. Nothing can possibly match.
if (allowedDocIds.length === 0) return [];
}
// ADR-0183 public tier: the surface gate as a doc-id allow-list, resolved
// once like the persona scope above. `json_each` over `$.surfaces` is the
// same predicate the help center uses (`helpSurfaceFilter`), so "public on
// the widget" means exactly one thing across the codebase. Intersects with
// a persona scope when both apply.
if (opts.publicSurface) {
const surfaceRows = await db
.select({ id: documents.id })
.from(documents)
.where(
and(
eq(documents.tenantId, tenant.id),
opts.workspaceId ? eq(documents.workspaceId, opts.workspaceId) : undefined,
sql`json_extract(${documents.aclJson}, '$.kind') = 'public'
AND EXISTS (
SELECT 1 FROM json_each(json_extract(${documents.aclJson}, '$.surfaces'))
WHERE value = ${opts.publicSurface}
)`,
),
);
const publicIds = surfaceRows.map((d) => d.id);
allowedDocIds = allowedDocIds
? allowedDocIds.filter((id) => publicIds.includes(id))
: publicIds;
if (allowedDocIds.length === 0) return [];
}
// Phase 1: Vectorize semantic search (when available).
// Tenant chunks and pack chunks query in parallel — same RRF/rerank
// stage consumes both. Pack results are pre-authorized by subscription
// (the caller resolved `packs` from active `tenant_pack_subscriptions`),
// so `allowedDocIds` source-scoping does NOT apply to them.
// Vectorize filter doesn't support docId IN (...) — oversample topK so
// post-filter still yields relevant hits after source scoping.
// Phase 2 (FTS5 lexical) runs in the SAME Promise.all as the vector
// queries: it depends only on the raw query + ACL filter, never on the
// semantic results, so awaiting it sequentially just stacked its latency
// onto the critical path for no reason. Substring fallback (Phase 3) is
// still sequential — it's gated on the combined candidate count.
const [semanticResults, packResults, lexicalResults] = await Promise.all([
semanticSearch({
...opts,
topK: allowedDocIds ? Math.max(50, topK * 10) : 20,
}),
opts.packs && opts.packs.length > 0 && !opts.publicSurface
? packSemanticSearch({
query: opts.query,
topK: opts.topK,
vectorize: opts.vectorize,
ai: opts.ai,
embeddingCache: opts.embeddingCache,
waitUntil: opts.waitUntil,
packs: opts.packs,
})
: Promise.resolve([] as SearchResult[]),
fts5Search(db, tenant, query, allowedDocIds, opts.workspaceId),
]);
if (allowedDocIds) {
const allowedSet = new Set(allowedDocIds);
candidates.push(...semanticResults.filter((r) => allowedSet.has(r.docId)));
} else {
candidates.push(...semanticResults);
}
candidates.push(...packResults);
candidates.push(...lexicalResults);
// Phase 3: Thai substring fallback (if phases 1+2 returned < 3 results)
if (candidates.length < 3) {
// Degrade to no extra candidates rather than failing the request, the
// same way the FTS5 phase does. A retrieval *fallback* that can take
// down a chat turn is the wrong shape regardless of which bug is live:
// ADR-0175 was written after an oversized LIKE pattern did exactly that.
try {
const substringResults = await substringSearch(
db,
tenant,
query,
allowedDocIds,
opts.workspaceId,
);
candidates.push(...substringResults);
} catch (err) {
console.error('[SUBSTRING_FALLBACK_FAILED]', err);
}
}
// Deduplicate by chunkId
const seen = new Set<string>();
const unique = candidates.filter((c) => {
if (seen.has(c.chunkId)) return false;
seen.add(c.chunkId);
return true;
});
// Phase 4: RRF merge (if we have results from multiple sources)
const sources = new Set(unique.map((r) => r.source));
let merged: SearchResult[];
if (sources.size > 1) {
merged = reciprocalRankFusion(unique);
} else {
merged = unique.sort((a, b) => b.score - a.score);
}
// Phase 5: Rerank. A calibrated reranker (Cohere) runs at ANY candidate
// count so the scope gate gets a real relevance floor even on small KBs
// (ADR-0112 D3) — the score IS the safety signal there. A non-calibrated
// reranker (LLM/noop) only reorders, so we keep the >5 FinOps guard:
// reranking ≤5 rank-derived results adds latency without changing scope.
if (opts.reranker && (opts.reranker.calibrated || merged.length > 5)) {
const toRerank = merged.slice(0, 25);
// Time-box the rerank: a stalled provider must not hold the chat
// response hostage. On timeout (or any error) we fall through to RRF
// order below. The late rerank promise is swallowed so a rejection
// arriving after the race doesn't surface as an unhandled rejection.
const rerankP = opts.reranker.rerank(
query,
toRerank.map((c) => `${c.title}\n${c.text}`),
topK,
);
rerankP.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const reranked = await Promise.race([
rerankP,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('rerank timeout')), RERANK_TIMEOUT_MS);
}),
]);
clearTimeout(timer);
opts.onReranked?.(true);
return withDocumentTitles(
opts.db,
opts.tenant.id,
reranked.map((r) => ({
...toRerank[r.index],
score: r.score,
})),
);
} catch {
clearTimeout(timer);
// Reranker failed or timed out — fall through to RRF order
}
}
opts.onReranked?.(false);
return withDocumentTitles(opts.db, opts.tenant.id, merged.slice(0, topK));
}
/**
* Replace raw section titles on the final top-K with `docTitle › section`
* (or the doc title alone) and attach the page URL. One tenant-scoped
* `documents` lookup for ≤ topK ids; pack chunks and deleted docs keep
* their existing title. Runs after rerank so it never affects ranking.
*/
async function withDocumentTitles(
db: Database,
tenantId: string,
results: SearchResult[],
): Promise<SearchResult[]> {
const ids = [...new Set(results.map((r) => r.docId).filter(Boolean))];
if (ids.length === 0) return results;
let rows: Array<{ id: string; title: string; sourceRef: string | null }> = [];
try {
rows = await db
.select({ id: documents.id, title: documents.title, sourceRef: documents.sourceRef })
.from(documents)
.where(and(eq(documents.tenantId, tenantId), inArray(documents.id, ids)));
} catch (err) {
// Citation polish must never take retrieval down with it.
console.error('[SEARCH_DOC_TITLES_FAILED]', err);
return results;
}
const byId = new Map(rows.map((r) => [r.id, r]));
return results.map((r) => {
const doc = byId.get(r.docId);
if (!doc) return r;
return {
...r,
title: citationTitle(doc.title, r.title),
docTitle: doc.title,
url: sourceUrlFromRef(doc.sourceRef),
};
});
}
// ── Phase 1: Vectorize semantic search ──────────────────────────────────
async function semanticSearch(opts: SearchOptions): Promise<SearchResult[]> {
if (!opts.vectorize || !opts.ai) return [];
try {
// Embedding cache (FinOps): bge-m3 is deterministic, so cache the
// vector by hashed query and skip the AI.run on a hit. ~50ms +
// ~$0.0001 saved per repeat. Cache is global (cross-tenant) since
// the embedding doesn't depend on tenant content.
let queryVec: number[] | null = null;
const cacheKey =
opts.embeddingCache && opts.waitUntil ? await buildEmbeddingCacheKey(opts.query) : null;
const cacheActive = !!opts.embeddingCache && cacheKey !== null;
if (opts.embeddingCache && cacheKey) {
queryVec = await readEmbedding(opts.embeddingCache, cacheKey);
}
if (!queryVec) {
const embedding = (await opts.ai.run('@cf/baai/bge-m3', {
text: [opts.query],
})) as { data: number[][] };
if (!embedding.data?.[0]) return [];
queryVec = embedding.data[0];
if (opts.embeddingCache && opts.waitUntil && cacheKey) {
writeEmbedding(opts.embeddingCache, cacheKey, queryVec, opts.waitUntil);
}
opts.onEmbeddingCacheStatus?.(cacheActive ? 'miss' : 'skipped');
} else {
opts.onEmbeddingCacheStatus?.('hit');
}
const vecResults = await opts.vectorize.query(queryVec, {
// Oversample when caller asks — source scoping needs more candidates
// so the post-filter still returns relevant hits.
topK: opts.topK ?? 20,
filter: { tenantId: opts.tenant.id },
returnMetadata: 'all',
});
const results: SearchResult[] = [];
for (const match of vecResults.matches) {
const meta = match.metadata as Record<string, string> | undefined;
if (!meta) continue;
// ADR-0039 Stage 3: workspace post-filter. Vectorize doesn't index
// workspaceId in its filter expression here (legacy vectors lack it
// in metadata), so we filter after the query. Missing metadata
// workspaceId is treated as "default workspace" — correct for
// pre-Stage-2 vectors during the migration window.
if (opts.workspaceId && meta.workspaceId && meta.workspaceId !== opts.workspaceId) {
continue;
}
const acl = JSON.parse(meta.acl || '{"kind":"org"}') as ACL;
if (!checkAcl(acl, opts.tenant.userId, opts.tenant.role)) continue;
results.push({
chunkId: meta.chunkId || match.id,
docId: meta.docId || '',
title: meta.title || '',
text: meta.text || '',
anchor: meta.anchor || '',
score: match.score ?? 0,
source: 'vectorize',
});
}
return results;
} catch {
return []; // Vectorize unavailable
}
}
// ── Phase 2: FTS5 lexical search ────────────────────────────────────────
async function fts5Search(
db: Database,
tenant: TenantContext,
query: string,
allowedDocIds: string[] | null,
workspaceId: string | undefined,
): Promise<SearchResult[]> {
try {
const segmented = segmentForIndex(query);
// FTS5 MATCH with OR between tokens for recall
const matchQuery = segmented
.split(/\s+/)
.filter((t) => t.length >= 2)
.join(' OR ');
if (!matchQuery) return [];
// chunks_fts itself doesn't carry workspace_id (it indexes denormalized
// content for FTS), but the JOIN to chunks gives us c.workspace_id —
// adding the filter there scopes the result set without re-indexing
// the FTS table. ADR-0039 Stage 3.
const wsClause = workspaceId ? sql`AND c.workspace_id = ${workspaceId}` : sql``;
// Source-scope filter pushed into SQL so we don't waste rank budget.
// Parameterized IN-list via `sql.join` keeps the query bindable.
const rows = (await (allowedDocIds
? db.all(sql`
SELECT fts.chunk_id, fts.acl_json, fts.rank,
c.doc_id, c.title, c.text, c.anchor
FROM chunks_fts fts
JOIN chunks c ON c.id = fts.chunk_id
WHERE fts.tenant_id = ${tenant.id}
${wsClause}
AND chunks_fts MATCH ${matchQuery}
AND c.doc_id IN (${sql.join(
allowedDocIds.map((id) => sql`${id}`),
sql`, `,
)})
ORDER BY fts.rank
LIMIT 50
`)
: db.all(sql`
SELECT fts.chunk_id, fts.acl_json, fts.rank,
c.doc_id, c.title, c.text, c.anchor
FROM chunks_fts fts
JOIN chunks c ON c.id = fts.chunk_id
WHERE fts.tenant_id = ${tenant.id}
${wsClause}
AND chunks_fts MATCH ${matchQuery}
ORDER BY fts.rank
LIMIT 20
`))) as Array<Record<string, unknown>>;
const results: SearchResult[] = [];
for (const row of rows) {
const acl = JSON.parse((row.acl_json as string) || '{"kind":"org"}') as ACL;
if (!checkAcl(acl, tenant.userId, tenant.role)) continue;
results.push({
chunkId: row.chunk_id as string,
docId: row.doc_id as string,
title: row.title as string,
text: row.text as string,
anchor: row.anchor as string,
score: Math.abs(row.rank as number),
source: 'fts5',
});
}
return results;
} catch {
return []; // FTS5 table may not exist
}
}
// ── Phase 3: Thai substring fallback ────────────────────────────────────
async function substringSearch(
db: Database,
tenant: TenantContext,
query: string,
allowedDocIds: string[] | null,
workspaceId: string | undefined,
): Promise<SearchResult[]> {
const segmented = segmentForIndex(query);
const tokens = segmented.split(/\s+/).filter((t) => t.length >= 2);
const qLower = query.toLowerCase();
// Use SQL LIKE to filter at DB level instead of loading all chunks
// Search for the full query OR the longest token in title/text
const likePattern = containsPattern(qLower);
const longestToken = tokens.sort((a, b) => b.length - a.length)[0] ?? qLower;
const tokenPattern = containsPattern(longestToken);
const whereConds = [
eq(chunks.tenantId, tenant.id),
or(
sql`lower(${chunks.title}) LIKE ${likePattern}`,
sql`lower(${chunks.text}) LIKE ${likePattern}`,
sql`lower(${chunks.title}) LIKE ${tokenPattern}`,
sql`lower(${chunks.text}) LIKE ${tokenPattern}`,
),
];
if (workspaceId) {
whereConds.push(eq(chunks.workspaceId, workspaceId));
}
if (allowedDocIds) {
whereConds.push(inArray(chunks.docId, allowedDocIds));
}
const matchedChunks = await db
.select({
id: chunks.id,
docId: chunks.docId,
title: chunks.title,
text: chunks.text,
anchor: chunks.anchor,
aclJson: chunks.aclJson,
})
.from(chunks)
.where(and(...whereConds))
.limit(100);
const results: SearchResult[] = [];
for (const chunk of matchedChunks) {
const h = `${chunk.title} ${chunk.text}`.toLowerCase();
let score = 0;
if (h.includes(qLower)) score += 15;
let hits = 0;
for (const tok of tokens) {
if (h.includes(tok)) {
hits++;
score += tok.length >= 4 ? 4 : 2;
}
}
if (tokens.length > 0) {
const coverage = hits / tokens.length;
if (coverage >= 0.7) score += 5;
if (coverage >= 0.9) score += 5;
}
if (chunk.title.toLowerCase().includes(qLower)) score += 8;
if (score > 0) {
const acl = JSON.parse(chunk.aclJson || '{"kind":"org"}') as ACL;
if (!checkAcl(acl, tenant.userId, tenant.role)) continue;
results.push({
chunkId: chunk.id,
docId: chunk.docId,
title: chunk.title,
text: chunk.text,
anchor: chunk.anchor ?? '',
score,
source: 'substring',
});
}
}
return results.sort((a, b) => b.score - a.score).slice(0, 20);
}
// ── Phase 4: Reciprocal Rank Fusion ─────────────────────────────────────
const RRF_K = 60;
function reciprocalRankFusion(results: SearchResult[]): SearchResult[] {
// Group by source to get per-source rankings
const bySource = new Map<string, SearchResult[]>();
for (const r of results) {
const list = bySource.get(r.source) || [];
list.push(r);
bySource.set(r.source, list);
}
// Sort each source list by score descending
for (const list of bySource.values()) {
list.sort((a, b) => b.score - a.score);
}
// Compute RRF scores
const rrfScores = new Map<string, { result: SearchResult; score: number }>();
for (const [, list] of bySource) {
for (let rank = 0; rank < list.length; rank++) {
const r = list[rank];
const rrfScore = 1 / (RRF_K + rank + 1);
const existing = rrfScores.get(r.chunkId);
if (existing) {
existing.score += rrfScore;
} else {
rrfScores.set(r.chunkId, { result: r, score: rrfScore });
}
}
}
return Array.from(rrfScores.values())
.sort((a, b) => b.score - a.score)
.map((e) => ({ ...e.result, score: e.score }));
}
// ── ACL check ───────────────────────────────────────────────────────────
function checkAcl(acl: ACL, userId: string | null, role: Role | null): boolean {
switch (acl.kind) {
case 'public':
return true;
case 'org':
// org-level content is visible to all users within the tenant context,
// including anonymous widget users (tenant resolved from /c/{slug}/)
return true;
case 'role':
return role !== null && acl.roles.includes(role);
case 'user':
return userId !== null && acl.userIds.includes(userId);
}
}