// // Copyright © 2022 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // // See the License for the specific language governing permissions and // limitations under the License. // import core, { Class, Doc, DocIndexState, DocumentQuery, DocumentUpdate, docUpdKey, IndexStageState, MeasureContext, Ref, WorkspaceId } from '@hcengineering/core' import { contentStageId, DbAdapter, docKey, DocUpdateHandler, fieldStateId, FullSummaryStage, FullTextAdapter, FullTextPipeline, FullTextPipelineStage, IndexedDoc, isIndexingRequired, loadIndexStageStage, RateLimiter } from '@hcengineering/server-core' import got from 'got' import { chunks } from './encoder/encoder' import openaiPlugin, { openAIRatelimitter } from './plugin' /** * @public */ export const openAIstage = 'emb-v5' /** * @public */ export interface OpenAIEmbeddingResponse { data: { embedding: number[] }[] usage: { prompt_tokens: number total_tokens: number } } /** * @public */ export class OpenAIEmbeddingsStage implements FullTextPipelineStage { require = [fieldStateId, contentStageId] stageId = openAIstage treshold = 50 unauthorized = false field = 'openai_embedding' field_enabled = '_use' enabled = false clearExcept?: string[] = undefined updateFields: DocUpdateHandler[] = [] copyToState = true model = 'text-embedding-ada-002' tokenLimit = 8191 endpoint = 'https://api.openai.com/v1/embeddings' token = '' rate = 5 stageValue: boolean | string = true limiter = new RateLimiter(this.rate) indexState?: IndexStageState async update (doc: DocIndexState, update: DocumentUpdate): Promise {} constructor ( readonly adapter: FullTextAdapter, readonly workspaceId: WorkspaceId ) {} updateSummary (summary: FullSummaryStage): void { summary.fieldFilter.push((attr, value) => { const tMarkup = attr.type._class === core.class.TypeMarkup const lowerCase: string = value.toLocaleLowerCase() if (tMarkup && (lowerCase.includes('gpt:') || lowerCase.includes('gpt Answer:'))) { return false } return true }) } async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise { try { // Just do nothing const config = await storage.findAll(ctx, openaiPlugin.class.OpenAIConfiguration, {}) let needCheck = 0 if (config.length > 0) { if (this.enabled !== config[0].embeddings) { needCheck++ this.enabled = config[0].embeddings } if (this.token !== config[0].token) { this.token = config[0].token needCheck++ } const ep = (config[0].endpoint + '/embeddings').replace('//', '/') if (this.endpoint !== ep) { this.endpoint = ep needCheck++ } if (this.tokenLimit !== config[0].tokenLimit) { this.tokenLimit = config[0].tokenLimit needCheck++ } } else { this.enabled = false } if (needCheck > 0 && this.enabled) { // Initialize field embedding const emb = await this.getEmbedding('dummy') const dim = emb.data[0].embedding.length this.field = `${this.field}_${dim}` this.field_enabled = this.field + this.field_enabled await this.adapter.initMapping({ key: this.field, dims: dim }) } } catch (err: any) { console.error(err) this.enabled = false } ;[this.stageValue, this.indexState] = await loadIndexStageStage( ctx, storage, this.indexState, this.stageId, 'config', { enabled: this.enabled, endpoint: this.endpoint, field: this.field, mode: this.model, copyToState: this.copyToState, stripNewLines: true } ) } async getEmbedding (text: string): Promise { if (this.token === '') { return { data: [ { embedding: [] } ], usage: { total_tokens: 0, prompt_tokens: 0 } } } let l = this.tokenLimit let response: OpenAIEmbeddingResponse | undefined while (true) { const chs = chunks(text, l) let chunkChange = false for (const c of chs) { try { const embeddingData = await openAIRatelimitter.exec( async () => await got.post(this.endpoint, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` }, json: { input: c, model: this.model }, timeout: 15000 }) ) const res = JSON.parse(embeddingData.body) as OpenAIEmbeddingResponse if (response === undefined) { response = res } else { // Combine parts response.data[0].embedding = response.data[0].embedding.map((it, idx) => it + res.data[0].embedding[idx]) response.usage.prompt_tokens += res.usage.prompt_tokens response.usage.total_tokens += res.usage.total_tokens } } catch (err: any) { const msg = (err.message ?? '') as string if (msg.includes('maximum context length is')) { // We need to split chunks and try again. l = l / 2 chunkChange = true response = undefined break } throw err } } if (chunkChange) { continue } break } if (response === undefined) { throw new Error('Failed to retrieve embedding') } return response } async search ( _classes: Ref>[], query: DocumentQuery, size: number | undefined, from?: number ): Promise<{ docs: IndexedDoc[], pass: boolean }> { if (this.token === '' || !this.enabled) { return { docs: [], pass: true } } if (query.$search === undefined) return { docs: [], pass: true } const queryString = query.$search.replace('\n ', ' ') const embeddingData = await this.getEmbedding(queryString) const embedding = embeddingData.data[0].embedding const docs = await this.adapter.searchEmbedding(_classes, query, embedding, { size, from, minScore: -9, embeddingBoost: 10, field: this.field, field_enable: this.field_enabled, fulltextBoost: 1 }) return { docs, pass: docs.length === 0 } } async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline, metrics: MeasureContext): Promise { if (!this.enabled) { return } for (const doc of toIndex) { if (pipeline.cancelling) { return } await this.limiter.add(() => this.collectDoc(doc, pipeline, metrics)) } await this.limiter.waitProcessing() } async collectDoc (doc: DocIndexState, pipeline: FullTextPipeline, metrics: MeasureContext): Promise { if (pipeline.cancelling) { return } const needIndex = isIndexingRequired(pipeline, doc) // Copy content attributes as well. const update: DocumentUpdate = {} // Mark as empty by default and matck dimm size for elastic await this.update(doc, update) // No need to index this class, mark embeddings as empty ones. if (!needIndex) { await pipeline.update(doc._id, this.stageValue, {}) return } try { if (this.unauthorized) { return } const embeddingText = doc.fullSummary ?? '' if (embeddingText.length > this.treshold) { // replace newlines, which can negatively affect performance. Based on OpenAI examples. const embeddText = embeddingText.replace('\n ', ' ') console.log('calculate embeddings:', doc.objectClass, doc._id) let embeddingData: OpenAIEmbeddingResponse | undefined while (true) { try { embeddingData = await metrics.with('fetch-embeddings', {}, async () => await this.getEmbedding(embeddText)) break } catch (err: any) { if (((err.message as string) ?? '').includes('connect ECONNREFUSED')) { await new Promise((resolve) => setTimeout(resolve, 1000)) } if (err.message === 'Response code 429 (Too Many Requests)') { await new Promise((resolve) => setTimeout(resolve, 1000)) continue } throw new Error(err) } } const embedding: number[] = embeddingData.data[0].embedding await this.adapter.update(doc._id, { [this.field]: embedding, [this.field_enabled]: true }) if (this.copyToState) { ;(update as any)[docUpdKey(this.field)] = embedding } else { ;(update as any)[docUpdKey(this.field)] = embedding.length } ;(update as any)[docUpdKey(this.field_enabled)] = true } } catch (err: any) { if (err.message === 'Response code 401 (Unauthorized)') { this.unauthorized = true } const wasError = doc.attributes.error !== undefined await pipeline.update(doc._id, false, { [docKey('error')]: JSON.stringify(err) }) if (wasError) { return } // Print error only first time, and update it in doc index console.error(err) } await pipeline.update(doc._id, this.stageValue, update) } async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise { // will be handled by field processor for (const doc of docs) { await pipeline.update(doc._id, this.stageValue, {}) } } }