当前位置: 首页 > ops >正文

搜索体验优化:ABP vNext 的查询改写(Query Rewrite)与同义词治理

🔎 搜索体验优化: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.QueryRewriteModuleDomain/Application/HttpApi/Infrastructure)。

  • 多租户ICurrentTenant 贯穿数据、缓存、指标;缓存键必须包含 TenantId;后台跨租户操作用 ICurrentTenant.Change(...)

  • 存储分层

    • 检索面:Elasticsearch(中文分词 + 同义词 + DSL)
    • 规则面:PostgreSQL(词典/规则/行为聚合)
    • 热数据:Redis(24h CTR / Dwell Top-K;ABP 官方 Redis 模块支持批量 SetManyAsync/GetManyAsync
  • 流程:改写(检索前)→ ES 查询 → 轻量行为重排(可选 RRF 融合多路召回)。

🗺️ 架构总览(Mermaid)

🛡️ 治理后台
ABP 应用层
词典/规则
在线行为
🧭 Rewrite Admin UI
🛠️ QueryRewritePipeline
Normalize→Spell→Synonym→Alias→Rule→Safety
🌐 HttpApi: /rewrite
🔎 ES Query
multi_match + tie_breaker
Elasticsearch
🎯 轻量重排
(CTR/Dwell)
🧑‍💻 用户/前端
✅ 最终结果
🗄️ PostgreSQL
⚡ Redis

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)

TENANTSYNONYM_SETALIASSPELL_LEXICONREWRITE_RULECLICK_LOGownsownsownsownsowns

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();}
}

建议顺序

  1. NormalizeStep(10):全/半角、大小写、标点统一、停用词(按语种/租户)
  2. SpellCorrectStep(20):SymSpell(≤2 编辑距)+ 键邻错(可加拼音/形近)
  3. SynonymStep(30):双向同义/单向归一;标准词高权
  4. AliasStep(40):品牌/部门/SKU 标准化(单向)
  5. BusinessRuleStep(50):类目/意图触发扩展/过滤
  6. SafetyStep(60):黑白名单/敏感词过滤

🔄 管线运行示意

Raw Query
Normalize
Spell Correct
Synonym Expand
Alias Map
Business Rules
Safety
Rewrite Plan
Must / Should / Filters / Boost
ES DSL
multi_match / dis_max

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(文件法)

  1. 更新 analysis/synonyms.txt
  2. 分发到所有数据节点相同路径;
  3. POST /{index}/_reload_search_analyzers
  4. 清理 request cache(如启用);

过滤器需仅用于 search_analyzer,且 updateable:true

Synonyms API(8.11+)

  • 维护“同义词集”,索引引用;发布时 API 生效,便于审计/回滚。

中文分词插件

  • 官方 smartcn;社区 IKstconvert:需按 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 融合示意

向量召回
同义扩展
原词检索
docC rank1
docD rank2
docA rank3
docB rank1
docA rank2
docD rank3
docA rank1
docB rank2
docC rank3
RRF Fuser
融合后排序
(docA/docB/docC/docD)

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) 🛡️

达标
不达标
⚙️ 前置条件
仅 search_analyzer + updateable:true
✏️ 编辑 synonyms.txt
🚚 分发到所有数据节点
🔄 POST /{index}/_reload_search_analyzers
🧹 清理 request cache (可选)
✅ 样例回归测试
📣 灰度/全量上线
↩️ 回滚上一版本

15. 常见坑与规避 ⚠️

  • 方向不当 → 召回污染:型号/品牌优先单向归一
  • 改写过度 → 泛召回:控制 Boost 与条件、必要时转 Should 而非 Must
  • 多租户串线 → 缓存键含租户;跨租户需显式上下文切换
  • 行为偏差 → CTR/Dwell 设上限,冷启回退;日志抽样
  • 中文分词插件 → 与 ES 版本严格匹配,容器镜像固定版本并做 CI
  • 生产安全 → 开启认证/TLS、最小权限、监控与告警

http://www.xdnf.cn/news/18498.html

相关文章:

  • 前端安全之XSS和CSRF
  • 鸿蒙异步处理从入门到实战:Promise、async/await、并发池、超时重试全套攻略
  • 互联网大厂Java面试实战:核心技术栈与场景化提问解析(含Spring Boot、微服务、测试框架等)
  • 量子计算驱动的Python医疗诊断编程前沿展望(中)
  • RabbitMQ面试精讲 Day 28:Docker与Kubernetes部署实践
  • Git checkout 与 Git reset 核心区别解析(分支与版本关联逻辑)
  • 如何在 Spring Boot 中安全读取账号密码等
  • 技术演进中的开发沉思-75 Linux系列:中断和与windows中断的区分
  • 【python与生活】如何自动总结视频并输出一段总结视频?
  • 基于 FastAPI 和 OpenFeature 使用 Feature Flag 控制业务功能
  • Js逆向 拼夕夕anti_content
  • 【读代码】SQLBot:开源自然语言转SQL智能助手原理与实践
  • 怎样避免游戏检测到云手机?
  • 深入浅出:图解 glibc —— 系统与应用的沉默基石
  • 【知识】Elsevier论文接收后的后续流程
  • 可预约体验 | 一句话生成全栈应用,网易CodeWave智能开发能力全新升级!
  • TDengine IDMP 应用场景:工业锅炉监控
  • 资深产品经理个人能力提升方向:如何系统化进阶与考证规划
  • Maven快速入门
  • Day26 树的层序遍历 哈希表 排序算法 内核链表
  • 数据库服务语句应用
  • 【机器学习深度学习】多模态典型任务与应用全景
  • 深入理解Java多线程:状态、安全、同步与通信
  • Trae 编辑器在 Python 环境缺少 Pylance,怎么解决
  • 服务器支持IPv6吗?如何让服务器支持IPv6
  • 爬楼梯变式
  • Unreal Engine ATriggerVolume
  • [TG开发]部署机器人
  • Unreal Engine AActor
  • 【typenum】 22 类型级别二进制对数运算(Logarithm2)