🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
273 lines
9.3 KiB
JavaScript
Executable File
273 lines
9.3 KiB
JavaScript
Executable File
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.FT_HYBRID_COMBINE_METHOD = exports.FT_HYBRID_VECTOR_METHOD = void 0;
|
|
const generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
|
|
const SEARCH_1 = require("./SEARCH");
|
|
const AGGREGATE_1 = require("./AGGREGATE");
|
|
/**
|
|
* Vector search method configuration - either KNN or RANGE.
|
|
*/
|
|
exports.FT_HYBRID_VECTOR_METHOD = {
|
|
/** K-Nearest Neighbors search configuration */
|
|
KNN: "KNN",
|
|
/** Range-based vector search configuration */
|
|
RANGE: "RANGE",
|
|
};
|
|
/**
|
|
* Score fusion method type constants for combining search results.
|
|
*/
|
|
exports.FT_HYBRID_COMBINE_METHOD = {
|
|
/** Reciprocal Rank Fusion */
|
|
RRF: "RRF",
|
|
/** Linear combination with ALPHA and BETA weights */
|
|
LINEAR: "LINEAR",
|
|
};
|
|
function parseSearchExpression(parser, search) {
|
|
parser.push("SEARCH", search.query);
|
|
if (search.SCORER) {
|
|
parser.push("SCORER", search.SCORER);
|
|
}
|
|
if (search.YIELD_SCORE_AS) {
|
|
parser.push("YIELD_SCORE_AS", search.YIELD_SCORE_AS);
|
|
}
|
|
}
|
|
function parseVectorExpression(parser, vsim) {
|
|
parser.push("VSIM", vsim.field, vsim.vector);
|
|
if (vsim.method) {
|
|
if (vsim.method.type === exports.FT_HYBRID_VECTOR_METHOD.KNN) {
|
|
let argsCount = 2;
|
|
if (vsim.method.EF_RUNTIME !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
parser.push("KNN", argsCount.toString(), "K", vsim.method.K.toString());
|
|
if (vsim.method.EF_RUNTIME !== undefined) {
|
|
parser.push("EF_RUNTIME", vsim.method.EF_RUNTIME.toString());
|
|
}
|
|
}
|
|
if (vsim.method.type === exports.FT_HYBRID_VECTOR_METHOD.RANGE) {
|
|
let argsCount = 2;
|
|
if (vsim.method.EPSILON !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
parser.push("RANGE", argsCount.toString(), "RADIUS", vsim.method.RADIUS.toString());
|
|
if (vsim.method.EPSILON !== undefined) {
|
|
parser.push("EPSILON", vsim.method.EPSILON.toString());
|
|
}
|
|
}
|
|
}
|
|
if (vsim.FILTER) {
|
|
parser.push("FILTER", vsim.FILTER);
|
|
}
|
|
if (vsim.YIELD_SCORE_AS) {
|
|
parser.push("YIELD_SCORE_AS", vsim.YIELD_SCORE_AS);
|
|
}
|
|
}
|
|
function parseCombineMethod(parser, combine) {
|
|
if (!combine)
|
|
return;
|
|
parser.push("COMBINE");
|
|
if (combine.method.type === exports.FT_HYBRID_COMBINE_METHOD.RRF) {
|
|
// Calculate argsCount: 2 per optional (WINDOW, CONSTANT, YIELD_SCORE_AS)
|
|
let argsCount = 0;
|
|
if (combine.method.WINDOW !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
if (combine.method.CONSTANT !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
if (combine.YIELD_SCORE_AS) {
|
|
argsCount += 2;
|
|
}
|
|
parser.push("RRF", argsCount.toString());
|
|
if (combine.method.WINDOW !== undefined) {
|
|
parser.push("WINDOW", combine.method.WINDOW.toString());
|
|
}
|
|
if (combine.method.CONSTANT !== undefined) {
|
|
parser.push("CONSTANT", combine.method.CONSTANT.toString());
|
|
}
|
|
}
|
|
if (combine.method.type === exports.FT_HYBRID_COMBINE_METHOD.LINEAR) {
|
|
// Calculate argsCount: 2 per optional (ALPHA, BETA, WINDOW, YIELD_SCORE_AS)
|
|
let argsCount = 0;
|
|
if (combine.method.ALPHA !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
if (combine.method.BETA !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
if (combine.method.WINDOW !== undefined) {
|
|
argsCount += 2;
|
|
}
|
|
if (combine.YIELD_SCORE_AS) {
|
|
argsCount += 2;
|
|
}
|
|
parser.push("LINEAR", argsCount.toString());
|
|
if (combine.method.ALPHA !== undefined) {
|
|
parser.push("ALPHA", combine.method.ALPHA.toString());
|
|
}
|
|
if (combine.method.BETA !== undefined) {
|
|
parser.push("BETA", combine.method.BETA.toString());
|
|
}
|
|
if (combine.method.WINDOW !== undefined) {
|
|
parser.push("WINDOW", combine.method.WINDOW.toString());
|
|
}
|
|
}
|
|
if (combine.YIELD_SCORE_AS) {
|
|
parser.push("YIELD_SCORE_AS", combine.YIELD_SCORE_AS);
|
|
}
|
|
}
|
|
function parseApply(parser, apply) {
|
|
parser.push("APPLY", apply.expression);
|
|
if (apply.AS) {
|
|
parser.push("AS", apply.AS);
|
|
}
|
|
}
|
|
function parseHybridOptions(parser, options) {
|
|
parseSearchExpression(parser, options.SEARCH);
|
|
parseVectorExpression(parser, options.VSIM);
|
|
if (options.COMBINE) {
|
|
parseCombineMethod(parser, options.COMBINE);
|
|
}
|
|
if (options.LOAD) {
|
|
if (options.LOAD === "*") {
|
|
parser.push("LOAD", "*");
|
|
}
|
|
else {
|
|
(0, generic_transformers_1.parseOptionalVariadicArgument)(parser, "LOAD", options.LOAD);
|
|
}
|
|
}
|
|
if (options.GROUPBY) {
|
|
(0, generic_transformers_1.parseOptionalVariadicArgument)(parser, "GROUPBY", options.GROUPBY.fields);
|
|
if (options.GROUPBY.REDUCE) {
|
|
const reducers = Array.isArray(options.GROUPBY.REDUCE)
|
|
? options.GROUPBY.REDUCE
|
|
: [options.GROUPBY.REDUCE];
|
|
for (const reducer of reducers) {
|
|
(0, AGGREGATE_1.parseGroupByReducer)(parser, reducer);
|
|
}
|
|
}
|
|
}
|
|
if (options.APPLY) {
|
|
const applies = Array.isArray(options.APPLY)
|
|
? options.APPLY
|
|
: [options.APPLY];
|
|
for (const apply of applies) {
|
|
parseApply(parser, apply);
|
|
}
|
|
}
|
|
if (options.SORTBY) {
|
|
const sortByArgsCount = options.SORTBY.fields.reduce((acc, field) => {
|
|
if (field.direction) {
|
|
return acc + 2;
|
|
}
|
|
return acc + 1;
|
|
}, 0);
|
|
parser.push("SORTBY", sortByArgsCount.toString());
|
|
for (const sortField of options.SORTBY.fields) {
|
|
parser.push(sortField.field);
|
|
if (sortField.direction) {
|
|
parser.push(sortField.direction);
|
|
}
|
|
}
|
|
}
|
|
if (options.NOSORT) {
|
|
parser.push("NOSORT");
|
|
}
|
|
if (options.FILTER) {
|
|
parser.push("FILTER", options.FILTER);
|
|
}
|
|
if (options.LIMIT) {
|
|
parser.push("LIMIT", options.LIMIT.offset.toString(), options.LIMIT.count.toString());
|
|
}
|
|
const hasParams = options.PARAMS && Object.keys(options.PARAMS).length > 0;
|
|
(0, SEARCH_1.parseParamsArgument)(parser, hasParams ? options.PARAMS : undefined);
|
|
if (options.TIMEOUT !== undefined) {
|
|
parser.push("TIMEOUT", options.TIMEOUT.toString());
|
|
}
|
|
}
|
|
exports.default = {
|
|
NOT_KEYED_COMMAND: true,
|
|
IS_READ_ONLY: true,
|
|
/**
|
|
* Performs a hybrid search combining multiple search expressions.
|
|
* Supports multiple SEARCH and VECTOR expressions with various fusion methods.
|
|
*
|
|
* @experimental
|
|
* NOTE: FT.Hybrid is still in experimental state
|
|
* It's behaviour and function signature may change
|
|
*
|
|
* @param parser - The command parser
|
|
* @param index - The index name to search
|
|
* @param options - Hybrid search options including:
|
|
* - SEARCH: Text search expression with optional scoring
|
|
* - VSIM: Vector similarity expression with KNN/RANGE methods
|
|
* - COMBINE: Fusion method (RRF, LINEAR)
|
|
* - Post-processing operations: LOAD, GROUPBY, APPLY, SORTBY, FILTER
|
|
* - Tunable options: LIMIT, PARAMS, TIMEOUT
|
|
*/
|
|
parseCommand(parser, index, options) {
|
|
parser.push("FT.HYBRID", index);
|
|
parseHybridOptions(parser, options);
|
|
},
|
|
transformReply: {
|
|
2: (reply) => {
|
|
return transformHybridSearchResults(reply);
|
|
},
|
|
3: undefined,
|
|
},
|
|
unstableResp3: true,
|
|
};
|
|
function transformHybridSearchResults(reply) {
|
|
// FT.HYBRID returns a map-like structure as flat array:
|
|
// ['total_results', N, 'results', [...], 'warnings', [...], 'execution_time', 'X.XXX']
|
|
const replyMap = parseReplyMap(reply);
|
|
const totalResults = replyMap["total_results"] ?? 0;
|
|
const rawResults = replyMap["results"] ?? [];
|
|
const warnings = replyMap["warnings"] ?? [];
|
|
const executionTime = replyMap["execution_time"]
|
|
? Number.parseFloat(replyMap["execution_time"])
|
|
: 0;
|
|
const results = [];
|
|
for (const result of rawResults) {
|
|
// Each result is a flat key-value array like FT.AGGREGATE: ['field1', 'value1', 'field2', 'value2', ...]
|
|
const resultMap = parseReplyMap(result);
|
|
const doc = Object.create(null);
|
|
// Add all other fields from the result
|
|
for (const [key, value] of Object.entries(resultMap)) {
|
|
if (key === "$") {
|
|
// JSON document - parse and merge
|
|
try {
|
|
Object.assign(doc, JSON.parse(value));
|
|
}
|
|
catch {
|
|
doc[key] = value;
|
|
}
|
|
}
|
|
else {
|
|
doc[key] = value;
|
|
}
|
|
}
|
|
results.push(doc);
|
|
}
|
|
return {
|
|
totalResults,
|
|
executionTime,
|
|
warnings,
|
|
results,
|
|
};
|
|
}
|
|
function parseReplyMap(reply) {
|
|
const map = {};
|
|
if (!Array.isArray(reply)) {
|
|
return map;
|
|
}
|
|
for (let i = 0; i < reply.length; i += 2) {
|
|
const key = reply[i];
|
|
const value = reply[i + 1];
|
|
if (typeof key === "string") {
|
|
map[key] = value;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
//# sourceMappingURL=HYBRID.js.map
|