diff --git a/.changeset/first-version.md b/.changeset/first-version.md index 6c1f9b9..fe387c4 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -9,6 +9,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. - Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. `meili multi-search --queries ''`, on `POST /multi-search`, runs several queries in one request, each naming its index, answered side by side or merged into one ranked list with `--federation`. +- Similar documents: `meili similar --index --id --embedder `, on `POST /indexes/{index_uid}/similar`, returns the documents of the index closest to one of them, narrowed by `--filter`, `--limit` and `--offset`; `--data` or `--file` carries a whole body, which a flag of the same field overrides. - Settings: `meili get settings --index `, which returns every setting with its current value or its default; `meili update settings --index `, with one flag per setting, from `--searchable-attributes` and `--stop-words` to `--typo-tolerance` and `--embedders`, where a value of `null` resets that one setting and `--data` or `--file` carries a whole settings body; `meili delete settings --index --yes`, which resets them all. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 3422e69..54dce1c 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -46,6 +46,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `PUT /indexes/{index_uid}/documents` | `meili update documents --index --file ` | | `POST …/documents/delete-batch`, `POST …/documents/delete`, `DELETE …/documents` | `meili delete documents --index (--ids \| --filter \| --all) --yes` | | `POST /indexes/{index_uid}/search` | `meili search --index --q ` | +| `POST /indexes/{index_uid}/similar` | `meili similar --index --id --embedder ` | | `POST /multi-search` | `meili multi-search --queries [--federation ]` | | `GET /indexes/{index_uid}/settings` | `meili get settings --index ` | | `PATCH /indexes/{index_uid}/settings` | `meili update settings --index -- ` | diff --git a/oclif.manifest.json b/oclif.manifest.json index ef5f670..d807140 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -5029,6 +5029,300 @@ "search.js" ] }, + "similar": { + "aliases": [], + "args": {}, + "description": "Find the documents an index holds that are closest to one of its documents, named by --id and compared with --embedder.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default", + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default --output json", + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default --filter 'genres = horror' --limit 5", + "<%= config.bin %> <%= command.id %> --index movies --data '{\"id\":143,\"embedder\":\"default\",\"retrieveVectors\":true}'" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "index": { + "description": "The index to search in.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "id": { + "description": "The document to compare with, by its primary key value.", + "name": "id", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "embedder": { + "description": "The embedder the similarity is computed with, one of those the index settings configure.", + "name": "embedder", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Number of documents to skip.", + "name": "offset", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "limit": { + "description": "Maximum number of documents to return, 20 by default.", + "name": "limit", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "filter": { + "description": "Filter expression, for example `genres = horror`. Its array-of-arrays form goes through --data.", + "name": "filter", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "attributes-to-retrieve": { + "description": "Document attributes to return, comma-separated. Defaults to the displayed ones.", + "name": "attributes-to-retrieve", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "retrieve-vectors": { + "description": "Add _vectors to each hit, the embeddings of the document.", + "name": "retrieve-vectors", + "allowNo": false, + "type": "boolean" + }, + "show-ranking-score": { + "description": "Add _rankingScore to each hit, between 0.0 and 1.0.", + "name": "show-ranking-score", + "allowNo": false, + "type": "boolean" + }, + "show-ranking-score-details": { + "description": "Add _rankingScoreDetails to each hit, the score of every ranking rule.", + "name": "show-ranking-score-details", + "allowNo": false, + "type": "boolean" + }, + "show-performance-details": { + "description": "Add performanceDetails to the response, a timing breakdown of the query.", + "name": "show-performance-details", + "allowNo": false, + "type": "boolean" + }, + "ranking-score-threshold": { + "description": "Drop the hits whose ranking score is below this value, between 0.0 and 1.0.", + "name": "ranking-score-threshold", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "similar", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "POST /indexes/{index_uid}/similar" + ], + "keyActions": [ + "search" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "similar.js" + ] + }, "update:documents": { "aliases": [], "args": {}, diff --git a/src/commands/similar.ts b/src/commands/similar.ts new file mode 100644 index 0000000..e0c5ccc --- /dev/null +++ b/src/commands/similar.ts @@ -0,0 +1,105 @@ +import { Flags } from "@oclif/core"; +import type { SearchSimilarDocumentsParams } from "meilisearch"; +import { type Api, BaseCommand } from "../lib/base-command.js"; +import { readJsonObjectBody } from "../lib/input.js"; +import { asNumber } from "../lib/values.js"; + +export default class Similar extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /indexes/{index_uid}/similar"]; + + static keyActions = ["search"]; + + static override description = + "Find the documents an index holds that are closest to one of its documents, named by --id and compared with --embedder."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default", + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default --output json", + "<%= config.bin %> <%= command.id %> --index movies --id 143 --embedder default --filter 'genres = horror' --limit 5", + '<%= config.bin %> <%= command.id %> --index movies --data \'{"id":143,"embedder":"default","retrieveVectors":true}\'', + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: "The index to search in.", + helpValue: "", + }), + id: Flags.string({ + description: "The document to compare with, by its primary key value.", + helpValue: "", + }), + embedder: Flags.string({ + description: + "The embedder the similarity is computed with, one of those the index settings configure.", + helpValue: "", + }), + offset: Flags.integer({ + description: "Number of documents to skip.", + helpValue: "", + }), + limit: Flags.integer({ + description: "Maximum number of documents to return, 20 by default.", + helpValue: "", + }), + filter: Flags.string({ + description: + "Filter expression, for example `genres = horror`. Its array-of-arrays form goes through --data.", + helpValue: "", + }), + "attributes-to-retrieve": Flags.string({ + description: + "Document attributes to return, comma-separated. Defaults to the displayed ones.", + helpValue: "", + }), + "retrieve-vectors": Flags.boolean({ + description: "Add _vectors to each hit, the embeddings of the document.", + }), + "show-ranking-score": Flags.boolean({ + description: "Add _rankingScore to each hit, between 0.0 and 1.0.", + }), + "show-ranking-score-details": Flags.boolean({ + description: "Add _rankingScoreDetails to each hit, the score of every ranking rule.", + }), + "show-performance-details": Flags.boolean({ + description: "Add performanceDetails to the response, a timing breakdown of the query.", + }), + "ranking-score-threshold": Flags.string({ + description: "Drop the hits whose ranking score is below this value, between 0.0 and 1.0.", + helpValue: "", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(Similar); + // Not typed against SearchSimilarDocumentsParams, unlike the flag object of `search`: + // the SDK type has no retrieveVectors, which SimilarQuery of the document declares. + const fromFlags = { + id: flags.id, + embedder: flags.embedder, + offset: flags.offset, + limit: flags.limit, + filter: flags.filter, + attributesToRetrieve: flags["attributes-to-retrieve"]?.split(","), + retrieveVectors: flags["retrieve-vectors"] ? true : undefined, + showRankingScore: flags["show-ranking-score"] ? true : undefined, + showRankingScoreDetails: flags["show-ranking-score-details"] ? true : undefined, + showPerformanceDetails: flags["show-performance-details"] ? true : undefined, + rankingScoreThreshold: asNumber(flags["ranking-score-threshold"]), + }; + + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + // The undefined entries are dropped rather than spread: an absent flag would otherwise + // erase the field the body carries, since JSON.stringify leaves an undefined value out. + const params = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as SearchSimilarDocumentsParams; + + await this.output(await this.meilisearch.index(flags.index).searchSimilarDocuments(params)); + } +} diff --git a/test/commands/__snapshots__/similar.test.ts.snap b/test/commands/__snapshots__/similar.test.ts.snap new file mode 100644 index 0000000..e647c19 --- /dev/null +++ b/test/commands/__snapshots__/similar.test.ts.snap @@ -0,0 +1,93 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili similar > passes the API error of a missing index through and exits 4 1`] = ` +{ + "exitCode": 4, + "stderr": "{"error":{"code":"index_not_found","type":"invalid_request","message":"Index \`missing\` not found.","link":"https://docs.meilisearch.com/errors#index_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili similar > prints its help without any network call 1`] = ` +"Find the documents an index holds that are closest to one of its documents, named by --id and compared with --embedder. + +USAGE + $ meili similar --index [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] [--id + ] [--embedder ] [--offset ] [--limit ] [--filter + ] [--attributes-to-retrieve ] [--retrieve-vectors] + [--show-ranking-score] [--show-ranking-score-details] + [--show-performance-details] [--ranking-score-threshold ] [--file + | --data ] + +FLAGS + -o, --output= json (default): one document, the API + body as it is, indented in a terminal, + compact when piped. ndjson: one item + per line, list commands only, streams + with --all. + --attributes-to-retrieve= Document attributes to return, + comma-separated. Defaults to the + displayed ones. + --data= Inline JSON body. + --debug Print every HTTP request and response + on stderr, secrets redacted. + --embedder= The embedder the similarity is + computed with, one of those the index + settings configure. + --file= Read the body from a file. \`-\` reads + from stdin. + --filter= Filter expression, for example \`genres + = horror\`. Its array-of-arrays form + goes through --data. + --id= The document to compare with, by its + primary key value. + --index= (required) The index to search in. + --limit= Maximum number of documents to return, + 20 by default. + --offset= Number of documents to skip. + --project= A project of the config file. Cannot + be combined with --project-url. + Required when the file has several + projects. + --project-api-key= Project API key. \`-\` reads it from + stdin. + --project-url= Project URL, the Meilisearch instance + to talk to. + --ranking-score-threshold= Drop the hits whose ranking score is + below this value, between 0.0 and 1.0. + --retrieve-vectors Add _vectors to each hit, the + embeddings of the document. + --show-performance-details Add performanceDetails to the + response, a timing breakdown of the + query. + --show-ranking-score Add _rankingScore to each hit, between + 0.0 and 1.0. + --show-ranking-score-details Add _rankingScoreDetails to each hit, + the score of every ranking rule. + +DESCRIPTION + Find the documents an index holds that are closest to one of its documents, + named by --id and compared with --embedder. + +EXAMPLES + $ meili similar --index movies --id 143 --embedder default + + $ meili similar --index movies --id 143 --embedder default --output json + + $ meili similar --index movies --id 143 --embedder default --filter 'genres = horror' --limit 5 + + $ meili similar --index movies --data '{"id":143,"embedder":"default","retrieveVectors":true}' + +" +`; + +exports[`meili similar > prints the API envelope as it is in JSON 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"hits":[{"id":2770,"title":"American Pie 2"},{"id":190859,"title":"American Sniper"}],"id":"143","processingTimeMs":1,"limit":20,"offset":0,"estimatedTotalHits":2} +", +} +`; diff --git a/test/commands/similar.test.ts b/test/commands/similar.test.ts new file mode 100644 index 0000000..d576f10 --- /dev/null +++ b/test/commands/similar.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import Similar from "../../src/commands/similar.js"; +import { MEILISEARCH_URL, meilisearchHandlers } from "../helpers/meilisearch-handlers.js"; +import { recordRequests, server, useMockServer } from "../helpers/msw.js"; +import { runCli, runCommand } from "../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const hits = [ + { id: 2770, title: "American Pie 2" }, + { id: 190859, title: "American Sniper" }, +]; + +/** The body the CLI sent to POST /indexes/{uid}/similar, request by request. */ +async function bodiesOf(argv: string[]): Promise { + const requests = await recordRequests(async () => { + await runCommand(Similar, argv, { env }); + }); + return requests.map((request) => JSON.parse(request.body)); +} + +describe("meili similar", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["similar", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the API envelope as it is in JSON", async () => { + server.use(meilisearchHandlers.similar(hits)); + const result = await runCommand( + Similar, + ["--index", "movies", "--id", "143", "--embedder", "default", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); + + it("sends the parameters of the route in its body", async () => { + server.use(meilisearchHandlers.similar(hits)); + const bodies = await bodiesOf([ + "--index", + "movies", + "--id", + "143", + "--embedder", + "default", + "--filter", + "genres = horror", + "--attributes-to-retrieve", + "id,title", + "--ranking-score-threshold", + "0.7", + "--retrieve-vectors", + "--show-ranking-score", + "--limit", + "2", + "--offset", + "1", + ]); + expect(bodies).toEqual([ + { + id: "143", + embedder: "default", + filter: "genres = horror", + attributesToRetrieve: ["id", "title"], + rankingScoreThreshold: 0.7, + retrieveVectors: true, + showRankingScore: true, + limit: 2, + offset: 1, + }, + ]); + }); + + it("takes the body from --data, which a flag of the same field overrides", async () => { + server.use(meilisearchHandlers.similar(hits)); + expect( + await bodiesOf(["--index", "movies", "--data", '{"id":143,"embedder":"default"}']), + ).toEqual([{ id: 143, embedder: "default" }]); + expect( + await bodiesOf([ + "--index", + "movies", + "--data", + '{"id":143,"embedder":"default"}', + "--id", + "2770", + ]), + ).toEqual([{ id: "2770", embedder: "default" }]); + }); + + it("sends a value that does not convert as the text it was given, for the API to refuse", async () => { + server.use(meilisearchHandlers.similar(hits)); + expect( + await bodiesOf([ + "--index", + "movies", + "--id", + "143", + "--embedder", + "default", + "--ranking-score-threshold", + "abc", + ]), + ).toEqual([{ id: "143", embedder: "default", rankingScoreThreshold: "abc" }]); + }); + + it("refuses --output ndjson: the response is hits, not a list envelope", async () => { + server.use(meilisearchHandlers.similar(hits)); + const result = await runCommand( + Similar, + ["--index", "movies", "--id", "143", "--embedder", "default", "--output", "ndjson"], + { env }, + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("cli_usage"); + }); + + it("passes the API error of a missing index through and exits 4", async () => { + server.use( + meilisearchHandlers.apiError("/indexes/missing/similar", 404, { + message: "Index `missing` not found.", + code: "index_not_found", + type: "invalid_request", + link: "https://docs.meilisearch.com/errors#index_not_found", + }), + ); + const result = await runCommand( + Similar, + ["--index", "missing", "--id", "143", "--embedder", "default", "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index 6cdcd8d..b9135a7 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -1486,6 +1486,127 @@ The file holds API keys and decides where every later command goes, for every pr "POST /indexes/{index_uid}/search", ], }, + { + "api": "meilisearch", + "description": "Find the documents an index holds that are closest to one of its documents, named by --id and compared with --embedder.", + "examples": [ + "meili similar --index movies --id 143 --embedder default", + "meili similar --index movies --id 143 --embedder default --output json", + "meili similar --index movies --id 143 --embedder default --filter 'genres = horror' --limit 5", + "meili similar --index movies --data '{"id":143,"embedder":"default","retrieveVectors":true}'", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The index to search in.", + "helpValue": "", + "name": "index", + "required": true, + "type": "option", + }, + { + "description": "The document to compare with, by its primary key value.", + "helpValue": "", + "name": "id", + "type": "option", + }, + { + "description": "The embedder the similarity is computed with, one of those the index settings configure.", + "helpValue": "", + "name": "embedder", + "type": "option", + }, + { + "description": "Number of documents to skip.", + "helpValue": "", + "name": "offset", + "type": "option", + }, + { + "description": "Maximum number of documents to return, 20 by default.", + "helpValue": "", + "name": "limit", + "type": "option", + }, + { + "description": "Filter expression, for example \`genres = horror\`. Its array-of-arrays form goes through --data.", + "helpValue": "", + "name": "filter", + "type": "option", + }, + { + "description": "Document attributes to return, comma-separated. Defaults to the displayed ones.", + "helpValue": "", + "name": "attributes-to-retrieve", + "type": "option", + }, + { + "description": "Add _vectors to each hit, the embeddings of the document.", + "name": "retrieve-vectors", + "type": "boolean", + }, + { + "description": "Add _rankingScore to each hit, between 0.0 and 1.0.", + "name": "show-ranking-score", + "type": "boolean", + }, + { + "description": "Add _rankingScoreDetails to each hit, the score of every ranking rule.", + "name": "show-ranking-score-details", + "type": "boolean", + }, + { + "description": "Add performanceDetails to the response, a timing breakdown of the query.", + "name": "show-performance-details", + "type": "boolean", + }, + { + "description": "Drop the hits whose ranking score is below this value, between 0.0 and 1.0.", + "helpValue": "", + "name": "ranking-score-threshold", + "type": "option", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "similar", + "keyActions": [ + "search", + ], + "routes": [ + "POST /indexes/{index_uid}/similar", + ], + }, { "api": "meilisearch", "async": true, @@ -2007,6 +2128,13 @@ The file holds API keys and decides where every later command goes, for every pr "POST /indexes/{index_uid}/search", ], }, + { + "description": "Find the documents an index holds that are closest to one of its documents, named by --id and compared with --embedder.", + "id": "similar", + "routes": [ + "POST /indexes/{index_uid}/similar", + ], + }, { "description": "Add documents to an index, merging their fields into the ones that already have the same primary key.", "id": "update documents", diff --git a/test/helpers/meilisearch-handlers.ts b/test/helpers/meilisearch-handlers.ts index 5eda475..acfd529 100644 --- a/test/helpers/meilisearch-handlers.ts +++ b/test/helpers/meilisearch-handlers.ts @@ -130,6 +130,23 @@ export const meilisearchHandlers = { }); }), + /** + * `POST /indexes/{uid}/similar` answering the envelope of the API at its + * default pagination, with the `id` of the document it was sent. + */ + similar: (hits: RecordAny[]) => + http.post(`${MEILISEARCH_URL}/indexes/:uid/similar`, async ({ request }) => { + const { id } = (await request.json()) as { id?: string }; + return HttpResponse.json({ + hits, + id: id ?? "", + processingTimeMs: 1, + limit: 20, + offset: 0, + estimatedTotalHits: hits.length, + }); + }), + /** * `POST /multi-search` answering `response`, whatever shape it is given: the * route answers one envelope per query, or the merged hits of every query diff --git a/test/integration/meilisearch/similar.test.ts b/test/integration/meilisearch/similar.test.ts new file mode 100644 index 0000000..5817604 --- /dev/null +++ b/test/integration/meilisearch/similar.test.ts @@ -0,0 +1,126 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch, type SearchResponse } from "meilisearch"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const uid = `meili-cli-test-similar-${randomUUID().slice(0, 8)}`; +// A user-provided embedder: the vectors come with the documents, so the suite +// needs no embedding service to have a similarity to compare. +const documents = [ + { id: 1, title: "Nosferatu", genre: "horror", _vectors: { manual: [1, 0, 0] } }, + { id: 2, title: "Dracula", genre: "horror", _vectors: { manual: [0.9, 0.1, 0] } }, + { id: 3, title: "Solaris", genre: "scifi", _vectors: { manual: [0, 0, 1] } }, +]; + +describe.skipIf(!meilisearchUp)("meili similar against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await client.createIndex(uid, { primaryKey: "id" }).waitTask(); + await client + .index(uid) + .updateSettings({ + filterableAttributes: ["genre"], + embedders: { manual: { source: "userProvided", dimensions: 3 } }, + }) + .waitTask(); + await client.index(uid).addDocuments(documents).waitTask(); + }); + + afterAll(async () => { + await client.deleteIndex(uid).waitTask(); + }); + + it("returns the documents closest to --id, the target excluded", async () => { + const result = await runBinary( + ["similar", "--index", uid, "--id", "1", "--embedder", "manual", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const body = JSON.parse(result.stdout) as SearchResponse & { id: string }; + expect(body.id).toBe("1"); + expect(body.hits.map((hit) => hit.id)).toEqual([2, 3]); + }); + + it("filters and limits the similar documents", async () => { + const result = await runBinary( + [ + "similar", + "--index", + uid, + "--id", + "1", + "--embedder", + "manual", + "--filter", + "genre = horror", + "--attributes-to-retrieve", + "id", + "--limit", + "1", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + const body = JSON.parse(result.stdout) as SearchResponse; + expect(body.hits).toEqual([{ id: 2 }]); + }); + + it("sends the body of --data, which a flag of the same field overrides", async () => { + const result = await runBinary( + [ + "similar", + "--index", + uid, + "--data", + JSON.stringify({ id: 1, embedder: "manual", limit: 1 }), + "--id", + "3", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + const body = JSON.parse(result.stdout) as SearchResponse & { id: string }; + expect(body.id).toBe("3"); + expect(body.hits).toHaveLength(1); + }); + + it("lets the API refuse a document that does not exist, with its own code", async () => { + const result = await runBinary( + ["similar", "--index", uid, "--id", "404", "--embedder", "manual", "--output", "json"], + env, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("not_found_similar_id"); + }); + + it("exits 4 with the API error when the index does not exist", async () => { + const result = await runBinary( + [ + "similar", + "--index", + `${uid}-missing`, + "--id", + "1", + "--embedder", + "manual", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(4); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("index_not_found"); + }); +});