ABP vNext + 多模型搜索:Elasticsearch + 向量(pgvector/Qdrant)混搜网关 🚀
📚 目录
一、为什么要“混搜” 🤔
- 🔍 关键词搜索:BM25 排序算法擅长精准匹配(如产品型号、品牌)。
- 🧠 向量搜索:适合模糊、语义相关的查询(如“像 iPad 的平板”)。
- 💡 混合搜索的优势:通过 RRF(Reciprocal Rank Fusion) 或线性加权,将两种搜索结果融合,可提升 Recall@k 和 NDCG。
- 🧩 ABP 模块化设计:解耦后端搜索实现,支持水平扩展与替换。
二、总体架构与数据流 🏗️
三、技术选型 ⚙️
Elasticsearch 📜
- 🏎️ 精确匹配、聚合、过滤
- 可选
dense_vector + kNN
(本文使用外置向量库)
向量库 📊
- pgvector:适合中等规模数据,部署简单
- Qdrant:原生向量数据库,HNSW 索引性能优越
嵌入模型 🧠
all-MiniLM-L6-v2
(384 维 ONNX)
编排与弹性 🛡️
- Polly v8 Resilience Pipeline:超时、重试、熔断、回退
四、数据建模与索引设计 🗄️
统一文档模型
public record SearchDocument(
Guid Id, string TenantId, string Title, string Content,
string Category, DateTimeOffset UpdatedAt,
float[]? Vector // 由 IEmbeddingService 生成
);
Elasticsearch 索引设计
.Highlight(h => h
.Fields(f => f.Field("content")
.PreTags("<b>").PostTags("</b>")
.FragmentSize(120).NumberOfFragments(1)
)
)
pgvector 索引设计
CREATE INDEX ON doc_vectors
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Qdrant 索引设计
- 使用 Qdrant 的 HNSW 索引并根据
tenantId
和其他业务字段进行 payload 过滤。
五、索引/向量写入一致性 🔄
- 📬 事件源:
DocumentCreated/Updated/Deleted
- ⚙️ 后台作业:拉取 Outbox → 生成向量 → 写 ES + 向量库
- 🛡️ 失败策略:Polly 重试 + 死信队列
六、统一 API 设计(ABP 模块) 🛠️
public interface ISearchProvider {
Task<IReadOnlyList<SearchHit>> SearchAsync(SearchRequest req, CancellationToken ct);
string Name { get; }
}
模块注册
ctx.Services.AddResiliencePipeline("search", builder => builder
.AddTimeout(TimeSpan.FromMilliseconds(300))
.AddRetry(new RetryOptions { MaxRetryAttempts = 2 })
.AddCircuitBreaker(20, TimeSpan.FromSeconds(30))
);
七、融合重排算法 🔄
在混合搜索中,搜索结果来自两个或多个不同的模型(如关键词搜索和向量搜索)。为了最终呈现给用户一个最佳的搜索结果,需要将这些结果进行融合和重排。常见的融合方法有 RRF (Reciprocal Rank Fusion) 和 线性加权,下面将详细介绍这两种方法。
1. Reciprocal Rank Fusion (RRF)
RRF 是一种经典的无监督方法,用于融合多个检索系统的排序结果。其核心思想是通过对每个候选文档的排名(即位次)进行倒数加权,从而平衡不同模型在排序中的影响力。RRF 方法具有较强的鲁棒性,适合不需要大量标注数据的场景。
公式:
假设我们有多个候选文档,每个文档在不同检索模型中都有一个排名。对于文档 h
,其在第 i
个模型中的排名为 i
,则其 RRF 分数 可以通过以下公式计算:
RRF ( h ) = ∑ i = 1 K 1 c + i \text{RRF}(h) = \sum_{i=1}^K \frac{1}{c + i} RRF(h)=i=1∑Kc+i1
其中:
K
是使用的检索模型数。c
是一个常数,通常设置为 60 或者其他合适的数值,用来平衡不同排名的贡献。i
是文档在第i
个检索模型中的排名。
代码实现:
const int k = 60; // 设置常数 c
const int c = 60; // 排名倒数的加权常数
var rr = 1.0 / (c + i + 1); // 计算每个文档在当前模型中的RRF分数
解释:
c
通常设置为 60,是一个经验值。较大的c
值会使得低排名的文档分数更加接近,从而减少高排名文档的权重。你可以根据具体需求调节c
。i
是文档在当前检索模型中的排名,排名越低,分数越小。这个倒数加权的过程确保了更高排名的文档在融合结果中的影响力更大。
优势:
- 无监督:RRF 不依赖标注数据,可以直接使用模型的排名进行融合。
- 简单且有效:其实现简单,且已经被证明对多种检索任务有效。
应用场景:
- 当你有多个检索模型(如 BM25 和向量搜索)时,RRF 是一种非常有效的融合方法,适用于没有训练数据的情况。
2. 线性加权 (Linear Weighting)
线性加权是另一种常见的融合方法,通过对不同模型的结果进行加权平均,将每个模型的贡献进行平衡。这种方法需要人为选择各个模型的权重,通常是根据模型的性能或业务需求来确定。
公式:
假设文档 h
在 BM25 和向量检索中分别有评分 ScoreBM25
和 ScoreVector
,且分别给予它们权重 wBM25
和 wVector
,则文档的融合分数可以通过如下线性加权公式计算:
Score ( h ) = w B M 25 × Score B M 25 ( h ) + w V e c t o r × Score V e c t o r ( h ) \text{Score}(h) = w_{BM25} \times \text{Score}_{BM25}(h) + w_{Vector} \times \text{Score}_{Vector}(h) Score(h)=wBM25×ScoreBM25(h)+wVector×ScoreVector(h)
其中:
wBM25
和wVector
是权重,代表了 BM25 和向量搜索的相对重要性。ScoreBM25
和ScoreVector
是文档在 BM25 和向量搜索中的得分。
代码实现:
double score = 0.55 * h.ScoreBM25 + 0.35 * h.ScoreVector + 0.10 * Recency(h, now);
解释:
ScoreBM25
是文档在 BM25 模型中的评分,ScoreVector
是文档在向量模型中的评分。根据具体的业务需求和模型效果,你可以调节wBM25
和wVector
来控制各模型在最终得分中的影响权重。Recency(h, now)
是对文档新鲜度的加权,这对于某些应用(如新闻搜索或电商推荐)可能很重要。它的计算通常是基于文档的更新时间与当前时间的差异。
优势:
- 易于理解和实现:线性加权是最直观的融合方法,易于理解和实现。
- 灵活:你可以根据实际需求调整每个模型的权重,从而控制每个模型的影响。
应用场景:
- 适用于当你已经有了模型的权重评估,并且希望通过简单的加权方式将其融合的情况。
融合重排的总体流程 🚀
1. 关键词结果和向量结果并行获取:
- BM25 模型用于精准的关键词匹配。
- 向量模型用于获取语义相关的近邻结果。
2. 融合重排:
- 对每个文档,根据 RRF 或 线性加权 方法计算最终得分。
- 可以根据需要为不同的模型或文档属性(如新鲜度)设置不同的权重。
3. 返回统一结果:
- 重排后的结果按得分返回,呈现给用户。
八、过滤、分页与高亮 ✨
- 多租户过滤:
term
filter - 分页优化:
search_after
/scroll
- 高亮:Elasticsearch
highlight
;向量库二次查询生成片段
九、弹性与可靠性 🛡️
- Polly Resilience Pipeline
- 回退策略:向量失败 → 仅关键词;ES 失败 → 仅向量
十、可观测性 📈
- OTel Trace:
SearchGateway.Search
→ES.Query
→Vector.Query
→Rerank
- Metrics:p50/p95、错误率、回退率
- Logs:结构化日志(Serilog + OTel OTLP Sink)
十一、Docker Compose 📦
services:
es:
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
environment:
- discovery.type=single-node
- xpack.security.enabled=false
ports: [ "9200:9200" ]
postgres:
image: pgvector/pgvector:pg16
environment: [ "POSTGRES_PASSWORD=pass" ]
ports: [ "5432:5432" ]
qdrant:
image: qdrant/qdrant:latest
ports: [ "6333:6333", "6334:6334" ]
search-gateway:
build: .
environment:
- ES__Url=http://es:9200
- PG__Conn=Host=postgres;Username=postgres;Password=pass
- QDRANT__Url=http://qdrant:6333
depends_on: [ es, postgres, qdrant ]