Skip to content

feat(search): add WITHSCORES support to FT.SEARCH (#2143) - #3432

Open
watersRand wants to merge 6 commits into
redis:masterfrom
watersRand:feat/search-withscores
Open

feat(search): add WITHSCORES support to FT.SEARCH (#2143)#3432
watersRand wants to merge 6 commits into
redis:masterfrom
watersRand:feat/search-withscores

Conversation

@watersRand

@watersRand watersRand commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Description

This PR resolves issue #2143 by adding full support for the WITHSCORES option in FT.SEARCH commands within @redis/search.

Previously, the WITHSCORES flag was either ignored during argument parsing or its returned scores were dropped during reply transformation, causing the relevance score to be missing from search results.

This change ensures that:

  1. WITHSCORES is properly serialized into the command arguments when requested.
  2. The relevance score is correctly parsed and extracted in both RESP2 and RESP3 reply transformers.
  3. The SearchReply type definition is updated to expose an optional score?: number property on each returned document.

Checklist

  • Does npm test pass with this change (including linting)?
  • Is the new or changed code fully tested?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?

Note

Medium Risk
Reply parsing is option-driven and replaces prior RESP2 shape heuristics; incorrect preserve or field order could mis-parse scores or document fields for existing callers.

Overview
Extends FT.SEARCH in @redis/search so common RediSearch flags are serialized and reflected in typed results—not only WITHSCORES, but also EXPLAINSCORE, NOCONTENT, WITHPAYLOADS, WITHSORTKEYS, FILTER (including ±Infinity-inf/+inf), GEOFILTER, and PAYLOAD.

Reply handling now depends on parser.preserve: RESP2 walks each hit using the requested options (scores, explain trees, payloads, sort keys, empty value when NOCONTENT or RETURN: []); RESP3 maps score, payload, and sortkey onto each document. SearchReply documents gain optional score, scoreExplain, payload, and sortKey.

searchNoContent reuses SEARCH.parseCommand, always appends NOCONTENT, preserves options for transforms, and restricts options that conflict with ID-only replies. Tests cover argument building, preserve, and live Redis behavior (including NOCONTENT vs RETURN and blob sort keys).

Reviewed by Cursor Bugbot for commit f5fde9e. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7741133c79

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment on lines +181 to +182
if(typeof reply[i] === 'number' || (typeof reply[i] === 'string' && !isNaN(Number(reply[i])) && Array.isArray(reply[i + 1]))){
score = Number(reply[i++]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse RESP2 scores after the document ID

For RESP2, FT.SEARCH ... WITHSCORES returns each row as id, score, fields, but this checks for and consumes a score before consuming the ID. A reply such as [1, 'doc', '1', ['field', 'value']] is therefore parsed with '1' as the document value and then produces a spurious row. Moreover, without WITHSCORES, a numeric document ID in [1, '1', ['field', 'value']] is mistaken for a score, corrupting ordinary RESP2 searches as well; consume the ID first and determine score presence from the requested reply layout rather than the ID's numeric shape.

Useful? React with 👍 / 👎.

VERBATIM?: boolean;
NOSTOPWORDS?: boolean;
INKEYS?: RedisVariadicArgument;
WITHSCORES?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle scores in the no-content search variant

SEARCH_NOCONTENT.parseCommand reuses Parameters<typeof SEARCH.parseCommand>, so adding WITHSCORES here also exposes and serializes it for ft.searchNoContent. That command's separate RESP2 transformer returns reply.slice(1), treating every score as another document ID, while its RESP3 transformer maps the main result back to IDs and silently discards every score. Callers can therefore request scores through the public type but cannot receive a valid no-content result; either exclude this option from that variant or preserve ID/score pairs in its reply contract.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment on lines +220 to +221
const rawScore = getMapValue(resultMap,['score']);
const score = rawScore !== undefined ? Number(rawScore) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve configured double type mappings for scores

When a caller uses withTypeMapping({ [RESP_TYPES.DOUBLE]: String }), the RESP3 decoder supplies the score as a string, but this unconditional Number conversion changes it back to a number, so the newly exposed field ignores the client's requested reply mapping. Scored commands normally pass RESP3 doubles through and use transformDoubleReply[2] for RESP2; this field should follow the same pattern and use a DoubleReply-compatible type so the runtime value and inferred type remain consistent.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment thread packages/search/lib/commands/SEARCH.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d226bdb672

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
_preserve?: any,
_typeMapping?: TypeMapping
_typeMapping?: TypeMapping,
options?: FtSearchOptions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve search options for RESP2 reply transformation

Although the prior parser-order issue is addressed, the new option-aware branch still cannot execute through the client: command transformers are called with only (reply, parser.preserve, typeMapping), parseCommand never assigns the search options to parser.preserve, and this fourth parameter is therefore always undefined. On RESP2, requests using WITHSCORES, NOCONTENT, WITHPAYLOADS, or WITHSORTKEYS consequently parse every optional element as document content or another ID; preserve the relevant options during command parsing and consume them as the transformer's second argument.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
id: String((id as { toString?(): string })?.toString?.() ?? id ?? ''),
...(score !== undefined && !isNaN(score) ? {score} : {}),
...(scoreExplain !== undefined ? {scoreExplain} : {}),
...(rawPayload !== undefined ? {payload: String(rawPayload)} : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve null and binary payload replies

When WITHPAYLOADS is used for an ordinary document with no payload, Redis returns null and this expression exposes the string "null"; with a blob-string-to-Buffer type mapping, it also decodes arbitrary payload bytes through UTF-8 instead of preserving the Buffer. This makes RESP3 behavior inconsistent with RESP2 and corrupts binary payloads, so the raw null/blob value should be retained and the reply type should include those possibilities.

Useful? React with 👍 / 👎.

Comment on lines +111 to +112
if (options?.EXPLAINSCORE) {
parser.push('EXPLAINSCORE');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require WITHSCORES when enabling EXPLAINSCORE

When a caller supplies { EXPLAINSCORE: true }, which the new options interface permits independently, the parser emits EXPLAINSCORE without WITHSCORES. Redis requires WITHSCORES for this modifier, so this validly typed invocation fails at the server instead of returning a search reply; either add WITHSCORES automatically or reject/encode the invalid combination in the public API.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment thread packages/search/lib/commands/SEARCH.spec.ts Outdated
@nkaradzhov

Copy link
Copy Markdown
Collaborator

Hi @watersRand, thanks for taking this! It looks like there are some outstanding comments from the bots. If you need any help, let me know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf79ad9750

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
if (Array.isArray(rawScore)){
score = Number(rawScore[0]);
if (Array.isArray(rawScore[1])){
scoreExplain = rawScore[1].map(String);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the nested score explanation tree

When EXPLAINSCORE returns a normal multi-level scorer explanation, nested child arrays are coerced with String, collapsing each subtree into comma-joined text and losing its hierarchy. The RESP2 path retains the nested arrays, so the same query also produces incompatible shapes across protocols; preserve the recursive explanation structure and type it accordingly.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
...(score !== undefined && !isNaN(score) ? {score} : {}),
...(scoreExplain !== undefined ? {scoreExplain} : {}),
...(rawPayload !== undefined && rawPayload !== null? {payload: String(rawPayload)} : {}),
...(rawSortKey !== undefined && rawSortKey !== null ? {sortKey: String(rawSortKey)} : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the configured blob mapping for sort keys

With RESP3 and withTypeMapping({ [RESP_TYPES.BLOB_STRING]: Buffer }), rawSortKey is a Buffer, but String(rawSortKey) always converts it back to a UTF-8 string. Consequently WITHSORTKEYS ignores the caller's requested mapping and can corrupt non-UTF-8 sortable values; retain the mapped reply value and expose a BlobStringReply-compatible type.

Useful? React with 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment thread packages/search/lib/commands/SEARCH.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ebac4f0d9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
const hasExplain = Boolean(options?.EXPLAINSCORE) || Boolean(options?.EXPLAINSCORE);
const hasPayloads = Boolean(options?.WITHPAYLOADS);
const hasSortKeys = Boolean(options?.WITHSORTKEYS);
const noContent = Boolean(options?.NOCONTENT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat RETURN 0 replies as contentless

On RESP2, RETURN: [] is encoded as RETURN 0, which returns only document IDs just like NOCONTENT, but noContent remains false here. With at least two matches, the loop consumes the second ID as the first document's value and drops/misaligns the remaining results, regressing the existing RETURN: [] behavior; include an empty RETURN list when determining the reply layout.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment on lines +246 to +247
const hasScores = Boolean(options?.WITHSCORES);
const hasExplain = Boolean(options?.EXPLAINSCORE) || Boolean(options?.EXPLAINSCORE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse automatically enabled scores for EXPLAINSCORE

For the valid typed call { EXPLAINSCORE: true }, the new argument logic now automatically emits WITHSCORES, but this reply-layout flag still checks only the user-supplied WITHSCORES property. On RESP2 the returned score/explanation tuple is therefore consumed as document content and subsequent fields become spurious documents; the fresh automatic WITHSCORES behavior needs to be mirrored here by treating EXPLAINSCORE as scored output.

Useful? React with 👍 / 👎.

parser.push('FT.SEARCH', index, query);

parseSearchOptions(parser, options);
parser.preserve = options;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve search options for PROFILE SEARCH

This new preservation assignment only runs through SEARCH.parseCommand, while PROFILE_SEARCH.parseCommand calls parseSearchOptions directly and later delegates its RESP2 results to this option-dependent transformer (PROFILE_SEARCH.ts:116 and :126). Consequently profileSearch with WITHSCORES, NOCONTENT, payloads, or sort keys still parses its RESP2 result using the plain id, fields layout; the fresh evidence beyond the earlier preservation issue is that the wrapper path never executes this assignment, so it also needs to preserve its search options.

Useful? React with 👍 / 👎.

Comment on lines +14 to +15
SEARCH.parseCommand(parser, index, query, options as FtSearchOptions);
parser.push('NOCONTENT');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve NOCONTENT for legacy RESP3 array replies

On RESP3 connections to module versions that return the legacy array-shaped search reply, SEARCH_NOCONTENT.transformReply[3] intentionally falls back through SEARCH.transformReply[3], but this call preserves only options and appends NOCONTENT to the wire separately. The fallback therefore believes content is present and consumes alternating IDs as values, losing results when multiple documents match; preserve an effective options object containing NOCONTENT: true as well as sending the token.

Useful? React with 👍 / 👎.

Comment thread packages/search/lib/commands/SEARCH.ts Outdated
Comment on lines +206 to +207
if (options?.PAYLOAD) {
parser.push('PAYLOAD', options.PAYLOAD);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send empty PAYLOAD arguments

When a custom scorer deliberately uses an empty-string payload, this truthiness check omits both PAYLOAD and its value even though the public RedisArgument type permits an empty string; an empty Buffer is sent, so equivalent binary-safe inputs also behave inconsistently. Check for undefined instead so an explicitly supplied empty payload reaches the scorer.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5fde9ebd4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

parser.push('FT.SEARCH', index, query);

parseSearchOptions(parser, options);
parser.preserve = options;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snapshot reply-layout flags before awaiting the reply

When a caller mutates or reuses the options object after invoking ft.search but before its promise settles, parser.preserve still references that same object. The wire arguments have already been serialized from the original flags, while the reply transformer later reads the mutated flags, so removing WITHSCORES (or changing NOCONTENT, payload, sort-key, or RETURN settings) causes the RESP2 response to be parsed with the wrong layout. Preserve an immutable snapshot of the layout-relevant options instead.

Useful? React with 👍 / 👎.

Comment on lines +42 to +44
return [String(summary), Array.isArray(children) ? children.map(normalizeScoreExplain) : []];
}
return String(raw);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve type mappings in score explanations

When EXPLAINSCORE is used with withTypeMapping({ [RESP_TYPES.BLOB_STRING]: Buffer }), the decoder supplies explanation summaries and leaves as Buffers, but these String conversions turn every one back into text in both RESP2 and RESP3. The new scoreExplain field therefore ignores the caller's configured blob mapping; its recursive type and normalization should retain the mapped blob-string values.

Useful? React with 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f5fde9e. Configure here.

parser.push('FT.SEARCH', index, query);

parseSearchOptions(parser, options);
parser.preserve = options;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Profile search drops reply layout options

Medium Severity

The RESP2 transformer now sizes each document from parser.preserve (WITHSCORES, EXPLAINSCORE, NOCONTENT, WITHPAYLOADS, WITHSORTKEYS, empty RETURN), but parseSearchOptions never stores those options. PROFILE_SEARCH still emits the new flags and then calls this transformer with preserve unset, so RESP2 profile hits misread scores as ids and skip or merge later documents.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5fde9e. Configure here.

...(score !== undefined && !isNaN(score) ? {score} : {}),
...(scoreExplain !== undefined ? {scoreExplain} : {}),
...(payload !== undefined ? {payload} : {}),
...(sortKey !== undefined ? {sortKey} : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null payloads differ between protocol versions

Low Severity

RESP2 copies payload and sortKey whenever the slot is not undefined, so a Redis null placeholder becomes null on the document. RESP3 drops null and omits the property. The same missing payload is therefore null on RESP2 and absent on RESP3, which does not match the optional string | Buffer type or the WITHPAYLOADS test that expects undefined.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5fde9e. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants