🔎 搜索体验优化:ABP vNext 的查询改写(Query Rewrite)与同义词治理
📚 目录
- 🔎 搜索体验优化:ABP vNext 的查询改写(Query Rewrite)与同义词治理
- 1. 背景与问题界定 🧩
- 2. 总体架构(ABP 模块化 + 多租户 + 分层) 🏗️
- 🗺️ 架构总览(Mermaid)
- 3. 数据模型(PostgreSQL,含索引) 🧬
- 🔗 租户关联(Mermaid ER)
- 4. 改写管线(确定性顺序 + 可扩展) 🛠️
- 🔄 管线运行示意
- 5. 拼写纠错(SymSpell + BK-Tree) ✍️
- 6. 同义词/别名治理(方向性 + 热更新) 🧠
- 7. 查询计划 → Elasticsearch DSL(去 `_all`,更稳的 `bool`/`dis_max`) 🧪
- 8. 点击反馈重排(轻量、弱监督、可回退) 🎯
- 🔀 RRF 融合示意
- 9. 多租户与缓存治理(ABP 最佳实践) 🧰
- 10. 评测与观测 📊
- 11. 一键 Compose 🧪
- 12. 关键代码片段 🧩
- 13. 同义词热更新流程(SOP) 🛡️
- 15. 常见坑与规避 ⚠️
1. 背景与问题界定 🧩
痛点:零结果、弱相关、错拼(键邻/近音/形近)、品牌/部门别名不一致(“华为/HUAWEI/华爲”)、多叫法(“摄像头/相机/camera”)。
目标(示例):ZRR 下降 ≥ 30%(📉)、首条点击率 +10%(📈)、二次搜索率 -15%(📉)(以 AB 实验为准)。
约束:多租户隔离、可灰度、可回滚、可观测、可复现。
2. 总体架构(ABP 模块化 + 多租户 + 分层) 🏗️
-
模块:
Search.QueryRewriteModule
(Domain
/Application
/HttpApi
/Infrastructure
)。 -
多租户:
ICurrentTenant
贯穿数据、缓存、指标;缓存键必须包含TenantId
;后台跨租户操作用ICurrentTenant.Change(...)
。 -
存储分层:
- 检索面:Elasticsearch(中文分词 + 同义词 + DSL)
- 规则面:PostgreSQL(词典/规则/行为聚合)
- 热数据:Redis(24h CTR / Dwell Top-K;ABP 官方 Redis 模块支持批量
SetManyAsync/GetManyAsync
)
-
流程:改写(检索前)→ ES 查询 → 轻量行为重排(可选 RRF 融合多路召回)。
🗺️ 架构总览(Mermaid)
3. 数据模型(PostgreSQL,含索引) 🧬
关键修正:
spell_lexicon
主键改为(tenant_id, term)
,避免多租户冲突;为热路径增加索引。
create table synonym_set (tenant_id uuid not null,group_id uuid not null,terms text[] not null,direction smallint not null, -- 0: 双向, 1: 单向 from→toboost double precision not null default 1.0,version int not null default 1,primary key (tenant_id, group_id)
);create table alias (tenant_id uuid not null,entity_type text not null, -- brand/department/skukey text not null,aliases text[] not null,boost double precision not null default 0.8,primary key (tenant_id, entity_type, key)
);create table spell_lexicon (tenant_id uuid not null,term text not null,freq bigint not null default 0,flags int not null default 0,primary key (tenant_id, term)
);create table rewrite_rule (tenant_id uuid not null,id uuid not null,cond_json jsonb not null, -- {"category":"phone"}action_json jsonb not null, -- {"extend":["phone case","保护壳"]}weight double precision not null default 1.0,active_from timestamptz,active_to timestamptz,primary key (tenant_id, id)
);create table click_log (tenant_id uuid not null,query text not null,doc_id text not null,clicked boolean not null,dwell_ms int,ts timestamptz not null default now()
);-- 索引建议
create index idx_syn_terms on synonym_set using gin (terms);
create index idx_alias_key on alias(tenant_id, entity_type, key);
create index idx_spell_term on spell_lexicon(tenant_id, term);
create index idx_click_q_ts on click_log(tenant_id, query, ts desc);
🔗 租户关联(Mermaid ER)
4. 改写管线(确定性顺序 + 可扩展) 🛠️
不依赖 DI 注册顺序。为每个 Step 增加
Order
,在管线里显式排序;或使用 .NET 8 Keyed Services 绑定“管线位点”。
public sealed record RewriteContext(string TenantId, string RawQuery, string? Category);
public sealed record Token(string Term, double Boost = 1.0, string Source = "orig");
public sealed record RewritePlan(string Original,IReadOnlyList<Token> Must, IReadOnlyList<Token> Should, IReadOnlyList<Token> Filters);public interface IRewriteStep {int Order { get; }Task InvokeAsync(RewriteContext ctx, RewritePlanBuilder plan, CancellationToken ct);
}public sealed class QueryRewritePipeline {private readonly IReadOnlyList<IRewriteStep> _steps;public QueryRewritePipeline(IEnumerable<IRewriteStep> steps)=> _steps = steps.OrderBy(s => s.Order).ToArray();public async Task<RewritePlan> ExecuteAsync(RewriteContext ctx, CancellationToken ct) {var b = new RewritePlanBuilder(ctx.RawQuery);foreach (var s in _steps) await s.InvokeAsync(ctx, b, ct);return b.Build();}
}
建议顺序
NormalizeStep
(10):全/半角、大小写、标点统一、停用词(按语种/租户)SpellCorrectStep
(20):SymSpell(≤2 编辑距)+ 键邻错(可加拼音/形近)SynonymStep
(30):双向同义/单向归一;标准词高权AliasStep
(40):品牌/部门/SKU 标准化(单向)BusinessRuleStep
(50):类目/意图触发扩展/过滤SafetyStep
(60):黑白名单/敏感词过滤
🔄 管线运行示意
5. 拼写纠错(SymSpell + BK-Tree) ✍️
- 首选 SymSpell:对称删除,低延迟;支持复合词纠错(空格插/漏)。
- 备选 BK-Tree + Levenshtein:适合小中词表 / 租户私有词。
- 候选排序:
score = α * freqPrior + β * editSim + γ * clickPrior
。 - 中文增强:拼音近音 / 形近字特征作为附加分。
public sealed class SpellCorrectStep : IRewriteStep
{public int Order => 20;private readonly SymSpell _sym;public SpellCorrectStep(SymSpell sym) => _sym = sym;public Task InvokeAsync(RewriteContext ctx, RewritePlanBuilder plan, CancellationToken ct){foreach (var term in plan.CurrentTerms()){var sug = _sym.Lookup(term, Verbosity.Top, maxEditDistance: 2);foreach (var s in sug.Take(3))plan.AddShould(new Token(s.Term, 0.75, "spell"));}return Task.CompletedTask;}
}
6. 同义词/别名治理(方向性 + 热更新) 🧠
- 方向性:双向同义(
手机
⇄移动电话
);单向归一(华为手机
→HUAWEI
)。 - 权重:标准 1.0;别名/口语 0.6–0.8;冷门 0.3–0.5。
- 冲突检测:同词多组 / 循环引用 / 相互否定。
- ES 配置:
synonym_graph
(多词同义)或synonym
;仅用于 search analyzer 的过滤器可设"updateable": true
;ES 8.11+ 可用 Synonyms API。
可运维的 search analyzer 示例
{"settings": {"analysis": {"filter": {"zh_syn": {"type": "synonym_graph","synonyms_path": "analysis/synonyms.txt","updateable": true}},"analyzer": {"zh_search": {"tokenizer": "standard","filter": [ "lowercase", "zh_syn" ]}}}},"mappings": {"properties": {"title": { "type": "text", "analyzer": "ik_smart", "search_analyzer": "zh_search" },"content": { "type": "text", "analyzer": "ik_smart", "search_analyzer": "zh_search" }}}
}
热更新 SOP(文件法)
- 更新
analysis/synonyms.txt
; - 分发到所有数据节点相同路径;
POST /{index}/_reload_search_analyzers
;- 清理 request cache(如启用);
过滤器需仅用于 search_analyzer,且
updateable:true
。
Synonyms API(8.11+)
- 维护“同义词集”,索引引用;发布时 API 生效,便于审计/回滚。
中文分词插件
- 官方 smartcn;社区 IK、stconvert:需按 ES 版本安装,自定义镜像并做回归;注意许可证与兼容性。
7. 查询计划 → Elasticsearch DSL(去 _all
,更稳的 bool
/dis_max
) 🧪
_all
自 ES 6 移除;推荐multi_match
多字段检索(carets 权重,如"title^2"
),或 mapping 用copy_to
。
中文较多、跨字段词项合并时可选CrossFields
;英文/短词可用BestFields
。
.NET(Elastic.Clients.Elasticsearch v8)示例:原词(Must) + 改写(Should + MinimumShouldMatch + dis_max)
同时传入 CancellationToken;当改写项较多时,设置
.MinimumShouldMatch(1)
可降噪。
var resp = await _es.SearchAsync<MyDoc>(s => s.Index("docs").Size(20).TrackTotalHits(true).Query(q => q.Bool(b => b.Must(m => m.MultiMatch(mm => mm.Query(ctx.RawQuery).Fields(new[] { "title^2", "content" }).Type(TextQueryType.BestFields) // 或 CrossFields 视语种/策略.TieBreaker(0.3))).Should(plan.Should.Select(t =>(Func<QueryDescriptor<MyDoc>, IQuery>)(sd => sd.DisMax(dx => dx.Queries(dq => dq.MultiMatch(mm => mm.Query(t.Term).Fields(new[] { "title^2", "content" }).Boost((float)t.Boost).Type(TextQueryType.BestFields)))))).ToArray()).MinimumShouldMatch(1) // 关键:至少命中一个改写项)), ct);
8. 点击反馈重排(轻量、弱监督、可回退) 🎯
信号:CTR、Dwell(停留)、跳出、二次搜索
原则:行为信号视为弱监督;设上限与冷启回退;阈值/权重可配置。
键空间降基数:查询做归一化+哈希存储(例如 SHA-256 前 12 字符),降低高基数键风险。
private static string NormalizeQuery(string q)=> q.Trim().ToLowerInvariant(); // 可叠加全/半角、标点等
private static string Hash12(string s)
{using var sha = System.Security.Cryptography.SHA256.Create();var b = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s));return BitConverter.ToString(b).Replace("-", "").Substring(0, 12).ToLowerInvariant();
}
private static string BehaviorKey(string tenantId, string q)=> $"behavior:{tenantId}:{Hash12(NormalizeQuery(q))}";async Task<double> BehaviorFactorAsync(string tenantId, string docId, string query, IConnectionMultiplexer mux,double lambda = 0.2, double maxBoost = 0.5, ILogger? logger = null)
{try{var db = mux.GetDatabase();var key = BehaviorKey(tenantId, query);var score = await db.SortedSetScoreAsync(key, docId).ConfigureAwait(false);return score is double s ? 1.0 + lambda * Math.Min(s, maxBoost) : 1.0; // 冷启回退}catch (Exception ex){logger?.LogWarning(ex, "Redis 行为分读取失败, 使用回退");return 1.0; // 异常回退}
}// 并发收集 + 应用
var factors = await Task.WhenAll(results.Select(async r =>(r.DocId, Factor: await BehaviorFactorAsync(ctx.TenantId, r.DocId, ctx.RawQuery, mux, logger:_logger))));
var factorMap = factors.ToDictionary(x => x.DocId, x => x.Factor);
foreach (var r in results) r.FinalScore = r.EsScore * factorMap[r.DocId];
results = results.OrderByDescending(x => x.FinalScore).ToList();
🔀 RRF 融合示意
9. 多租户与缓存治理(ABP 最佳实践) 🧰
[DependsOn(typeof(AbpAspNetCoreMvcModule),typeof(AbpDddApplicationModule),typeof(AbpCachingStackExchangeRedisModule) // 官方 Redis 模块
)]
public sealed class SearchQueryRewriteModule : AbpModule { /* ... */ }
- 多租户上下文:统一用
ICurrentTenant
;日志与 UI 默认不外泄TenantId
(除审计场景)。 - 缓存键规范:
{env}:{tenantId}:{module}:{category}:{key}
;配置热更新需按租户清理键空间。 - 批量缓存:优先
IDistributedCache<T>.SetManyAsync/GetManyAsync
,减少 RTT。
10. 评测与观测 📊
- 离线:ZRR、Recall@k、NDCG@k(小标注集)
- 在线:首条点击率、二次搜索率、平均停留、改写命中率
- 可视化:改写命中、词典版本/灰度进度、规则冲突、重排提升分布
- 验收(示例目标,7 天):ZRR ≥ -30%;首条点击率 ≥ +10%;二次搜索率 ≥ -15%(租户/类目分桶 + 显著性检验)
📌 指标为示例目标,以实际 AB 实验结论为准。
11. 一键 Compose 🧪
services:es:image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4environment:- discovery.type=single-node- xpack.security.enabled=false # ⚠️ 仅限本地演示!ports: ["9200:9200"]redis:image: redis:7ports: ["6379:6379"]postgres:image: postgres:16environment: ["POSTGRES_PASSWORD=pass"]ports: ["5432:5432"]rewrite:build: ./QueryRewriteModuleenvironment:- ConnectionStrings__Default=Host=postgres;Database=qrw;Username=postgres;Password=pass- Redis__Configuration=redis:6379- Elastic__Url=http://es:9200depends_on: [ es, redis, postgres ]ports: ["8080:8080"]admin:build: ./RewriteAdminUIports: ["5173:80"]
🔐 生产安全基线:开启
xpack.security
、设置内建用户密码、TLS、最小权限账号(只读/写入分离),并配置监控与告警。
🧩 中文分词插件:smartcn/IK/stconvert 与 ES 版本严格匹配;建议自定义镜像并做 CI 回归。
12. 关键代码片段 🧩
Normalize(含全/半角)
public sealed class NormalizeStep : IRewriteStep
{public int Order => 10;public Task InvokeAsync(RewriteContext ctx, RewritePlanBuilder plan, CancellationToken ct){var q = plan.Original.Trim().Normalize(NormalizationForm.FormKC);q = ToHalfWidth(q).ToLowerInvariant();plan.ReplaceCurrent(q);return Task.CompletedTask;}private static string ToHalfWidth(string s) =>string.Concat(s.Select(c => c == '\u3000' ? ' ' :(c >= 0xFF01 && c <= 0xFF5E) ? (char)(c - 0xFEE0) : c));
}
同义词 Step(节选)
public sealed class SynonymStep : IRewriteStep {public int Order => 30;private readonly ISynonymStore _store;public SynonymStep(ISynonymStore store) => _store = store;public async Task InvokeAsync(RewriteContext ctx, RewritePlanBuilder plan, CancellationToken ct) {foreach (var term in plan.CurrentTerms()) {var syns = await _store.LookupAsync(ctx.TenantId, term, ct);foreach (var s in syns) plan.AddShould(new Token(s.Term, s.Boost, "syn"));}}
}
Elasticsearch 查询(Multi-match + dis_max + MinimumShouldMatch + CT)
var resp = await _es.SearchAsync<MyDoc>(s => s.Index("docs").Size(20).TrackTotalHits(true).Query(q => q.Bool(b => b.Must(m => m.MultiMatch(mm => mm.Query(ctx.RawQuery).Fields(new[] { "title^2", "content" }).Type(TextQueryType.BestFields) // 或 CrossFields.TieBreaker(0.3))).Should(plan.Should.Select(t =>(Func<QueryDescriptor<MyDoc>, IQuery>)(sd => sd.DisMax(dx => dx.Queries(dq => dq.MultiMatch(mm => mm.Query(t.Term).Fields(new[] { "title^2", "content" }).Boost((float)t.Boost).Type(TextQueryType.BestFields)))))).ToArray()).MinimumShouldMatch(1))), ct);
在线行为重排(并发收集 + 键降基数 + 异常回退)
// 见第 8 节完整实现
13. 同义词热更新流程(SOP) 🛡️
15. 常见坑与规避 ⚠️
- 方向不当 → 召回污染:型号/品牌优先单向归一
- 改写过度 → 泛召回:控制 Boost 与条件、必要时转 Should 而非 Must
- 多租户串线 → 缓存键含租户;跨租户需显式上下文切换
- 行为偏差 → CTR/Dwell 设上限,冷启回退;日志抽样
- 中文分词插件 → 与 ES 版本严格匹配,容器镜像固定版本并做 CI
- 生产安全 → 开启认证/TLS、最小权限、监控与告警