Files
simstudioai--sim/apps/sim/lib/knowledge/embeddings.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

452 lines
14 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getBYOKKey } from '@/lib/api-key/byok'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { env, envNumber } from '@/lib/core/config/env'
import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
import {
DEFAULT_EMBEDDING_MODEL,
EMBEDDING_DIMENSIONS,
getEmbeddingModelInfo,
SUPPORTED_EMBEDDING_MODELS,
type TokenizerProviderId,
} from '@/lib/knowledge/embedding-models'
import { batchByTokenLimit, estimateTokenCount } from '@/lib/tokenization'
import { calculateCost } from '@/providers/utils'
const logger = createLogger('EmbeddingUtils')
const MAX_TOKENS_PER_REQUEST = 8000
const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 50)
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
export { EMBEDDING_DIMENSIONS } from '@/lib/knowledge/embedding-models'
class EmbeddingAPIError extends Error {
public status: number
constructor(message: string, status: number) {
super(message)
this.name = 'EmbeddingAPIError'
this.status = status
}
}
export type EmbeddingInputType = 'document' | 'query'
interface ProviderRequest {
apiUrl: string
headers: Record<string, string>
body: unknown
parse: (json: unknown) => number[][]
}
interface ResolvedProvider {
modelName: string
pricingId: string
isBYOK: boolean
/** Tokenizer used to estimate tokens when the API does not return a usage field. */
tokenizerProvider: TokenizerProviderId
/** Hard per-request item cap enforced by the provider (e.g. Gemini caps at 100). */
maxItemsPerRequest?: number
buildRequest: (inputs: string[], inputType: EmbeddingInputType) => ProviderRequest
}
/** Gemini's `batchEmbedContents` rejects requests with more than 100 items. */
const GEMINI_MAX_ITEMS_PER_REQUEST = 100
async function resolveOpenAIKey(workspaceId?: string | null): Promise<{
apiKey: string
isBYOK: boolean
}> {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'openai')
if (byokResult) {
logger.info('Using workspace BYOK key for OpenAI embeddings')
return { apiKey: byokResult.apiKey, isBYOK: true }
}
}
if (env.OPENAI_API_KEY) {
return { apiKey: env.OPENAI_API_KEY, isBYOK: false }
}
try {
return { apiKey: getRotatingApiKey('openai'), isBYOK: false }
} catch {
throw new Error('OPENAI_API_KEY is not configured')
}
}
async function resolveGeminiKey(workspaceId?: string | null): Promise<{
apiKey: string
isBYOK: boolean
}> {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'google')
if (byokResult) {
logger.info('Using workspace BYOK key for Gemini embeddings')
return { apiKey: byokResult.apiKey, isBYOK: true }
}
}
if (env.GEMINI_API_KEY) {
return { apiKey: env.GEMINI_API_KEY, isBYOK: false }
}
try {
return { apiKey: getRotatingApiKey('gemini'), isBYOK: false }
} catch {
throw new Error(
'GEMINI_API_KEY (or GEMINI_API_KEY_1/2/3 for rotation) must be configured for Gemini embeddings'
)
}
}
function buildOpenAIProvider(modelName: string, apiKey: string): ResolvedProvider['buildRequest'] {
return (inputs) => ({
apiUrl: 'https://api.openai.com/v1/embeddings',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: {
input: inputs,
model: modelName,
encoding_format: 'float',
dimensions: EMBEDDING_DIMENSIONS,
},
parse: (json) => {
const data = json as { data: Array<{ embedding: number[] }> }
return data.data.map((item) => item.embedding)
},
})
}
function buildAzureOpenAIProvider(
deployment: string,
apiKey: string,
endpoint: string,
apiVersion: string
): ResolvedProvider['buildRequest'] {
return (inputs) => ({
apiUrl: `${endpoint}/openai/deployments/${deployment}/embeddings?api-version=${apiVersion}`,
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
},
body: {
input: inputs,
encoding_format: 'float',
dimensions: EMBEDDING_DIMENSIONS,
},
parse: (json) => {
const data = json as { data: Array<{ embedding: number[] }> }
return data.data.map((item) => item.embedding)
},
})
}
/**
* Gemini does NOT auto-normalize embeddings when `outputDimensionality` is set below the
* native 3072 dimension on `gemini-embedding-001`. Manually L2-normalize so cosine and
* inner-product similarity work correctly.
*/
function l2Normalize(vector: number[]): number[] {
let sumSquares = 0
for (const v of vector) sumSquares += v * v
const norm = Math.sqrt(sumSquares)
if (norm === 0) return vector
return vector.map((v) => v / norm)
}
function buildGeminiProvider(modelName: string, apiKey: string): ResolvedProvider['buildRequest'] {
return (inputs, inputType) => ({
apiUrl: `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:batchEmbedContents`,
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: {
requests: inputs.map((text) => ({
model: `models/${modelName}`,
content: { parts: [{ text }] },
taskType: inputType === 'query' ? 'RETRIEVAL_QUERY' : 'RETRIEVAL_DOCUMENT',
outputDimensionality: EMBEDDING_DIMENSIONS,
})),
},
parse: (json) => {
const data = json as { embeddings: Array<{ values: number[] }> }
return data.embeddings.map((item) => l2Normalize(item.values))
},
})
}
/**
* Returns the embedding model to use for new knowledge bases.
* Sourced from the `KB_EMBEDDING_MODEL` env var; falls back to the default if
* unset or set to an unsupported model.
*/
export function getConfiguredEmbeddingModel(): string {
const configured = env.KB_EMBEDDING_MODEL
if (configured && SUPPORTED_EMBEDDING_MODELS[configured]) {
return configured
}
if (configured) {
logger.warn(
`KB_EMBEDDING_MODEL="${configured}" is not a supported embedding model — falling back to ${DEFAULT_EMBEDDING_MODEL}`
)
}
return DEFAULT_EMBEDDING_MODEL
}
async function resolveProvider(
embeddingModel: string,
workspaceId?: string | null
): Promise<ResolvedProvider> {
const azureApiKey = env.AZURE_OPENAI_API_KEY
const azureEndpoint = env.AZURE_OPENAI_ENDPOINT
const azureApiVersion = env.AZURE_OPENAI_API_VERSION
const isOpenAIModel = SUPPORTED_EMBEDDING_MODELS[embeddingModel]?.provider === 'openai'
/**
* Azure deployment names default to the embedding model name when
* `KB_OPENAI_MODEL_NAME` is unset — this matches the pre-existing
* convention where deployments are named after the model they host.
*/
const azureDeploymentName = env.KB_OPENAI_MODEL_NAME || embeddingModel
const useAzure = Boolean(isOpenAIModel && azureApiKey && azureEndpoint && azureApiVersion)
const info = getEmbeddingModelInfo(embeddingModel)
if (useAzure) {
return {
modelName: azureDeploymentName,
pricingId: info.pricingId,
isBYOK: false,
tokenizerProvider: info.tokenizerProvider,
buildRequest: buildAzureOpenAIProvider(
azureDeploymentName,
azureApiKey!,
azureEndpoint!,
azureApiVersion!
),
}
}
if (info.provider === 'openai') {
const { apiKey, isBYOK } = await resolveOpenAIKey(workspaceId)
return {
modelName: embeddingModel,
pricingId: info.pricingId,
isBYOK,
tokenizerProvider: info.tokenizerProvider,
buildRequest: buildOpenAIProvider(embeddingModel, apiKey),
}
}
if (info.provider === 'gemini') {
const { apiKey, isBYOK } = await resolveGeminiKey(workspaceId)
return {
modelName: embeddingModel,
pricingId: info.pricingId,
isBYOK,
tokenizerProvider: info.tokenizerProvider,
maxItemsPerRequest: GEMINI_MAX_ITEMS_PER_REQUEST,
buildRequest: buildGeminiProvider(embeddingModel, apiKey),
}
}
throw new Error(`Unknown embedding provider for model ${embeddingModel}`)
}
async function callEmbeddingAPI(
inputs: string[],
provider: ResolvedProvider,
inputType: EmbeddingInputType
): Promise<{ embeddings: number[][]; totalTokens: number }> {
return retryWithExponentialBackoff(
async () => {
const request = provider.buildRequest(inputs, inputType)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), EMBEDDING_REQUEST_TIMEOUT_MS)
const response = await fetch(request.apiUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
signal: controller.signal,
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
const errorText = await response.text()
throw new EmbeddingAPIError(
`Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`,
response.status
)
}
const json = await response.json()
const embeddings = request.parse(json)
const usage = (json as { usage?: { total_tokens?: number } }).usage
const totalTokens =
usage?.total_tokens ??
// Gemini does not return usage.total_tokens — estimate with the provider's tokenizer
inputs.reduce(
(sum, text) => sum + estimateTokenCount(text, provider.tokenizerProvider).count,
0
)
return { embeddings, totalTokens }
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
retryCondition: (error: unknown) => {
if (error instanceof EmbeddingAPIError) {
return error.status === 429 || error.status >= 500
}
return isRetryableError(error)
},
}
)
}
function splitByItemLimit<T>(items: T[], limit: number): T[][] {
if (items.length <= limit) return [items]
const result: T[][] = []
for (let i = 0; i < items.length; i += limit) {
result.push(items.slice(i, i + limit))
}
return result
}
async function processWithConcurrency<T, R>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await processor(items[index], index)
}
})
await Promise.all(workers)
return results
}
export interface GenerateEmbeddingsResult {
embeddings: number[][]
totalTokens: number
isBYOK: boolean
modelName: string
/** Pricing identifier for use with calculateCost / EMBEDDING_MODEL_PRICING. */
pricingId: string
}
/**
* Generate embeddings for multiple texts with token-aware batching and parallel processing.
*/
export async function generateEmbeddings(
texts: string[],
embeddingModel: string = DEFAULT_EMBEDDING_MODEL,
workspaceId?: string | null
): Promise<GenerateEmbeddingsResult> {
const provider = await resolveProvider(embeddingModel, workspaceId)
const tokenBatches = batchByTokenLimit(texts, MAX_TOKENS_PER_REQUEST, embeddingModel)
const batches = provider.maxItemsPerRequest
? tokenBatches.flatMap((batch) => splitByItemLimit(batch, provider.maxItemsPerRequest!))
: tokenBatches
const batchResults = await processWithConcurrency(
batches,
MAX_CONCURRENT_BATCHES,
async (batch, i) => {
try {
return await callEmbeddingAPI(batch, provider, 'document')
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
throw error
}
}
)
const allEmbeddings: number[][] = []
let totalTokens = 0
for (const batch of batchResults) {
for (const emb of batch.embeddings) {
allEmbeddings.push(emb)
}
totalTokens += batch.totalTokens
}
return {
embeddings: allEmbeddings,
totalTokens,
isBYOK: provider.isBYOK,
modelName: provider.modelName,
pricingId: provider.pricingId,
}
}
/**
* Generate embedding for a single search query.
*/
export async function generateSearchEmbedding(
query: string,
embeddingModel: string = DEFAULT_EMBEDDING_MODEL,
workspaceId?: string | null
): Promise<{ embedding: number[]; isBYOK: boolean }> {
const provider = await resolveProvider(embeddingModel, workspaceId)
logger.info(`Using ${provider.modelName} for search embedding generation`)
const { embeddings } = await callEmbeddingAPI([query], provider, 'query')
return { embedding: embeddings[0], isBYOK: provider.isBYOK }
}
/**
* Records a query embedding's hosted-key cost for callers that generate a search
* embedding directly, outside the metered `/api/knowledge/search` route (e.g. the
* v1 search API and copilot KB search). No-ops for BYOK (no Sim cost) or when
* there is no workspace to attribute to. Best-effort: never throws.
*/
export async function recordSearchEmbeddingUsage(params: {
userId: string
workspaceId?: string | null
embeddingModel: string
query: string
isBYOK: boolean
sourceReference: string
}): Promise<void> {
const { userId, workspaceId, embeddingModel, query, isBYOK, sourceReference } = params
if (isBYOK || !workspaceId) return
try {
const { count } = estimateTokenCount(
query,
getEmbeddingModelInfo(embeddingModel).tokenizerProvider
)
const cost = calculateCost(embeddingModel, count, 0, false)
if (!cost || cost.total <= 0) return
await recordUsage({
userId,
workspaceId,
entries: [
{
category: 'model',
source: 'knowledge-base',
description: embeddingModel,
cost: cost.total,
sourceReference,
},
],
})
} catch (error) {
logger.warn('Failed to record search embedding usage', { error: getErrorMessage(error) })
}
}