Use default elser deployment for product documentation (#204760)

## Summary

Fix https://github.com/elastic/kibana/issues/204559

Use the default ELSER deployment (`.elser-2-elasticsearch`) for the
product documentation semantic_text fields instead of maintaining our
own custom deployment.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2025-01-08 20:17:14 +01:00 committed by GitHub
parent ad3b9880c7
commit 015911d2bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 46 additions and 262 deletions

View file

@ -104,3 +104,4 @@ export {
isSupportedConnector,
type InferenceConnector,
} from './src/connectors';
export { defaultInferenceEndpoints } from './src/inference_endpoints';

View file

@ -5,6 +5,10 @@
* 2.0.
*/
export { waitUntilModelDeployed } from './wait_until_model_deployed';
export { getModelInstallStatus } from './get_model_install_status';
export { installElser } from './install_elser';
/**
* Constants for all default (preconfigured) inference endpoints.
*/
export const defaultInferenceEndpoints = {
ELSER: '.elser-2-elasticsearch',
MULTILINGUAL_E5_SMALL: '.multilingual-e5-small-elasticsearch',
};

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import { defaultInferenceEndpoints } from '@kbn/inference-common';
export const productDocInstallStatusSavedObjectTypeName = 'product-doc-install-status';
/**
* The id of the inference endpoint we're creating for our product doc indices.
* Could be replaced with the default elser 2 endpoint once the default endpoint feature is available.
*/
export const internalElserInferenceId = 'kibana-internal-elser2';
export const internalElserInferenceId = defaultInferenceEndpoints.ELSER;

View file

@ -21,7 +21,6 @@ import {
} from './types';
import { productDocInstallStatusSavedObjectType } from './saved_objects';
import { PackageInstaller } from './services/package_installer';
import { InferenceEndpointManager } from './services/inference_endpoint';
import { ProductDocInstallClient } from './services/doc_install_status';
import { DocumentationManager } from './services/doc_manager';
import { SearchService } from './services/search';
@ -79,15 +78,9 @@ export class ProductDocBasePlugin
);
const productDocClient = new ProductDocInstallClient({ soClient });
const endpointManager = new InferenceEndpointManager({
esClient: core.elasticsearch.client.asInternalUser,
logger: this.logger.get('endpoint-manager'),
});
const packageInstaller = new PackageInstaller({
esClient: core.elasticsearch.client.asInternalUser,
productDocClient,
endpointManager,
kibanaVersion: this.context.env.packageInfo.version,
artifactsFolder: Path.join(getDataPath(), 'ai-kb-artifacts'),
artifactRepositoryUrl: this.context.config.get().artifactRepositoryUrl,

View file

@ -1,58 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { InferenceEndpointManager } from './endpoint_manager';
jest.mock('./utils');
import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils';
const installElserMock = installElser as jest.MockedFn<typeof installElser>;
const getModelInstallStatusMock = getModelInstallStatus as jest.MockedFn<
typeof getModelInstallStatus
>;
const waitUntilModelDeployedMock = waitUntilModelDeployed as jest.MockedFn<
typeof waitUntilModelDeployed
>;
describe('InferenceEndpointManager', () => {
let logger: MockedLogger;
let esClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let endpointManager: InferenceEndpointManager;
beforeEach(() => {
logger = loggerMock.create();
esClient = elasticsearchServiceMock.createElasticsearchClient();
endpointManager = new InferenceEndpointManager({ esClient, logger });
});
afterEach(() => {
installElserMock.mockReset();
getModelInstallStatusMock.mockReset();
waitUntilModelDeployedMock.mockReset();
});
describe('#ensureInternalElserInstalled', () => {
it('installs ELSER if not already installed', async () => {
getModelInstallStatusMock.mockResolvedValue({ installed: true });
await endpointManager.ensureInternalElserInstalled();
expect(installElserMock).not.toHaveBeenCalled();
expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1);
});
it('does not install ELSER if already present', async () => {
getModelInstallStatusMock.mockResolvedValue({ installed: false });
await endpointManager.ensureInternalElserInstalled();
expect(installElserMock).toHaveBeenCalledTimes(1);
expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { internalElserInferenceId } from '../../../common/consts';
import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils';
export class InferenceEndpointManager {
private readonly log: Logger;
private readonly esClient: ElasticsearchClient;
constructor({ logger, esClient }: { logger: Logger; esClient: ElasticsearchClient }) {
this.log = logger;
this.esClient = esClient;
}
async ensureInternalElserInstalled() {
const { installed } = await getModelInstallStatus({
inferenceId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
if (!installed) {
await installElser({
inferenceId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
}
await waitUntilModelDeployed({
modelId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
}
}

View file

@ -1,8 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { InferenceEndpointManager } from './endpoint_manager';

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { InferenceEndpointManager } from './endpoint_manager';
export type InferenceEndpointManagerMock = jest.Mocked<InferenceEndpointManager>;
const createMock = (): InferenceEndpointManagerMock => {
return {
ensureInternalElserInstalled: jest.fn(),
} as unknown as InferenceEndpointManagerMock;
};
export const inferenceManagerMock = {
create: createMock,
};

View file

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
export const getModelInstallStatus = async ({
inferenceId,
taskType = 'sparse_embedding',
client,
}: {
inferenceId: string;
taskType?: InferenceTaskType;
client: ElasticsearchClient;
log: Logger;
}) => {
const getInferenceRes = await client.inference.get(
{
task_type: taskType,
inference_id: inferenceId,
},
{ ignore: [404] }
);
const installed = (getInferenceRes.endpoints ?? []).some(
(endpoint) => endpoint.inference_id === inferenceId
);
return { installed };
};

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
export const installElser = async ({
inferenceId,
client,
log,
}: {
inferenceId: string;
client: ElasticsearchClient;
log: Logger;
}) => {
await client.inference.put(
{
task_type: 'sparse_embedding',
inference_id: inferenceId,
inference_config: {
service: 'elasticsearch',
service_settings: {
adaptive_allocations: { enabled: true },
num_threads: 1,
model_id: '.elser_model_2',
},
task_settings: {},
},
},
{ requestTimeout: 5 * 60 * 1000 }
);
};

View file

@ -1,39 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
export const waitUntilModelDeployed = async ({
modelId,
client,
log,
maxRetries = 20,
delay = 2000,
}: {
modelId: string;
client: ElasticsearchClient;
log: Logger;
maxRetries?: number;
delay?: number;
}) => {
for (let i = 0; i < maxRetries; i++) {
const statsRes = await client.ml.getTrainedModelsStats({
model_id: modelId,
});
const deploymentStats = statsRes.trained_model_stats[0]?.deployment_stats;
if (!deploymentStats || deploymentStats.nodes.length === 0) {
log.debug(`ML model [${modelId}] was not deployed - attempt ${i + 1} of ${maxRetries}`);
await sleep(delay);
continue;
}
return;
}
throw new Error(`Timeout waiting for ML model ${modelId} to be deployed`);
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View file

@ -24,6 +24,7 @@ jest.doMock('./steps', () => {
export const downloadToDiskMock = jest.fn();
export const openZipArchiveMock = jest.fn();
export const loadMappingFileMock = jest.fn();
export const ensureDefaultElserDeployedMock = jest.fn();
jest.doMock('./utils', () => {
const actual = jest.requireActual('./utils');
@ -32,5 +33,6 @@ jest.doMock('./utils', () => {
downloadToDisk: downloadToDiskMock,
openZipArchive: openZipArchiveMock,
loadMappingFile: loadMappingFileMock,
ensureDefaultElserDeployed: ensureDefaultElserDeployedMock,
};
});

View file

@ -13,6 +13,7 @@ import {
openZipArchiveMock,
validateArtifactArchiveMock,
fetchArtifactVersionsMock,
ensureDefaultElserDeployedMock,
} from './package_installer.test.mocks';
import {
@ -24,7 +25,6 @@ import {
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { installClientMock } from '../doc_install_status/service.mock';
import { inferenceManagerMock } from '../inference_endpoint/service.mock';
import type { ProductInstallState } from '../../../common/install_status';
import { PackageInstaller } from './package_installer';
@ -40,7 +40,6 @@ describe('PackageInstaller', () => {
let logger: MockedLogger;
let esClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let productDocClient: ReturnType<typeof installClientMock.create>;
let endpointManager: ReturnType<typeof inferenceManagerMock.create>;
let packageInstaller: PackageInstaller;
@ -48,13 +47,11 @@ describe('PackageInstaller', () => {
logger = loggerMock.create();
esClient = elasticsearchServiceMock.createElasticsearchClient();
productDocClient = installClientMock.create();
endpointManager = inferenceManagerMock.create();
packageInstaller = new PackageInstaller({
artifactsFolder,
logger,
esClient,
productDocClient,
endpointManager,
artifactRepositoryUrl,
kibanaVersion,
});
@ -68,6 +65,7 @@ describe('PackageInstaller', () => {
openZipArchiveMock.mockReset();
validateArtifactArchiveMock.mockReset();
fetchArtifactVersionsMock.mockReset();
ensureDefaultElserDeployedMock.mockReset();
});
describe('installPackage', () => {
@ -87,7 +85,7 @@ describe('PackageInstaller', () => {
productVersion: '8.16',
});
const indexName = getProductDocIndexName('kibana');
expect(endpointManager.ensureInternalElserInstalled).toHaveBeenCalledTimes(1);
expect(ensureDefaultElserDeployedMock).toHaveBeenCalledTimes(1);
expect(downloadToDiskMock).toHaveBeenCalledTimes(1);
expect(downloadToDiskMock).toHaveBeenCalledWith(
@ -128,9 +126,7 @@ describe('PackageInstaller', () => {
it('executes the steps in the right order', async () => {
await packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' });
expect(callOrder(endpointManager.ensureInternalElserInstalled)).toBeLessThan(
callOrder(downloadToDiskMock)
);
expect(callOrder(ensureDefaultElserDeployedMock)).toBeLessThan(callOrder(downloadToDiskMock));
expect(callOrder(downloadToDiskMock)).toBeLessThan(callOrder(openZipArchiveMock));
expect(callOrder(openZipArchiveMock)).toBeLessThan(callOrder(loadMappingFileMock));
expect(callOrder(loadMappingFileMock)).toBeLessThan(callOrder(createIndexMock));

View file

@ -14,8 +14,13 @@ import {
type ProductName,
} from '@kbn/product-doc-common';
import type { ProductDocInstallClient } from '../doc_install_status';
import type { InferenceEndpointManager } from '../inference_endpoint';
import { downloadToDisk, openZipArchive, loadMappingFile, type ZipArchive } from './utils';
import {
downloadToDisk,
openZipArchive,
loadMappingFile,
ensureDefaultElserDeployed,
type ZipArchive,
} from './utils';
import { majorMinor, latestVersion } from './utils/semver';
import {
validateArtifactArchive,
@ -29,7 +34,6 @@ interface PackageInstallerOpts {
logger: Logger;
esClient: ElasticsearchClient;
productDocClient: ProductDocInstallClient;
endpointManager: InferenceEndpointManager;
artifactRepositoryUrl: string;
kibanaVersion: string;
}
@ -39,7 +43,6 @@ export class PackageInstaller {
private readonly artifactsFolder: string;
private readonly esClient: ElasticsearchClient;
private readonly productDocClient: ProductDocInstallClient;
private readonly endpointManager: InferenceEndpointManager;
private readonly artifactRepositoryUrl: string;
private readonly currentVersion: string;
@ -48,14 +51,12 @@ export class PackageInstaller {
logger,
esClient,
productDocClient,
endpointManager,
artifactRepositoryUrl,
kibanaVersion,
}: PackageInstallerOpts) {
this.esClient = esClient;
this.productDocClient = productDocClient;
this.artifactsFolder = artifactsFolder;
this.endpointManager = endpointManager;
this.artifactRepositoryUrl = artifactRepositoryUrl;
this.currentVersion = majorMinor(kibanaVersion);
this.log = logger;
@ -144,7 +145,7 @@ export class PackageInstaller {
productVersion,
});
await this.endpointManager.ensureInternalElserInstalled();
await ensureDefaultElserDeployed({ client: this.esClient });
const artifactFileName = getArtifactName({ productName, productVersion });
const artifactUrl = `${this.artifactRepositoryUrl}/${artifactFileName}`;

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core/server';
import { defaultInferenceEndpoints } from '@kbn/inference-common';
export const ensureDefaultElserDeployed = async ({ client }: { client: ElasticsearchClient }) => {
await client.inference.inference(
{
inference_id: defaultInferenceEndpoints.ELSER,
input: 'I just want to call the API to force the model to download and allocate',
},
{ requestTimeout: 10 * 60 * 1000 }
);
};

View file

@ -8,3 +8,4 @@
export { downloadToDisk } from './download';
export { openZipArchive, type ZipArchive } from './zip_archive';
export { loadManifestFile, loadMappingFile } from './archive_accessors';
export { ensureDefaultElserDeployed } from './ensure_default_elser_deployed';

View file

@ -25,5 +25,6 @@
"@kbn/logging-mocks",
"@kbn/licensing-plugin",
"@kbn/task-manager-plugin",
"@kbn/inference-common",
]
}