语义搜索根据查询与内容之间学习得到的关系进行检索,而不只依赖词项重合。它适合召回改写和概念相关内容,但不能识别用户的“真实意图”,也不保证结果正确。一个生产检索系统还需要词法证据、权限控制、文档生命周期、版本化索引、排序和评测。
语义搜索术语页负责简明定义;本文回答工程问题:如何构建可度量、已授权,并且能在不污染索引的前提下迁移的语义搜索系统?
核心摘要
- 语义搜索是一项目标,不是向量搜索的同义词。
- 标识符、精确短语、代码符号、人名和新词仍需要词法检索。
- 在同一语料快照和标注集上比较纯词法、纯语义与混合检索。
- 不同 Retriever 的分数空间不兼容时,优先使用 RRF 等排名融合,而不是查询内 min-max 后直接加权。
- 租户与 ACL 过滤必须由认证主体和权威策略生成,不能接受用户、调用方或 LLM 自由传入授权范围。
- 完整索引契约必须版本化;迁移使用新索引、回放评测、影子流量和受控切流。
- Retriever、Ranker、ANN、产品结果和安全结果必须分层评测。
什么是语义搜索?
语义搜索是一种利用学习型表示查找与查询含义相关内容的信息检索方法。Dense Embedding 是一种实现,但学习型稀疏检索、词法扩展、混合检索和重排同样可以服务于语义搜索目标。
需要先划清这些边界:
| 概念 | 负责什么 | 不保证什么 |
|---|---|---|
| 词法检索 | 排序词项、短语、字段和邻近证据 | 跨改写召回 |
| Dense Retrieval | 查找相近的学习型向量表示 | 精确词召回、新鲜度或授权 |
| 学习型稀疏检索 | 在保留稀疏特征的同时扩展语义证据 | 普遍优于 BM25 或 Dense Retrieval |
| 混合检索 | 合并多个 Retriever 的候选列表 | 较弱路径存在时仍能提升效果 |
| 重排 | 联合评估查询与小规模候选集 | 不增加延迟和成本就提升质量 |
| 语义搜索系统 | 编排检索、策略、排序与结果展示 | 事实正确或 RAG 回答有证据支撑 |
向量搜索引擎只负责在向量空间执行近邻查询,而向量可以表示文本、图像、商品、用户或风控特征。语义搜索是面向用户的检索目标。因此,只有向量索引既不等于完整语义搜索系统,也不能证明返回文档相关。
用显式契约组织检索流水线
可靠流水线应分离离线建索引与在线查询,并把策略约束同时下沉到两个阶段。
索引契约至少包含:
语料版本
文档解析器与预处理 Revision
分块策略及父子映射
Embedding Provider、模型 Revision 与任务模式
向量维度与归一化
距离度量与 ANN 配置
词法分析器与字段 Schema
元数据 Schema、生命周期状态与策略版本
任一字段变化都可能改变检索行为。更换 Embedding 模型、维度、归一化或距离度量,通常会让旧文档向量与新查询向量不再兼容。不能混用两个向量空间,再假设相似度仍然有效。
分块必须保留文档身份与出处
分块是检索决策,不是简单字符串切片。每个 Chunk 都需要稳定标识、父文档、来源、版本与字符区间,才能完成结果归因、引用、删除和重建。
下面的标准库示例修复了常见的尾部重复循环,并验证 overlap 小于 chunk_size:
from dataclasses import dataclass
from typing import List
@dataclass(frozen=True)
class Chunk:
chunk_id: str
parent_id: str
source_uri: str
corpus_version: str
start: int
end: int
text: str
def chunk_text(
text: str,
parent_id: str,
source_uri: str,
corpus_version: str,
chunk_size: int = 120,
overlap: int = 20,
) -> List[Chunk]:
if chunk_size <= 0 or overlap < 0 or overlap >= chunk_size:
raise ValueError("require chunk_size > overlap >= 0")
chunks: List[Chunk] = []
start = 0
while start < len(text):
hard_end = min(start + chunk_size, len(text))
end = hard_end
if hard_end < len(text):
boundary = max(
text.rfind("。", start, hard_end),
text.rfind(" ", start, hard_end),
)
if boundary > start:
end = boundary + 1
body = text[start:end].strip()
if body:
chunks.append(
Chunk(
chunk_id=f"{parent_id}:{start}:{end}",
parent_id=parent_id,
source_uri=source_uri,
corpus_version=corpus_version,
start=start,
end=end,
text=body,
)
)
if end == len(text):
break
start = end - overlap
return chunks
sample = "语义检索适合召回改写。词法检索负责保留精确标识符。"
result = chunk_text(sample, "doc-7", "kb://search", "corpus-2026-08", 22, 4)
assert result[-1].end == len(sample)
assert len({item.chunk_id for item in result}) == len(result)
print([(item.start, item.end) for item in result])
分块大小必须和 Retriever、任务一起评测。小块可能提高 Passage Precision,却会丢失上下文;大块保留上下文,却可能稀释匹配信号并增加重排或 LLM 的 Token 成本。父文档检索可以用精确子块命中,再展示更大的已授权上下文。
按查询切片选择检索路径
Retriever 应由查询分布、语料和约束决定,而不是由某个静态排行榜决定。
| 查询切片 | 首选基线 | 原因 |
|---|---|---|
| 错误码、SKU、工单 ID | 带精确字段的词法检索 | 字符本身承载含义 |
| 代码符号与 API 名称 | 词法检索加代码字段 | Token 边界和大小写可能关键 |
| 自然语言改写 | Dense 或学习型稀疏检索 | 查询与文档词汇可能不同 |
| 多语言发现 | 经目标语对评测的多语言 Retriever | 跨语言质量随语对和领域变化 |
| 新商品名或新概念 | 词法检索 | 新词可能不在模型训练分布中 |
| 混合型企业问句 | 同时比较词法、语义和混合路径 | 精确实体与概念意图并存 |
不要根据模型维度或通用 Leaderboard 直接选型。先定义模型契约:语言、对称或非对称检索、领域、最大输入、隐私、硬件或 API 约束、延迟和成本,再在同一版本化标注集上评测候选。Sentence Transformers 官方文档明确区分相似文本检索和 Query-to-Passage 的非对称检索,编码任务模式必须匹配实际问题。
相似度度量也是模型契约的一部分。余弦相似度、点积和欧氏距离不是可以随意替换的标签。向量归一化后,点积在数值上等于余弦相似度;但未归一化模型可能有意利用向量模长。应遵循模型要求的度量和预处理,再验证完整流水线。
融合排名,而不是伪装分数可比
BM25 分数、余弦相似度、内积和模型 Logit 的含义与范围不同。查询内 min-max 不能完成校准:一个离群值或候选集变化,就会改变所有归一化分数。因此,0.5 * dense + 0.5 * BM25 之类固定加权需要标注数据提供校准证据。
Reciprocal Rank Fusion(RRF)不比较原始分数,只使用每个文档在各列表中的排名:
RRF(d) = sum(1 / (k + rank_i(d)))
下面的实现保留每条路径的贡献,便于调试融合失败:
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, List, Sequence
@dataclass(frozen=True)
class FusedHit:
document_id: str
score: float
contributions: Dict[str, float]
def reciprocal_rank_fusion(
rankings: Dict[str, Sequence[str]],
rank_constant: int = 60,
limit: int = 10,
) -> List[FusedHit]:
if rank_constant < 1 or limit < 1:
raise ValueError("rank_constant and limit must be positive")
totals = defaultdict(float)
parts: Dict[str, Dict[str, float]] = defaultdict(dict)
for path, document_ids in rankings.items():
for rank, document_id in enumerate(document_ids, start=1):
contribution = 1.0 / (rank_constant + rank)
totals[document_id] += contribution
parts[document_id][path] = contribution
ordered = sorted(totals, key=lambda item: (-totals[item], item))
return [
FusedHit(item, totals[item], parts[item])
for item in ordered[:limit]
]
hits = reciprocal_rank_fusion(
{
"lexical": ["doc-a", "doc-c", "doc-b"],
"semantic": ["doc-b", "doc-a", "doc-d"],
}
)
print([(hit.document_id, round(hit.score, 5)) for hit in hits])
# [('doc-a', 0.03252), ('doc-b', 0.03227), ...]
RRF 原论文实验使用了 k=60,一些产品也将其作为默认值,但它只是基线,不是通用最优值。需要用标注集调优常数、候选深度、路径权重与并列规则,并始终与两种单路 Retriever 对比。较弱路径会注入噪声,混合检索并非默认必胜。
在检索 Runtime 内强制执行授权
授权不是由调用方、用户或 LLM 可选传入的元数据过滤条件。Runtime 必须根据认证主体和权威策略服务推导授权范围,并对每条检索路径应用相同范围。
from dataclasses import dataclass
from typing import Callable, Dict, List, Sequence
@dataclass(frozen=True)
class Principal:
subject: str
tenant: str
roles: Sequence[str]
policy_version: str
@dataclass(frozen=True)
class Candidate:
document_id: str
tenant: str
allowed_roles: Sequence[str]
lifecycle: str
Retriever = Callable[[str, str], List[Candidate]]
def fuse_rankings(rankings: Dict[str, List[str]]) -> List[str]:
scores: Dict[str, float] = {}
for document_ids in rankings.values():
for rank, document_id in enumerate(document_ids, start=1):
scores[document_id] = (
scores.get(document_id, 0.0) + 1.0 / (60 + rank)
)
return sorted(scores, key=lambda item: (-scores[item], item))
def authorized_search(
query: str,
principal: Principal,
lexical_retriever: Retriever,
semantic_retriever: Retriever,
) -> List[str]:
def allowed(candidate: Candidate) -> bool:
return (
candidate.tenant == principal.tenant
and candidate.lifecycle == "active"
and bool(set(candidate.allowed_roles) & set(principal.roles))
)
lexical = [
hit.document_id
for hit in lexical_retriever(query, principal.tenant)
if allowed(hit)
]
semantic = [
hit.document_id
for hit in semantic_retriever(query, principal.tenant)
if allowed(hit)
]
return fuse_rankings({"lexical": lexical, "semantic": semantic})
真实索引应在引擎支持的情况下,把租户、生命周期和 ACL 约束下沉到候选生成。对很小的 ANN Top-K 做 Post-filter,可能在更深位置存在相关授权文档时仍返回空结果,也可能把安全性寄托在单个应用层条件上。应按规模与威胁模型评测 Filtered ANN Recall,并决定租户分片或 Payload Index 策略。
每个结果必须保留 document_id、chunk_id、来源、语料版本、策略版本和检索路径证据。这些 Provenance 是引用、删除、审计与事故响应的基础。
把重排与相似度限定为局部信号
Bi-encoder 独立编码查询和文档,因此文档表示可以复用并高效召回。Cross-encoder 或其他 Reranker 联合评估查询与小规模候选集,可能改善目标工作负载的排序,但会增加延迟与成本,也仍然可能失败。
候选深度和重排深度必须一起测试。如果相关文档没有进入 Retriever Top-K,Reranker 无法恢复;如果候选过多,延迟可能突破 SLO;候选过少,则可能损失必要多样性。
不能把 1 - distance 暴露为通用“相关性概率”。距离方向与范围依赖度量和实现。原始分数应留在内部,并绑定模型和索引版本;产品需要阈值时,应使用经过评测的排名和阈值。
缓存键必须绑定策略与索引版本
只用 Query 文本作为缓存键,可能返回过期结果或跨租户结果。Key 必须覆盖所有会改变授权或排序的输入。
from dataclasses import asdict, dataclass
import hashlib
import json
from typing import Tuple
@dataclass(frozen=True)
class SearchCacheKey:
subject: str
tenant: str
policy_version: str
corpus_version: str
index_version: str
normalized_query: str
filter_items: Tuple[Tuple[str, str], ...]
ranking_config: str
def digest(self) -> str:
payload = json.dumps(
asdict(self),
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
key = SearchCacheKey(
subject="user-17",
tenant="tenant-a",
policy_version="policy-42",
corpus_version="corpus-9",
index_version="search-12",
normalized_query="退款政策",
filter_items=(("language", "zh"),),
ranking_config="lexical+dense:rrf-v3",
)
print(key.digest())
策略、语料、索引、过滤或排序配置变化时,缓存应失效或自然落到新 Key。能否把 Subject 替换为更粗粒度的授权群组,是安全决策,不只是缓存优化。
分五层评测检索系统
一个平均相关性分数无法诊断语义搜索,应分离以下评测层:
| 层级 | 核心问题 | 适用指标 |
|---|---|---|
| Retriever | 相关候选是否进入 Top-K? | Recall@K、精确标识符召回、多语言召回 |
| Ranker | 最佳候选是否排到前面? | MRR、nDCG@K、Precision@K |
| ANN 索引 | 近似搜索损失了什么? | 相对 Exact Search 的 Recall、延迟、内存 |
| 产品或 RAG | 用户是否安全完成任务? | 成功率、引用支持率、无结果率、拒答 |
| 安全与生命周期 | 是否暴露禁止或过期内容? | 跨租户暴露率、过期结果率、授权后零结果率 |
延迟至少记录查询编码、每个 Retriever、融合、重排、策略查询和总请求的 p50 / p95。成本应归一到“成功查询”,而不是只计算请求,否则便宜但无效的流水线会显得高效。
下面的最小评测器展示召回与排序指标的差异:
from math import log2
from typing import Dict, Iterable, List, Set
def recall_at_k(ranking: List[str], relevant: Set[str], k: int) -> float:
if not relevant:
raise ValueError("relevant set must not be empty")
return len(set(ranking[:k]) & relevant) / len(relevant)
def reciprocal_rank(ranking: Iterable[str], relevant: Set[str]) -> float:
for rank, document_id in enumerate(ranking, start=1):
if document_id in relevant:
return 1.0 / rank
return 0.0
def ndcg_at_k(ranking: List[str], grades: Dict[str, int], k: int) -> float:
def dcg(items: List[str]) -> float:
return sum(
(2 ** grades.get(item, 0) - 1) / log2(rank + 1)
for rank, item in enumerate(items, start=1)
)
actual = dcg(ranking[:k])
ideal = dcg(sorted(grades, key=grades.get, reverse=True)[:k])
return actual / ideal if ideal else 0.0
ranking = ["doc-b", "doc-a", "doc-c"]
grades = {"doc-a": 3, "doc-c": 1}
print(recall_at_k(ranking, set(grades), 3)) # 1.0
print(reciprocal_rank(ranking, set(grades))) # 0.5
print(round(ndcg_at_k(ranking, grades, 3), 4))
标注集应来自经过隐私处理的真实查询日志、专家标注、客服工单和已知困难样本,并覆盖精确标识符、无答案、多语言语对、过期文档、已删除文档、恶意元数据及所有权限边界。BEIR 等公共基准可用于比较研究方法,但不能替代业务标注。
迁移 Embedding 索引时不能混用向量空间
Embedding 变更是一项数据迁移:
- 固化新旧两份显式索引契约。
- 创建新物理索引,不在 Serving Index 上原地覆盖。
- 重新解析语料,必要时重新分块,并对完整授权语料全量重嵌入。
- 验证文档数量、删除记录、元数据、向量维度和策略字段。
- 抽样运行 Exact Search,量化 ANN 召回损失。
- 对两个索引回放同一版本化标注查询集。
- 执行生产 Query Shadow,但不向用户展示影子结果。
- 比较相关性切片、授权、过期结果率、延迟、内存和成本。
- 门禁通过后才移动 Read Alias 或逐步切流。
- 在新索引与缓存稳定前保留回滚能力。
迁移期间双写可以降低漂移,却不能证明两个索引等价。更新和删除必须在两侧使用幂等版本处理。每个结果都应记录语料与策略版本,才能在事故中重建当时的决策。
生产决策清单
发布检索变更前,需要用证据回答:
- 这条流水线服务哪一个稳定搜索意图?
- 哪些查询切片走词法、语义或混合路径?
- 当前语料、标注、策略和索引分别是什么版本?
- 所有检索路径是否执行相同租户与生命周期约束?
- Hybrid 是否胜过两种单路基线?
- Reranker 增加的 nDCG 或 MRR 是否值得其 p95 延迟与成本?
- ANN 相对 Exact Search 的 Recall 是多少?
- 系统能否从所有索引与缓存删除一份文档?
- 运维能否把结果归因到来源、Chunk、Retriever、Ranker 与策略?
- 哪些阈值会停止发布并触发回滚?
当这些契约和指标可供审阅时,语义搜索才进入生产状态。更多向量、更大模型或多一层 Reranker 只是实现选择,不是搜索质量证据。
常见问题
语义搜索和向量搜索是一回事吗?
不是。向量搜索是在向量表示上执行查询;语义搜索是检索目标与完整系统,可以组合 Dense Vector、学习型稀疏表示、词法检索、过滤、融合和重排。它的系统边界还包含授权、生命周期、出处和评测。
混合检索一定优于单路 Retriever 吗?
不一定。只有当多条路径提供互补相关候选时,混合检索才有价值。如果某条路径较弱,融合会增加噪声、延迟、内存和调优成本。必须在同一标注与错误切片上比较 Hybrid、Lexical-only 和 Semantic-only。
RRF 解决了什么问题?
RRF 无需假设原始分数可比,就能融合多个排名列表。它简单、可解释,但会舍弃分数幅度信息,仍需评测 Rank Constant、路径权重、候选深度和并列规则。
Post-filter 能保证向量检索安全吗?
不能把它作为默认充分条件。Post-filter 可能把很小 Top-K 中的结果全部删除并损害召回;应用层漏掉一次检查也可能形成披露路径。应优先使用策略感知的候选生成、纵深校验和显式跨租户测试。
检索质量更好就能保证 RAG 回答正确吗?
不能。检索质量只决定有用且已授权的证据是否进入上下文;生成模型仍可能忽略、误读或违背证据。应分别评测检索、引用支持度、答案正确性、拒答与安全结果。RAG 生产指南负责回答系统边界。
资料与延伸阅读
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks - Bi-encoder 检索的基础研究。
- BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models - 在多种任务上评测检索方法的依据。
- Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs - HNSW 算法及其近似权衡。
- Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods - RRF 原始实验。
- Sentence Transformers:Semantic Search - 对称与非对称检索的官方说明。
- 向量 Embedding 工程指南 - Embedding 契约、评测与迁移。
- 向量数据库指南 - 存储与 ANN 基础设施选型。