Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/docs/plugins/ai-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ console.log(result.results);

Pass optional overrides as a second argument to `query` to adjust `numResults` or other per-call settings.

## Caching

Query results are cached with a short TTL (60s) so repeated identical queries — including a component that re-renders or mounts twice — reuse a single Vector Search call instead of hitting the index each time. The next-page route is not cached: a page token is a single-use cursor and already identifies the exact page.

The cache key covers everything that changes results: the resolved index, `queryText`, `queryVector` (hashed), `queryType`, `numResults`, the resolved `columns`, `filters`, and whether reranking is on. Two queries that differ in any of these are cached separately.

The TTL is short on purpose: Vector Search results shift as the index resyncs, so results are only reused briefly.

### Per-user isolation

For `auth: "on-behalf-of-user"` indexes the caller's identity is part of the cache key, so one user never sees another user's cached results — and an on-behalf-of-user query never reads a service-principal-populated entry. Service-principal indexes share a single cache entry across callers.

## React hook

`useAiSearchQuery` reads the configured indexes from the plugin's client config and posts to the right `/:alias/query` route, so the UI never hardcodes an alias. With one index configured it needs no arguments; pass `{ alias }` to target a specific one.
Expand Down
145 changes: 123 additions & 22 deletions packages/appkit/src/plugins/ai-search/ai-search.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { createHash } from "node:crypto";
import type express from "express";
import type { IAppRouter, PluginExecutionSettings } from "shared";
import type { CacheConfig, IAppRouter, PluginExecutionSettings } from "shared";
import { AiSearchConnector } from "../../connectors/ai-search/client";
import type {
VsQueryParams,
VsRawResponse,
} from "../../connectors/ai-search/types";
import { getWorkspaceClient } from "../../context";
import { getCurrentUserId, getWorkspaceClient } from "../../context";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
Expand Down Expand Up @@ -166,22 +167,35 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
// projection past what the app configured. (query() callers are
// trusted and keep the override.)
const { columns: _clientColumns, ...safeBody } = body;
const plugin =
indexConfig.auth === "on-behalf-of-user" ? this.asUser(req) : this;
const isAsUser = indexConfig.auth === "on-behalf-of-user";
const plugin = isAsUser ? this.asUser(req) : this;
const queryType =
safeBody.queryType ?? indexConfig.queryType ?? "hybrid";

// Fold the caller identity into the cache key so an OBO index never
// serves one user's results to another (or an SP-populated entry).
// SP indexes share a stable "global" entry. `resolveUserId` is only
// reached on the OBO path, where a user header is required.
const executorKey = isAsUser ? this.resolveUserId(req) : "global";
const cache: CacheConfig = {
...aiSearchDefaults.cache,
cacheKey: this._cacheKeyFor(safeBody, indexConfig, executorKey),
};

try {
// Prepare inside execute so a self-managed embeddingFn runs in the
// same OBO context as the query, not as the service principal.
const result = await plugin.execute(async (signal) => {
const prepared = await this._prepareQuery(safeBody, indexConfig);
return this.connector.query(
getWorkspaceClient(),
{ indexName: indexConfig.indexName, ...prepared },
signal,
);
}, querySettings);
const result = await plugin.execute(
async (signal) => {
const prepared = await this._prepareQuery(safeBody, indexConfig);
return this.connector.query(
getWorkspaceClient(),
{ indexName: indexConfig.indexName, ...prepared },
signal,
);
},
{ default: { ...aiSearchDefaults, cache } },
);

this._sendResult(res, result, queryType);
} catch (error) {
Expand Down Expand Up @@ -230,6 +244,11 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
const plugin =
indexConfig.auth === "on-behalf-of-user" ? this.asUser(req) : this;

// Not cached: a page token is a single-use cursor into one query's
// result stream, so a repeat request with the same token is rare and
// the token itself already encodes the exact page. `querySettings`
// carries `cache.enabled` but no `cacheKey`, so the interceptor stays
// off (see plugin `_buildInterceptors`).
const result = await plugin.execute(
async (signal) =>
this.connector.queryNextPage(
Expand Down Expand Up @@ -301,16 +320,31 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
throw new Error(`No index configured with alias "${alias}"`);
}

const prepared = await this._prepareQuery(request, indexConfig);
// Resolved up front only for the response's queryType — `_prepareQuery`
// (and any `embeddingFn`) now runs inside `execute`, so a cache hit skips
// both the embedding and the VS call, mirroring the HTTP query route.
const { queryType } = this._resolveQueryParams(request, indexConfig);

// `query()` runs inside the caller's context — under `asUser(req)` the
// proxy wraps it in `runInUserContext`, so `getCurrentUserId()` is the
// user's id here and folds into the key (SP callers get the service id).
// This matches the interceptor's own `context.userKey`, so an OBO caller
// never shares an SP or another user's cache entry.
const cache: CacheConfig = {
...aiSearchDefaults.cache,
cacheKey: this._cacheKeyFor(request, indexConfig, getCurrentUserId()),
};

const result = await this.execute(
async (signal) =>
this.connector.query(
async (signal) => {
const prepared = await this._prepareQuery(request, indexConfig);
return this.connector.query(
getWorkspaceClient(),
{ indexName: indexConfig.indexName, ...prepared },
signal,
),
querySettings,
);
},
{ default: { ...aiSearchDefaults, cache } },
);

if (!result.ok) {
Expand All @@ -319,7 +353,7 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
);
}

return this._parseResponse(result.data, prepared.queryType);
return this._parseResponse(result.data, queryType);
}

async shutdown(): Promise<void> {
Expand Down Expand Up @@ -373,11 +407,38 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
res.json(this._parseResponse(result.data, queryType));
}

/**
* Resolve request-vs-index defaults for the fields that determine a query's
* result: `queryType`, `columns` (post-allowlist), `numResults`, and the
* derived `reranker`. Single source of truth for both `_prepareQuery` (what
* gets sent to VS) and `_cacheKeyFor` (what the result is keyed on), so the
* key can never silently drift from the payload.
*/
private _resolveQueryParams(
request: SearchRequest,
indexConfig: IndexConfig,
): {
queryType: "ann" | "hybrid" | "full_text";
columns: string[];
numResults: number;
reranker: { columnsToRerank: string[] } | undefined;
} {
const queryType = request.queryType ?? indexConfig.queryType ?? "hybrid";
const columns = request.columns ?? indexConfig.columns ?? [];
return {
queryType,
columns,
numResults: request.numResults ?? indexConfig.numResults ?? 20,
reranker: this._resolveReranker(request.reranker, indexConfig, columns),
};
}

private async _prepareQuery(
request: SearchRequest,
indexConfig: IndexConfig,
): Promise<Omit<VsQueryParams, "indexName">> {
const queryType = request.queryType ?? indexConfig.queryType ?? "hybrid";
const { queryType, columns, numResults, reranker } =
this._resolveQueryParams(request, indexConfig);
let queryText = request.queryText;
let queryVector = request.queryVector;

Expand All @@ -398,18 +459,58 @@ export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
}
}

const columns = request.columns ?? indexConfig.columns ?? [];
return {
queryText,
queryVector,
queryType,
columns,
numResults: request.numResults ?? indexConfig.numResults ?? 20,
numResults,
filters: request.filters,
reranker: this._resolveReranker(request.reranker, indexConfig, columns),
reranker,
};
}

/**
* Build the cache key for a query. Every input that changes the VS result
* is included, resolved through the same `_resolveQueryParams` that builds
* the payload so the key always agrees with what's actually sent (e.g. the
* post-allowlist `columns`, not the raw request). `executorKey` folds in the
* caller identity — "global" for the service principal, the user id for OBO
* — so an OBO caller never shares an SP or another user's entry.
*
* `queryVector` is hashed rather than embedded inline (vectors are large).
* We key on the request's inputs, not the derived embedding: an
* `embeddingFn` is a deterministic function of `queryText`, which is already
* in the key, and it hasn't run yet at key-build time (it runs inside
* `execute`, in the OBO context).
*/
private _cacheKeyFor(
request: SearchRequest,
indexConfig: IndexConfig & { indexName: string },
executorKey: string,
): CacheConfig["cacheKey"] {
const { queryType, columns, numResults, reranker } =
this._resolveQueryParams(request, indexConfig);
return [
"ai-search:query",
indexConfig.indexName,
request.queryText ?? "",
request.queryVector ? this._hashVector(request.queryVector) : "",
queryType,
numResults,
JSON.stringify(columns),
JSON.stringify(request.filters ?? null),
// Reranker changes result ordering; keep it in the key. Stringified
// because `cacheKey` entries are string | number | object, not boolean.
String(!!reranker),
executorKey,
];
}

private _hashVector(vector: number[]): string {
return createHash("sha256").update(JSON.stringify(vector)).digest("hex");
}

private _resolveReranker(
requestReranker: boolean | undefined,
indexConfig: IndexConfig,
Expand Down
6 changes: 5 additions & 1 deletion packages/appkit/src/plugins/ai-search/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { PluginExecuteConfig } from "shared";

export const aiSearchDefaults: PluginExecuteConfig = {
cache: { enabled: false },
// Short TTL: Vector Search results shift as the index resyncs, so cache only
// long enough to absorb bursts (repeated queries, React StrictMode double
// mounts) without serving stale results for long. The interceptor still needs
// a per-call `cacheKey` — set in the query paths — before caching engages.
cache: { enabled: true, ttl: 60 },
retry: { enabled: true, initialDelay: 1000, attempts: 3 },
timeout: 30_000,
};
Loading
Loading