Files
simstudioai--sim/apps/sim/lib/knowledge/reranker.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

183 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createLogger } from '@sim/logger'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
import { isSupportedRerankerModel } from '@/lib/knowledge/reranker-models'
const logger = createLogger('Reranker')
const RERANK_REQUEST_TIMEOUT_MS = 30_000
/**
* Cohere bills per "search unit" = one query with up to 100 documents.
* We cap at 100 so each rerank call costs exactly 1 unit and matches
* `RERANK_MODEL_PRICING` in `providers/models.ts`. The search route also
* caps `candidateTopK` at 100, so this is a defensive ceiling.
*/
const MAX_DOCUMENTS_PER_RERANK = 100
export interface RerankItem {
/** Stable identifier so callers can correlate ranked results back to source rows. */
id: string
text: string
}
interface RerankedResult<T extends RerankItem> {
item: T
relevanceScore: number
}
export interface RerankResponse<T extends RerankItem> {
results: RerankedResult<T>[]
/** True when a workspace-supplied (BYOK) Cohere key was used. Callers should skip platform billing in that case. */
isBYOK: boolean
}
class RerankAPIError extends Error {
public status: number
constructor(message: string, status: number) {
super(message)
this.name = 'RerankAPIError'
this.status = status
}
}
async function resolveCohereKey(
workspaceId?: string | null,
userApiKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
/**
* Mirrors the agent block hosted-key pattern (`injectHostedKeyIfNeeded`):
* on self-hosted the user-supplied key from the block field flows through
* unchanged; on hosted Sim we always source the key from workspace BYOK or
* platform env, so any user-supplied value is ignored.
*/
if (!isHosted && userApiKey) {
return { apiKey: userApiKey, isBYOK: false }
}
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'cohere')
if (byokResult) {
logger.info('Using workspace BYOK key for Cohere reranker')
return { apiKey: byokResult.apiKey, isBYOK: true }
}
}
if (env.COHERE_API_KEY) {
return { apiKey: env.COHERE_API_KEY, isBYOK: false }
}
try {
return { apiKey: getRotatingApiKey('cohere'), isBYOK: false }
} catch {
throw new Error(
'No Cohere API key configured. Set COHERE_API_KEY_1/2/3 (rotation) or COHERE_API_KEY.'
)
}
}
/**
* Subset of Cohere v2/rerank response fields we read.
* Reference: https://docs.cohere.com/v2/reference/rerank
* - `results[].index` maps back to the position in the documents we sent.
* - `results[].relevance_score` is normalized 01.
* - `meta.warnings` is documented as an array of strings; we surface them in logs
* so issues like document truncation don't disappear silently.
*/
interface CohereRerankResponse {
results: Array<{ index: number; relevance_score: number }>
meta?: {
warnings?: string[]
}
}
/**
* Rerank documents against a query using Cohere's `/v2/rerank` endpoint.
* Returns the items in descending order of relevance, capped at `topN`.
*/
export async function rerank<T extends RerankItem>(
query: string,
items: T[],
options: {
model: string
topN?: number
workspaceId?: string | null
/** User-supplied Cohere key from the Knowledge block field. Honored only on self-hosted. */
apiKey?: string
}
): Promise<RerankResponse<T>> {
if (items.length === 0) return { results: [], isBYOK: false }
if (!isSupportedRerankerModel(options.model)) {
throw new Error(`Unsupported reranker model: ${options.model}`)
}
const { apiKey, isBYOK } = await resolveCohereKey(options.workspaceId, options.apiKey)
const cappedItems =
items.length > MAX_DOCUMENTS_PER_RERANK ? items.slice(0, MAX_DOCUMENTS_PER_RERANK) : items
if (items.length > MAX_DOCUMENTS_PER_RERANK) {
logger.warn(`Rerank input capped from ${items.length} to ${MAX_DOCUMENTS_PER_RERANK} documents`)
}
const documents = cappedItems.map((it) => it.text)
const response = await retryWithExponentialBackoff(
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), RERANK_REQUEST_TIMEOUT_MS)
const res = await fetch('https://api.cohere.com/v2/rerank', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: options.model,
query,
documents,
top_n: options.topN ?? cappedItems.length,
}),
signal: controller.signal,
}).finally(() => clearTimeout(timeout))
if (!res.ok) {
const errorText = await res.text()
throw new RerankAPIError(
`Cohere rerank failed: ${res.status} ${res.statusText} - ${errorText}`,
res.status
)
}
return (await res.json()) as CohereRerankResponse
},
{
maxRetries: 3,
initialDelayMs: 500,
maxDelayMs: 5000,
retryCondition: (error: unknown) => {
if (error instanceof RerankAPIError) {
return error.status === 429 || error.status >= 500
}
return isRetryableError(error)
},
}
)
if (response.meta?.warnings && response.meta.warnings.length > 0) {
logger.warn('Cohere rerank returned warnings', {
model: options.model,
warnings: response.meta.warnings,
})
}
return {
results: response.results
.filter((r) => r.index >= 0 && r.index < cappedItems.length)
.map((r) => ({
item: cappedItems[r.index],
relevanceScore: r.relevance_score,
})),
isBYOK,
}
}