fix: improve redis recommendation fuzzy fallback handling

- Preserve level continuation marker when trimming recommendation terms
- Add group fallback for config and measurement typo inputs
- Validate fuzzy exact continuation with redis set membership
- Use local nspath set for local fuzzy member checks
- Avoid returning continuation marker for exact measurement tag matches
- Add tests for fuzzy continuation and mismatch fallback behavior
This commit is contained in:
douxu 2026-07-10 16:03:16 +08:00
parent 578f805b57
commit a5e8c2ba4e
2 changed files with 230 additions and 8 deletions

View File

@ -418,6 +418,15 @@ func trimRecommendTerms(recommends []string, offset int) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, recommend := range recommends { for _, recommend := range recommends {
if recommend == "." {
if _, exists := seen[recommend]; exists {
continue
}
seen[recommend] = struct{}{}
resultRecommends = append(resultRecommends, recommend)
continue
}
recommendRunes := []rune(recommend) recommendRunes := []rune(recommend)
if offset > len(recommendRunes) { if offset > len(recommendRunes) {
continue continue
@ -530,7 +539,7 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str
searchInput := inputSlice[len(inputSlice)-1] searchInput := inputSlice[len(inputSlice)-1]
matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput)
if len(matchedMembers) == 0 { if len(matchedMembers) == 0 {
return []string{}, 0, nil return fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members)
} }
prefixTokens := inputSlice[:len(inputSlice)-1] prefixTokens := inputSlice[:len(inputSlice)-1]
@ -542,6 +551,16 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str
return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil
} }
func fallbackConfigGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string, members []string) ([]string, int, error) {
recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, members)
recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends)
if len(recommends) == 0 {
return []string{}, 0, nil
}
return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil
}
func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) {
members, err := rdb.SMembers(ctx, allSetKey).Result() members, err := rdb.SMembers(ctx, allSetKey).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
@ -551,7 +570,7 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin
searchInput := inputSlice[len(inputSlice)-1] searchInput := inputSlice[len(inputSlice)-1]
matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput)
if len(matchedMembers) == 0 { if len(matchedMembers) == 0 {
return []string{}, 0, nil return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice)
} }
recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers)
@ -563,6 +582,27 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin
return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil
} }
func fallbackMeasGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string) ([]string, int, error) {
compTag, _, ok := recommendGroupTokens(inputSlice)
if !ok {
return []string{}, 0, nil
}
queryKey := getSpecificKeyByLength(constants.MeasTagRecommendHierarchyType, compTag)
members, err := rdb.SMembers(ctx, queryKey).Result()
if err != nil && err != redis.Nil {
return nil, 0, err
}
recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, members)
recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends)
if len(recommends) == 0 {
return []string{}, 0, nil
}
return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil
}
func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) { func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) {
for len([]rune(searchInput)) != 0 { for len([]rune(searchInput)) != 0 {
filtered := make([]string, 0, len(members)) filtered := make([]string, 0, len(members))
@ -1060,7 +1100,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
if keyExists { if keyExists {
var QueryData []string var QueryData []string
if hierarchy == constants.MaxIdentifyHierarchy || (hierarchy == constants.IdentifyHierarchy && redisSetKey == constants.RedisAllMeasTagSetKey) { if hierarchy == constants.MeasTagRecommendHierarchyType {
QueryData = []string{""} QueryData = []string{""}
} else { } else {
QueryData = []string{"."} QueryData = []string{"."}
@ -1148,6 +1188,10 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err)
} }
if len(results) == 0 { if len(results) == 0 {
if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) {
return []string{"."}, fuzzyRecommendOffset(searchPrefix, searchInput), nil
}
// 如果没有结果,退一步(删除最后一个字符)并继续循环 // 如果没有结果,退一步(删除最后一个字符)并继续循环
// TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式
searchInput = trimLastRune(searchInput) searchInput = trimLastRune(searchInput)
@ -1187,7 +1231,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
} }
if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) { if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) {
exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, memberComparePrefix, termLastPart) exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, termLastPart)
if err != nil || !exists { if err != nil || !exists {
logger.Info(ctx, "fuzzy recommend term not found in specific redis set", logger.Info(ctx, "fuzzy recommend term not found in specific redis set",
"hierarchy", hierarchy, "hierarchy", hierarchy,
@ -1201,6 +1245,11 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
continue continue
} }
if termLastPart == searchInput {
recommends = append(recommends, fuzzyExactContinuationValue(hierarchy))
continue
}
recommend := result.Term recommend := result.Term
if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix { if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix {
recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".")
@ -1209,16 +1258,42 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
} }
} }
if len(recommends) == 0 { if len(recommends) == 0 {
if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) {
return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil
}
searchInput = trimLastRune(searchInput) searchInput = trimLastRune(searchInput)
searchInputLen = len([]rune(searchInput)) searchInputLen = len([]rune(searchInput))
continue continue
} }
return recommends, fuzzyRecommendOffset(searchPrefix, searchInput), nil offset := fuzzyRecommendOffset(searchPrefix, searchInput)
return recommends, offset, nil
} }
return []string{}, 0, nil return []string{}, 0, nil
} }
func fuzzyExactContinuationExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, memberComparePrefix string, searchInput string, fuzzyInput string) bool {
exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput)
if err != nil {
logger.Info(ctx, "fuzzy exact continuation check failed",
"hierarchy", hierarchy,
"recommend_len_type", recommendLenType,
"query_key", fuzzyInput,
"search_input", searchInput,
"error", err,
)
}
return exists
}
func fuzzyExactContinuationValue(hierarchy constants.RecommendHierarchyType) string {
if hierarchy == constants.MeasTagRecommendHierarchyType {
return ""
}
return "."
}
// fuzzyRecommendOffset 返回模糊命中的裁剪位置, 保留完整输入前缀参与 offset。 // fuzzyRecommendOffset 返回模糊命中的裁剪位置, 保留完整输入前缀参与 offset。
func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { func fuzzyRecommendOffset(searchPrefix string, searchInput string) int {
if searchPrefix == "" { if searchPrefix == "" {
@ -1228,8 +1303,8 @@ func fuzzyRecommendOffset(searchPrefix string, searchInput string) int {
} }
// fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。 // fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。
func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, comparePrefix string, termLastPart string) (bool, error) { func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string, termLastPart string) (bool, error) {
setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, comparePrefix) setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, recommendLenType, comparePrefix)
if !ok { if !ok {
return true, nil return true, nil
} }
@ -1237,7 +1312,7 @@ func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarch
} }
// fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。 // fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。
func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comparePrefix string) (string, bool) { func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string) (string, bool) {
switch hierarchy { switch hierarchy {
case constants.GridRecommendHierarchyType: case constants.GridRecommendHierarchyType:
return constants.RedisAllGridSetKey, true return constants.RedisAllGridSetKey, true
@ -1246,6 +1321,9 @@ func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comp
case constants.StationRecommendHierarchyType: case constants.StationRecommendHierarchyType:
return fmt.Sprintf(constants.RedisSpecZoneStationSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" return fmt.Sprintf(constants.RedisSpecZoneStationSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != ""
case constants.CompNSPathRecommendHierarchyType: case constants.CompNSPathRecommendHierarchyType:
if recommendLenType == constants.IsLocalRecommendLength {
return constants.RedisAllCompNSPathSetKey, true
}
return fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" return fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != ""
case constants.CompTagRecommendHierarchyType: case constants.CompTagRecommendHierarchyType:
return fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" return fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != ""

View File

@ -46,6 +46,78 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) {
} }
} }
func TestFuzzyRecommendMemberSetKeyUsesLocalNSPathSet(t *testing.T) {
setKey, ok := fuzzyRecommendMemberSetKey(constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, "")
if !ok {
t.Fatalf("expected local nspath fuzzy member check to use a redis set")
}
if setKey != constants.RedisAllCompNSPathSetKey {
t.Fatalf("expected set key %q, got %q", constants.RedisAllCompNSPathSetKey, setKey)
}
}
func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) {
tests := []struct {
name string
input string
recommendType constants.RecommendHierarchyType
offset int
}{
{
name: "local nspath typo completes current level",
input: "110kV_TV-demoProjectx",
recommendType: constants.CompNSPathRecommendHierarchyType,
offset: len([]rune("110kV_TV-demoProject")),
},
{
name: "grid typo completes current level",
input: "grid000x",
recommendType: constants.GridRecommendHierarchyType,
offset: len([]rune("grid000")),
},
{
name: "zone typo completes current level",
input: "grid000.zone000x",
recommendType: constants.ZoneRecommendHierarchyType,
offset: len([]rune("grid000.zone000")),
},
{
name: "station typo completes current level",
input: "grid000.zone000.station000x",
recommendType: constants.StationRecommendHierarchyType,
offset: len([]rune("grid000.zone000.station000")),
},
{
name: "full nspath typo completes current level",
input: "grid000.zone000.station000.110kV_TV-demoProjectx",
recommendType: constants.CompNSPathRecommendHierarchyType,
offset: len([]rune("grid000.zone000.station000.110kV_TV-demoProject")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := map[string]SearchResult{
tt.recommendType.String(): {
RecommendType: tt.recommendType,
QueryDatas: []string{"."},
IsFuzzy: true,
Offset: tt.offset,
},
}
got := normalizeRecommendResults(tt.input, results)
result := got[tt.recommendType.String()]
if result.Offset != tt.offset {
t.Fatalf("expected offset %d, got %d", tt.offset, result.Offset)
}
if !reflect.DeepEqual(result.QueryDatas, []string{"."}) {
t.Fatalf("expected fuzzy exact continuation '.', got %#v", result.QueryDatas)
}
})
}
}
func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) { func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -350,6 +422,78 @@ func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsist
} }
} }
func TestNormalizeRecommendResultsKeepsConfigMismatchFallbackConsistentWithEmptyInput(t *testing.T) {
prefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV"
members := []string{"bay", "base_extend", "model", "component"}
groupResults := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(prefix+".", "."), members)
offset := recommendPrefixOffset(strings.Split(prefix, "."))
emptyInputResults := map[string]SearchResult{
constants.ConfigRecommendHierarchyType.String(): {
RecommendType: constants.ConfigRecommendHierarchyType,
QueryDatas: append([]string{}, groupResults...),
IsFuzzy: false,
},
}
mismatchFallbackResults := map[string]SearchResult{
constants.ConfigRecommendHierarchyType.String(): {
RecommendType: constants.ConfigRecommendHierarchyType,
QueryDatas: append([]string{}, groupResults...),
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
}
emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults)
mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults)
emptyResult := emptyGot[constants.ConfigRecommendHierarchyType.String()]
mismatchResult := mismatchGot[constants.ConfigRecommendHierarchyType.String()]
if mismatchResult.Offset != emptyResult.Offset {
t.Fatalf("expected config mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset)
}
if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) {
t.Fatalf("expected config mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas)
}
}
func TestNormalizeRecommendResultsKeepsMeasurementMismatchFallbackConsistentWithEmptyInput(t *testing.T) {
prefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay"
members := []string{"IA_rms_CTA-testProject1", "IB_rms_CTA-testProject1", "IC_rms_CTA-testProject1"}
groupResults := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(prefix+".", "."), members)
offset := recommendPrefixOffset(strings.Split(prefix, "."))
emptyInputResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: append([]string{}, groupResults...),
IsFuzzy: false,
},
}
mismatchFallbackResults := map[string]SearchResult{
constants.MeasTagRecommendHierarchyType.String(): {
RecommendType: constants.MeasTagRecommendHierarchyType,
QueryDatas: append([]string{}, groupResults...),
IsFuzzy: true,
IsFallback: true,
Offset: offset,
},
}
emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults)
mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults)
emptyResult := emptyGot[constants.MeasTagRecommendHierarchyType.String()]
mismatchResult := mismatchGot[constants.MeasTagRecommendHierarchyType.String()]
if mismatchResult.Offset != emptyResult.Offset {
t.Fatalf("expected measurement mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset)
}
if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) {
t.Fatalf("expected measurement mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas)
}
}
func TestRecommendGroupTokens(t *testing.T) { func TestRecommendGroupTokens(t *testing.T) {
tests := []struct { tests := []struct {
name string name string