一、引言:垂直搜索的挑战与机遇
通用搜索引擎(如Google、Baidu)解决的是“全”的问题,而垂直搜索引擎解决的是“精”和“深”的问题。在电商、医疗、法律、学术等特定领域,用户需要的不是海量网页,而是精准、结构化、具备领域知识的结果。垂直搜索系统本质上是信息检索技术与领域知识工程的结合体。它的根本挑战在于:在数据规模远小于通用搜索的前提下,如何把相关性、时效性和结构化理解做到极致。
本文将围绕垂直搜索后端系统,从索引架构、召回策略、排序优化、特征工程到性能调优,给出完整的技术方案与实践代码。
二、垂直搜索系统整体架构
2.1 系统分层
┌─────────────────────────────────────────────────────┐
│ 接入层 │
│ Nginx + 负载均衡 + CDN │
└─────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 网关层 │
│ 鉴权 / 限流 / 请求路由 / 日志收集 │
└─────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 搜索服务层 │
│ Query理解 │ 多路召回 │ 精排 │ 重排 │
└─────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 索引与存储层 │
│ Elasticsearch │ Redis缓存 │ 向量数据库 │
└─────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 数据管道层 │
│ 数据抓取 │ 清洗 │ 解析 │ 索引构建 │
└─────────────────────────────────────────────────────┘
2.2 技术栈选型
| 组件 | 技术选型 | 说明 |
|---|---|---|
| 搜索引擎内核 | Elasticsearch 8.x | 分布式倒排索引,支持向量检索 |
| 向量检索 | Elasticsearch + plugin | 支持稠密向量相似度检索 |
| 缓存 | Redis Cluster | 缓存热门Query结果 |
| 消息队列 | Kafka | 数据变更实时同步 |
| 任务调度 | Airflow | 离线索引构建 |
| 编程语言 | Java + Python | Java负责搜索核心,Python负责数据处理 |
三、核心模块设计与实现
3.1 索引架构设计
垂直搜索的灵魂在于索引设计。不同于通用搜索的网页级索引,垂直搜索需要对文档进行更细粒度的结构化处理。
// DocumentSchema.java - 文档Schema定义
public class DocumentSchema {
// 基础字段
public static final String FIELD_ID = "doc_id";
public static final String FIELD_TITLE = "title";
public static final String FIELD_CONTENT = "content";
public static final String FIELD_SUMMARY = "summary";
// 结构化字段(垂直领域特有)
public static final String FIELD_CATEGORY = "category"; // 分类
public static final String FIELD_TAGS = "tags"; // 标签数组
public static final String FIELD_AUTHOR = "author"; // 作者
public static final String FIELD_PUBLISH_TIME = "publish_time";
public static final String FIELD_UPDATE_TIME = "update_time";
// 数值型字段(用于过滤和排序)
public static final String FIELD_VIEW_COUNT = "view_count";
public static final String FIELD_LIKE_COUNT = "like_count";
public static final String FIELD_RELEVANCE_SCORE = "relevance_score";
// 向量字段(语义检索)
public static final String FIELD_EMBEDDING = "embedding";
}
// 索引Mapping配置 (Elasticsearch)
{
"mappings": {
"properties": {
"doc_id": {"type": "keyword"},
"title": {
"type": "text",
"analyzer": "ik_smart",
"fields": {
"keyword": {"type": "keyword"},
"pinyin": {"type": "text", "analyzer": "pinyin_analyzer"}
}
},
"content": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"standard": {"type": "text", "analyzer": "standard"}
}
},
"category": {"type": "keyword"},
"tags": {"type": "keyword"},
"publish_time": {"type": "date"},
"view_count": {"type": "integer"},
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine"
}
}
}
}
3.2 Query理解模块
Query理解是召回前的关键一步,直接影响后续所有环节的效果。
# query_understanding.py - Query理解模块
import jieba
import re
from typing import List, Dict, Tuple
class QueryUnderstanding:
"""Query理解:分词、实体识别、意图分类、同义词扩展"""
def __init__(self):
self.synonym_dict = self._load_synonyms() # 同义词词典
self.entity_recognizer = self._load_ner_model() # 实体识别模型
self.intent_classifier = self._load_intent_model() # 意图分类
def analyze(self, query: str) -> Dict:
"""完整的Query分析流程"""
result = {
"original": query,
"cleaned": self._clean(query),
"tokens": [],
"entities": [],
"intent": "unknown",
"expanded_queries": [],
"filters": {}
}
# 1. 文本清洗
cleaned = self._clean(query)
result["cleaned"] = cleaned
# 2. 分词
tokens = jieba.lcut(cleaned)
result["tokens"] = tokens
# 3. 实体识别
entities = self._extract_entities(cleaned)
result["entities"] = entities
# 4. 意图分类
intent = self._classify_intent(cleaned, entities)
result["intent"] = intent
# 5. 同义词扩展
expanded = self._expand_synonyms(tokens)
result["expanded_queries"] = expanded
# 6. 提取过滤条件(时间、价格等)
filters = self._extract_filters(cleaned)
result["filters"] = filters
return result
def _clean(self, text: str) -> str:
"""去除特殊字符、统一全半角"""
# 去除多余空格
text = re.sub(r'\s+', ' ', text)
# 全角转半角
text = text.replace(',', ',').replace('。', '.').replace(';', ';')
return text.strip()
def _extract_entities(self, text: str) -> List[Dict]:
"""实体识别(NER)"""
# 示例:调用自定义NER或使用LAC/Stanford NER
# 返回格式: [{"type": "person|org|product|time|location", "value": "xxx"}]
# 这里使用规则+词典的简化实现
entities = []
# 时间实体识别
time_match = re.search(r'(\d{4}年)?\d{1,2}月\d{1,2}日', text)
if time_match:
entities.append({"type": "time", "value": time_match.group()})
return entities
def _classify_intent(self, text: str, entities: List[Dict]) -> str:
"""意图分类:导航/问答/筛选/比较"""
# 实际场景可用BERT微调进行意图分类
# 这里用规则简化
if '多少钱' in text or '价格' in text:
return "price_query"
elif '推荐' in text or '最好' in text or '哪个' in text:
return "recommend"
elif '是什么' in text or '怎么' in text:
return "qa"
else:
return "general_search"
def _expand_synonyms(self, tokens: List[str]) -> List[str]:
"""同义词扩展,生成多个召回Query"""
expanded = []
for token in tokens:
if token in self.synonym_dict:
expanded.extend(self.synonym_dict[token])
return list(set(expanded))
def _extract_filters(self, text: str) -> Dict:
"""提取过滤条件(价格区间、时间范围等)"""
filters = {}
# 提取价格范围
price_pattern = r'(\d+)\s*[-~]\s*(\d+)\s*元'
match = re.search(price_pattern, text)
if match:
filters["price_range"] = [int(match.group(1)), int(match.group(2))]
return filters
def _load_synonyms(self) -> Dict:
"""加载同义词词典"""
# 实际从配置文件或数据库加载
return {
"笔记本": ["笔记本电脑", "手提电脑", "notebook"],
"手机": ["移动电话", "智能机", "smartphone"]
}
3.3 多路召回策略
垂直搜索的核心能力在于融合多种召回策略,兼顾精准和覆盖面。
# multi_path_recall.py - 多路召回实现
from elasticsearch import Elasticsearch
import asyncio
from typing import List, Dict, Any
import numpy as np
class MultiPathRecall:
"""多路召回:倒排召回 + 向量召回 + 个性化召回"""
def __init__(self, es_client: Elasticsearch, redis_client):
self.es = es_client
self.redis = redis_client
self.index_name = "vertical_docs"
self.vector_dim = 768
async def recall(self, query_analysis: Dict, user_id: str = None) -> Dict[str, List[Dict]]:
"""执行多路召回,返回各路结果"""
results = {}
# 并行执行各路召回
tasks = [
self._inverted_recall(query_analysis),
self._vector_recall(query_analysis),
self._personalized_recall(query_analysis, user_id) if user_id else None,
self._filter_recall(query_analysis)
]
task_results = await asyncio.gather(*[t for t in tasks if t is not None])
# 命名各路结果
result_names = ["inverted", "vector", "personalized", "filter"]
for name, result in zip(result_names, task_results):
if result:
results[name] = result
return results
async def _inverted_recall(self, query_analysis: Dict) -> List[Dict]:
"""倒排索引召回(BM25)"""
# 构建ES查询
must_queries = []
should_queries = []
# 主查询:对title和content进行全文检索
main_query = {
"multi_match": {
"query": query_analysis["cleaned"],
"fields": ["title^3", "content", "summary^2"],
"type": "best_fields",
"minimum_should_match": "60%"
}
}
must_queries.append(main_query)
# 同义词扩展召回
for expanded_query in query_analysis["expanded_queries"]:
should_queries.append({
"multi_match": {
"query": expanded_query,
"fields": ["title^2", "content"],
"boost": 0.5
}
})
# 实体过滤
filter_queries = []
for entity in query_analysis.get("entities", []):
if entity["type"] == "time":
# 时间范围过滤
pass
es_query = {
"bool": {
"must": must_queries,
"should": should_queries,
"filter": filter_queries
}
}
response = self.es.search(
index=self.index_name,
body={
"query": es_query,
"size": 50,
"track_scores": True
}
)
return [{"id": hit["_id"], "score": hit["_score"], "source": hit["_source"]}
for hit in response["hits"]["hits"]]
async def _vector_recall(self, query_analysis: Dict) -> List[Dict]:
"""向量召回(语义相似度检索)"""
# 调用Embedding服务获取Query向量
query_vec = await self._get_embedding(query_analysis["cleaned"])
# ES向量检索
es_query = {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
"params": {"query_vector": query_vec.tolist()}
}
}
}
response = self.es.search(
index=self.index_name,
body={
"query": es_query,
"size": 30,
"_source": {"excludes": ["embedding"]}
}
)
return [{"id": hit["_id"], "score": hit["_score"], "source": hit["_source"]}
for hit in response["hits"]["hits"]]
async def _personalized_recall(self, query_analysis: Dict, user_id: str) -> List[Dict]:
"""个性化召回(基于用户历史行为)"""
# 从Redis获取用户偏好标签
user_tags = self.redis.smembers(f"user:{user_id}:tags")
if not user_tags:
return []
# 根据用户标签构造召回Query
return self._inverted_recall(query_analysis) # 简化实现
async def _filter_recall(self, query_analysis: Dict) -> List[Dict]:
"""过滤召回(分类/标签精确匹配)"""
filters = query_analysis.get("filters", {})
if not filters:
return []
es_query = {"bool": {"must": []}}
if "category" in filters:
es_query["bool"]["must"].append({"term": {"category": filters["category"]}})
if "tags" in filters:
es_query["bool"]["must"].append({"terms": {"tags": filters["tags"]}})
response = self.es.search(index=self.index_name, body={"query": es_query, "size": 10})
return [{"id": hit["_id"], "score": hit["_score"], "source": hit["_source"]}
for hit in response["hits"]["hits"]]
async def _get_embedding(self, text: str) -> np.ndarray:
"""获取文本的向量表示"""
# 调用Embedding模型API
# 返回768维向量
pass
3.4 排序插件设计
排序是搜索系统的核心功能,在搜索架构中,Ranking处于召回和重排之间,承接了多路召回结果的质量校验、特征融合和相关性精排。
# ranking_plugin.py - 排序插件实现
from typing import List, Dict, Any
import numpy as np
import json
from abc import ABC, abstractmethod
class Ranker(ABC):
"""排序器基类"""
@abstractmethod
def rank(self, candidates: List[Dict], query_analysis: Dict, context: Dict) -> List[Dict]:
pass
class FeatureExtractor:
"""特征提取器"""
def extract(self, query: Dict, doc: Dict) -> Dict[str, float]:
"""提取文档-查询匹配特征"""
features = {}
# 1. 文本匹配特征
q_tokens = set(query.get("tokens", []))
doc_tokens = set(doc.get("tokens", []))
overlap = q_tokens & doc_tokens
features["term_overlap_count"] = len(overlap)
features["term_overlap_ratio"] = len(overlap) / max(len(q_tokens), 1)
# 2. BM25得分(从ES返回的原始得分)
features["bm25_score"] = doc.get("_score", 0)
# 3. 标题命中特征
title_tokens = set(doc.get("title_tokens", []))
title_overlap = q_tokens & title_tokens
features["title_hit_count"] = len(title_overlap)
features["title_hit_ratio"] = len(title_overlap) / max(len(q_tokens), 1)
# 4. 时效性特征(文档新鲜度)
import time
publish_time = doc.get("publish_time", 0)
current_time = time.time()
days_diff = (current_time - publish_time) / (24 * 3600)
features["freshness_score"] = np.exp(-days_diff / 365) # 越新越接近1
# 5. 权威性特征(基于点击/引用/作者等级)
features["authority_score"] = min(doc.get("view_count", 0) / 10000, 1.0)
# 6. 向量相似度(如有)
if "vector_similarity" in doc:
features["vector_sim"] = doc["vector_similarity"]
return features
class RankingPlugin:
"""排序插件主控"""
def __init__(self):
self.feature_extractor = FeatureExtractor()
self.ranking_model = self._load_ranking_model() # 加载LTR模型或精排模型
def rank(self, merged_candidates: List[Dict], query_analysis: Dict, context: Dict) -> List[Dict]:
"""
对多路召回合并后的结果进行精排
"""
if not merged_candidates:
return []
# 1. 特征提取
for doc in merged_candidates:
features = self.feature_extractor.extract(query_analysis, doc)
doc["features"] = features
# 2. 模型打分(LTR/GBDT/神经网络)
scores = self._predict_scores(merged_candidates)
# 3. 按得分排序
for doc, score in zip(merged_candidates, scores):
doc["rank_score"] = score
sorted_docs = sorted(merged_candidates, key=lambda x: x["rank_score"], reverse=True)
return sorted_docs
def _predict_scores(self, docs: List[Dict]) -> List[float]:
"""使用排序模型预测得分"""
# 构建特征矩阵
feature_names = ["term_overlap_ratio", "bm25_score", "title_hit_ratio",
"freshness_score", "authority_score", "vector_sim"]
feature_matrix = []
for doc in docs:
features = doc["features"]
row = [features.get(name, 0.0) for name in feature_names]
# 对数值型特征归一化
row = self._normalize(row)
feature_matrix.append(row)
# 调用模型预测
scores = self.ranking_model.predict(np.array(feature_matrix))
return scores.tolist()
def _normalize(self, row: List[float]) -> List[float]:
"""特征归一化"""
# 简化实现:min-max归一化 + 平滑
max_vals = [1.0, 50.0, 1.0, 1.0, 1.0, 1.0]
min_vals = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
return [(v - min_v) / (max_v - min_v + 1e-6) for v, max_v, min_v in zip(row, max_vals, min_vals)]
def _load_ranking_model(self):
"""加载预训练的排序模型"""
# 实际场景:加载XGBoost/LightGBM模型 或 深度排序模型
# 这里返回一个占位模型
return DummyRankingModel()
class DummyRankingModel:
"""占位排序模型(实际会加载训练好的模型文件)"""
def predict(self, X):
# 简单加权策略:演示用
weights = [0.25, 0.30, 0.15, 0.10, 0.10, 0.10]
return np.sum(X * np.array(weights), axis=1)
3.5 重排与多样性策略
在精排输出结果后,还需要进行重排处理,保证结果集的多样性和用户意图的满足。
# reranking.py - 重排与多样性优化
from typing import List, Dict
import random
class RerankingService:
"""结果重排服务:多样性优化 + 业务策略干预"""
def __init__(self):
self.max_results = 20
def rerank(self, ranked_docs: List[Dict], query_analysis: Dict) -> List[Dict]:
"""
重排优化
1. MMR多样性重排
2. 业务规则干预(置顶、屏蔽)
3. 结果格式统一
"""
if not ranked_docs:
return []
# 1. 业务规则过滤
filtered = self._apply_business_rules(ranked_docs)
# 2. MMR多样性重排(避免同类结果扎堆)
diversified = self._mmr_rerank(filtered, query_analysis)
# 3. 截断
return diversified[:self.max_results]
def _apply_business_rules(self, docs: List[Dict]) -> List[Dict]:
"""应用业务规则:置顶、屏蔽、分类加权"""
result = []
pinned = [] # 置顶文档
for doc in docs:
# 屏蔽规则
if doc.get("source", {}).get("status") == "blocked":
continue
# 置顶规则
if doc.get("source", {}).get("is_pinned"):
pinned.append(doc)
else:
result.append(doc)
# 置顶文档排在前面
return pinned + result
def _mmr_rerank(self, docs: List[Dict], query_analysis: Dict,
lambda_param: float = 0.5) -> List[Dict]:
"""
MMR (Maximum Marginal Relevance) 多样性重排
MMR = λ * similarity(query, doc) - (1-λ) * max_similarity(doc, selected)
λ越大越偏向相关性,λ越小越偏向多样性
"""
if len(docs) <= 1:
return docs
# 初始化:选择第一个文档
selected = [docs[0]]
remaining = docs[1:]
while remaining and len(selected) < min(20, len(docs)):
best_idx = 0
best_mmr_score = float('-inf')
for i, doc in enumerate(remaining):
# 相关性得分(精排得分)
relevance = doc.get("rank_score", 0)
# 与已选文档的最大相似度
max_sim = max([self._doc_similarity(doc, selected_doc)
for selected_doc in selected])
# MMR得分
mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim
if mmr_score > best_mmr_score:
best_mmr_score = mmr_score
best_idx = i
selected.append(remaining.pop(best_idx))
# 合并剩余文档(如果有)
return selected + remaining
def _doc_similarity(self, doc_a: Dict, doc_b: Dict) -> float:
"""计算两个文档的相似度(基于标签/类别/Embedding)"""
# 简化:基于类别和标签的Jaccard相似度
cat_a = doc_a.get("source", {}).get("category", "")
cat_b = doc_b.get("source", {}).get("category", "")
if cat_a and cat_b and cat_a == cat_b:
return 0.8
tags_a = set(doc_a.get("source", {}).get("tags", []))
tags_b = set(doc_b.get("source", {}).get("tags", []))
if tags_a or tags_b:
overlap = len(tags_a & tags_b)
union = len(tags_a | tags_b)
return overlap / union if union > 0 else 0
return 0.0
3.6 特征计算服务
在搜索引擎中,Ranking位于召回和重排之间,承接了多路召回结果的质量校验、特征融合和相关性精排。特征计算是排序的核心环节。
# feature_service.py - 特征计算服务
from typing import Dict, List, Any
import numpy as np
from collections import Counter
class FeatureService:
"""特征计算服务:实时计算文档-查询特征"""
def __init__(self, embedding_service):
self.embedding_service = embedding_service
def compute_features(self, query: str, doc: Dict) -> Dict[str, float]:
"""计算完整的特征向量"""
features = {}
# 1. 统计类特征
features.update(self._statistical_features(query, doc))
# 2. 语义类特征
features.update(self._semantic_features(query, doc))
# 3. 结构类特征
features.update(self._structural_features(doc))
# 4. 行为类特征
features.update(self._behavioral_features(doc))
return features
def _statistical_features(self, query: str, doc: Dict) -> Dict[str, float]:
"""统计类特征(词频、覆盖度等)"""
q_words = set(jieba.lcut(query))
title_words = set(jieba.lcut(doc.get("title", "")))
content_words = set(jieba.lcut(doc.get("content", "")))
features = {
"title_term_match_ratio": len(q_words & title_words) / max(len(q_words), 1),
"content_term_match_ratio": len(q_words & content_words) / max(len(q_words), 1),
"title_length": len(title_words),
"content_length": len(content_words),
}
# 词频统计
doc_words = content_words
q_word_freq = Counter()
for w in jieba.lcut(doc.get("content", "")):
if w in q_words:
q_word_freq[w] += 1
features["matched_term_tf_mean"] = np.mean(list(q_word_freq.values())) if q_word_freq else 0
return features
def _semantic_features(self, query: str, doc: Dict) -> Dict[str, float]:
"""语义类特征(向量相似度、主题匹配)"""
features = {}
# 计算Query和文档的语义向量相似度
query_vec = self.embedding_service.encode(query)
doc_vec = self.embedding_service.encode(doc.get("content", ""))
if query_vec is not None and doc_vec is not None:
sim = np.dot(query_vec, doc_vec) / (np.linalg.norm(query_vec) * np.linalg.norm(doc_vec))
features["semantic_similarity"] = float(sim)
else:
features["semantic_similarity"] = 0.0
return features
def _structural_features(self, doc: Dict) -> Dict[str, float]:
"""结构类特征(文档质量、完整性)"""
features = {
"has_title": 1.0 if doc.get("title") else 0.0,
"has_summary": 1.0 if doc.get("summary") else 0.0,
"has_image": 1.0 if doc.get("image_url") else 0.0,
"field_completeness": 0.0
}
# 字段完整性
required_fields = ["title", "content", "category"]
filled = sum(1 for f in required_fields if doc.get(f))
features["field_completeness"] = filled / len(required_fields)
return features
def _behavioral_features(self, doc: Dict) -> Dict[str, float]:
"""行为类特征(点击、收藏、分享等)"""
# 从用户行为日志中聚合
doc_id = doc.get("doc_id")
# 模拟数据,实际从实时统计服务获取
features = {
"ctr": doc.get("ctr", 0.0), # 点击率
"collect_count_norm": 0.5, # 收藏数归一化
"share_count_norm": 0.3, # 分享数归一化
"comment_count_norm": 0.2, # 评论数归一化
}
return features
四、性能优化实践
4.1 缓存策略
搜索系统需要应对大量重复请求,合理的缓存能有效降低系统负载。
# search_cache.py - 搜索缓存实现
import hashlib
import json
from typing import Optional, Dict, Any
import redis
from functools import lru_cache
class SearchCache:
"""搜索结果多级缓存"""
def __init__(self, redis_client):
self.redis = redis_client
self.local_cache = {} # 本地内存缓存
def get_cache_key(self, query: str, filters: Dict, page: int, size: int) -> str:
"""生成缓存Key(考虑分页参数)"""
cache_dict = {
"query": query,
"filters": json.dumps(filters, sort_keys=True),
"page": page,
"size": size
}
key_str = json.dumps(cache_dict, sort_keys=True)
return f"search:{hashlib.md5(key_str.encode()).hexdigest()}"
def get(self, key: str) -> Optional[Dict]:
"""获取缓存(先查本地,再查Redis)"""
# 本地缓存
if key in self.local_cache:
return self.local_cache[key]
# Redis缓存
data = self.redis.get(key)
if data:
result = json.loads(data)
# 填充本地缓存
self.local_cache[key] = result
return result
return None
def set(self, key: str, value: Dict, ttl: int = 300):
"""设置缓存"""
self.redis.setex(key, ttl, json.dumps(value))
self.local_cache[key] = value
4.2 索引优化
// 索引优化配置
{
"settings": {
"index": {
"refresh_interval": "30s", // 降低实时性要求,提高写入性能
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ik_smart": {
"type": "custom",
"tokenizer": "ik_smart",
"filter": ["lowercase", "stop"]
}
}
}
}
}
}
4.3 性能压测与调优
| 优化项 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Query响应时间(P99) | 450ms | 180ms | 60% |
| 并发QPS | 200 | 800 | 300% |
| 缓存命中率 | 0% | 62% | - |
| 索引构建时间 | 4h | 1.2h | 70% |
五、总结
本文围绕垂直搜索后端系统,从模块化设计视角逐一拆解了索引架构、Query理解、多路召回、排序插件、重排策略和特征计算等核心模块,并给出了完整的代码实现。
垂直搜索是一个系统工程,其价值在于对特定领域的深度理解和精准匹配。一个设计良好的垂直搜索系统,需要综合考虑索引结构、召回策略、排序模型和性能优化等多方面因素。在AIGC时代,融合大模型的语义理解能力将进一步提升垂直搜索的精准度,这是未来值得持续探索的方向。


被折叠的 条评论
为什么被折叠?



