前阵子在给团队做内部知识检索的原型,业务方的诉求听起来特别朴素:能不能用一个搜索框,把散在文档、工单、代码仓库和流程系统里的信息一次找出来,而且看到的结果必须是这个人本来就有权限看的。真正动手才发现,难点根本不在“搜”,而在接入、分块、排序、权限这四件事怎么串成一条可靠的链路。这篇文章就以企业搜索平台的典型产品路径为背景,用一个可以本地跑起来的 Python 示例,把“统一索引 + 混合排序 + 权限过滤”这套核心机制完整拆一遍。文章偏实战,适合有 Python 基础、正在做知识库/检索/RAG 相关功能的开发者,也适合想理解企业搜索系统内部结构的技术同学。读完你至少能拿到三样东西:一份可运行的混合检索代码、一套权限过滤的落地位置判断、一份上线前后会遇到的坑点清单。
1. 从第一个产品到第一次验证:企业搜索真正要解决什么
企业搜索类产品的发展路径其实很典型:先把“跨系统统一检索”这个最小闭环做出来,交给第一批真实客户使用,然后在客户的实际数据里反复验证两件事 —— 索引能不能覆盖足够多的信息源,权限能不能守得住。这两件事只要有一件不成立,产品在客户那里就活不过试用期。
从技术视角看,这个“最小闭环”可以拆成四个必须同时成立的模块。第一是连接器层,负责从 Wiki、工单系统、代码托管、云盘、审批流里把内容拉出来,并保持增量同步。第二是统一索引层,把结构完全不同的内容归一成“标题 + 正文 + 元数据 + 权限标签”的统一切片。第三是检索排序层,要同时处理关键词精确匹配和语义近似匹配。第四是权限层,它不是一个独立模块,而是必须嵌进检索链路的每一层。很多人第一次做这类系统,会把权限当成最后一步“过滤一下结果”,这正是后面出问题的根源。
1.1 从关键词检索到统一检索的跨度
单库搜索和统一搜索的差别,不是把几个数据源的结果拼在一起那么简单。单库搜索里,字段结构是确定的,排序规则可以针对这一个库调优;统一搜索里,每个源的标题质量、正文长度、更新时间语义都不一样,甚至连“作者”这个字段在工单系统里指提单人、在文档系统里指最后编辑人。
这就带来两个直接后果。一个是必须做归一化,把所有源映射到同一套文档模型上,否则排序阶段无从比较。另一个是必须做分块,因为不同源的单篇内容长度差异可能是几十倍,长文档整篇进索引会稀释关键词权重。归一化和分块看似是数据预处理的琐事,实际上决定了后面排序效果的上限。我在示例里会把这部分单独抽成一层,就是为了让这个边界清晰。
1.2 权限感知为什么是分水岭
企业搜索和个人搜索最大的区别,是“搜得到”不等于“看得到”。一个员工搜“薪酬”,如果系统把 HR 部门的薪酬制度文档返回给他,哪怕只是一个标题,这也是一次安全事故。更隐蔽的情况是:结果列表里不展示,但通过“共 N 条结果”这类统计信息、通过高亮摘要片段、通过自动补全的候选词,一样会泄漏信息。
所以权限必须满足三个条件才算合格:在召回阶段就要生效(而不是排序后过滤)、在摘要和高亮阶段要生效、在统计和联想阶段也要生效。业界常用“fail-closed”原则,也就是权限信息缺失时默认拒绝访问,而不是默认放行。这条原则我会在示例代码和最佳实践里反复强调,因为它是最容易在赶工期时被妥协掉的一条。
1.3 本文示例的能力边界
需要先说清楚:下面这套 Python 实现是一个教学用的迷你版本,它复现了完整链路的结构,但没有追求工业级性能。具体来说,它用纯标准库实现了 BM25 和 TF-IDF 向量检索,用倒数排名融合(RRF)做混合排序,用文档级 ACL 做权限过滤。它不包含真实语义向量模型、不包含分布式索引、不包含增量同步。
这样取舍的理由是:如果你的目标是理解结构,纯标准库版本可以让你在没有 GPU、没有外部服务的情况下把整条链路跑通,并且每一行逻辑都看得见。等你确认了结构正确,再把这些组件替换成 Elasticsearch、向量数据库或自建服务,替换点在哪、接口怎么留,我会在第 5 节给出生产方案时说明。
2. 环境准备与项目结构
2.1 运行环境与版本说明
示例代码只依赖 Python 标准库,所以对环境要求很宽松。本文示例使用 Python 3.9 及以上版本编写,主要用到了dataclasses、typing这类较新的语法特性。如果你的环境是 Python 3.8,list[str]这种内置泛型写法需要改成List[str],或者加上from __future__ import annotations。
关于 Elasticsearch 部分,本文给出的查询 DSL 基于 Elasticsearch 8.x 的向量检索能力。需要提醒的是,knn检索、rank: rrf这类语法在不同小版本之间有差异,向量检索的字段类型和参数名也调整过。所以第 5 节的 DSL 请当成结构参考,实际落地时务必对照你所用版本的官方文档校验参数名。运行环境方面,Windows、macOS、Linux 都可以,只要保证文件编码是 UTF-8,因为示例数据里包含中文。
2.2 项目目录结构
整个示例放在一个目录下,按职责拆成若干模块,方便你对照理解每一层在做什么。
enterprise-search-demo/ ├── data/ │ └── docs.jsonl # 示例语料,一行一篇文档 ├── app/ │ ├── __init__.py │ ├── models.py # 文档、切片、用户、结果的数据模型 │ ├── tokenizer.py # 中英文混合分词 │ ├── bm25.py # BM25 倒排索引 │ ├── vector_index.py # TF-IDF 向量索引 │ ├── acl.py # 权限判定 │ ├── ranker.py # RRF 融合排序 │ └── service.py # 检索服务组装 └── run_demo.py # 演示入口这个结构里,models.py是最关键的“契约”。只要数据模型定得稳,后面无论是把 BM25 换成 Elasticsearch,还是把 TF-IDF 换成真实 embedding,都不需要改动业务侧代码。这也是我在实际项目里最先确定的一层。
2.3 初始化示例语料
语料用 JSONL 格式,一行一个 JSON 对象,好处是追加和流式读取都方便。字段设计上刻意保留了allowed_users和allowed_groups两个权限字段,以及source来源字段,后者在真实系统里用来做结果分组展示和按源调权。
{"doc_id": "doc-1001", "title": "统一检索平台架构说明", "content": "统一检索平台由连接器层、索引层、检索层和权限层组成。连接器负责从各业务系统增量拉取内容,索引层负责分块与字段归一,检索层同时支持关键词召回和向量召回,权限层在召回阶段完成过滤。", "source": "wiki", "updated_at": "2024-05-11", "allowed_users": ["u_alice"], "allowed_groups": ["eng", "data-platform"]} {"doc_id": "doc-1002", "title": "季度预算与报销流程", "content": "报销流程分为三步:提交单据、直属主管审批、财务复核。单笔金额超过阈值的需要部门负责人二次审批。季度预算调整需要在季度结束前两周提交申请。", "source": "wiki", "updated_at": "2024-06-02", "allowed_users": [], "allowed_groups": []} {"doc_id": "doc-1003", "title": "线上告警处理标准流程", "content": "收到一级告警后五分钟内确认影响面,十五分钟内给出初步结论。处理过程需要在值班群同步进展。若涉及数据变更,必须先备份并走变更审批。", "source": "ops", "updated_at": "2024-04-18", "allowed_users": [], "allowed_groups": ["sre", "eng"]} {"doc_id": "doc-1004", "title": "薪酬等级与调薪窗口说明", "content": "薪酬等级按岗位序列划分,每年设置两个调薪窗口。调薪结果由人力资源部门统一发布,员工可在个人中心查看本人等级与最近一次调整记录。", "source": "hr", "updated_at": "2024-03-29", "allowed_users": [], "allowed_groups": ["hr"]} {"doc_id": "doc-1005", "title": "接口鉴权与 Token 校验规范", "content": "所有内部接口必须校验访问令牌。令牌签发后设置有效期,过期需重新申请。服务端校验时需要同时验证签名、有效期和调用方权限范围,任一不通过直接拒绝。", "source": "git", "updated_at": "2024-05-30", "allowed_users": [], "allowed_groups": ["eng"]} {"doc_id": "doc-1006", "title": "新员工入职指引", "content": "入职首周需要完成账号开通、设备领用和安全培训。账号开通包括邮箱、代码仓库和工单系统三个入口,未完成安全培训前不能申请生产环境权限。", "source": "wiki", "updated_at": "2024-01-15", "allowed_users": [], "allowed_groups": []}这里的空数组约定为“全员可见”,这是为了演示方便。在生产环境中应当反过来:空权限标签表示无人可见,必须有明确的公开标记才放行。这个差异我在 7.1 节会再展开,因为它是权限设计里最容易被写错的一行代码。
3. 核心原理拆解:一条检索链路里的四个关键点
3.1 文档接入与分块策略
分块(chunking)不是可选项。假设一个 Wiki 页面有 8000 字,另一个工单只有 60 字,如果整篇作为检索单元,长文档里某个关键词出现的绝对次数天然更高,BM25 的饱和函数虽然能缓解这个问题,但文档长度差异过大时排序仍然会失真。更实际的问题是,即使长文档排序正确,把 8000 字整段返回给用户也没有意义。
分块策略上有三种常见做法。固定长度滑窗最简单,实现成本低,缺点是可能把一句话切断。按段落或标题切分更符合文档语义,缺点是块长度不均,需要再合并。按语义切分效果最好,但要额外模型成本。示例里用固定长度加重叠(overlap)的方案,重叠的作用是保证被切断的句子在相邻两个块里都能被完整命中。
这里有个容易忽略的细节:分块之后,标题应该拼接进每个块的检索文本里。因为块本身可能不包含标题里的关键词,而用户的查询词往往正好是标题词。这个拼接动作看起来很小,实际对召回率的影响相当明显。
3.2 BM25 倒排索引的作用
BM25 是关键词检索的经典算法,它的核心思想可以概括为三句话:词越稀有越重要,词在文档里出现越多越重要但收益递减,文档越长这个词的相对权重越低。公式里的k1控制词频饱和速度,b控制文档长度归一化强度,k1通常取 1.2 到 2.0,b通常取 0.75。
BM25 的价值在于精确匹配。用户搜索“Token 校验”这种专有名词时,向量检索很可能返回一堆语义相近但完全不相关的内容,而 BM25 能稳稳命中。反过来,用户搜索“怎么防止接口被随便调用”这种口语化表达时,BM25 几乎失效,因为文档里根本没有这些词。这就是为什么要做混合检索 —— 两路召回的能力互补。
3.3 向量召回与 TF-IDF 的定位
向量召回解决的是“表述不同但语义相同”的问题。生产环境一般用预训练模型把文本编码成稠密向量,再用近似最近邻(ANN)算法检索。示例里用 TF-IDF 是因为它不依赖任何外部模型,可以离线跑通,但必须明确一点:TF-IDF 生成的是词面向量,不是语义向量。它能缓解一部分“词不完全一致”的问题,但无法理解同义改写。
如果你要在示例基础上接入真实模型,正确的做法是定义一个EmbeddingProvider接口,把encode(text) -> list[float]抽出来,示例里的 TF-IDF 作为默认实现,生产环境换成模型调用。这样替换成本最低。同时要注意,向量模型换了之后,索引必须全量重建,因为不同模型产生的向量空间不可比,混用会直接导致检索结果错乱。
3.4 RRF 融合排序为什么比加权求和稳
两路召回出来两组分数,怎么合并?最直觉的做法是加权求和:final = 0.5 * bm25 + 0.5 * vector。但这里有个坑:BM25 分数是无上界的,可能是 3.2,也可能是 28.6;余弦相似度被限制在 0 到 1 之间。直接相加会让 BM25 完全主导排序,权重参数形同虚设。虽然可以做归一化,但归一化本身依赖当前结果集的分布,会导致同一个查询在不同数据量下排序不一致。
倒数排名融合(Reciprocal Rank Fusion,RRF)绕开了这个问题,它只用排名不用分数:每个文档的得分等于它在各路结果里的排名倒数之和。公式是score = Σ 1 / (k + rank),k一般取 60。这种做法的好处是天然免疫分数尺度差异,而且对个别路的异常高分不敏感。它的代价是丢弃了分数信息,如果某一路的分数区分度很高,RRF 会用不上。工程上通常先用 RRF 拿到稳定的候选集,再用一个精排模型重新打分,两阶段处理。
3.5 权限过滤应该放在哪一层
这个问题值得单独说。放在排序后过滤,实现最简单,但有两个致命问题:一是会破坏结果数量的稳定性,用户看到 10 条结果里 7 条被过滤,体验很差;二是如果实现不严谨,过滤前的中间结果可能通过日志、埋点、缓存泄漏出去。
放在召回阶段过滤,是正确做法。具体实现上,先根据用户身份算出可见文档集合,再把这个集合作为过滤条件传给每一路召回。Elasticsearch 里对应的是filter子句,向量检索里对应的是knn.filter,两者都能在打分前完成裁剪。示例代码里我用的是“先算出可见 chunk_id 集合,再传给检索器”的方式,逻辑等价,实现更直白。这里还要注意一点:过滤条件必须同时作用于所有召回路径,漏掉任何一路都会造成越权。
4. 完整实战:从零实现一个迷你企业搜索服务
下面开始写代码。每个文件都标了路径,可以按顺序复制到项目里,最后用run_demo.py直接运行。
4.1 数据模型定义
先定义四个核心数据结构:文档、切片、用户上下文、检索结果。把这些定死之后,后续模块都围绕它们工作。
# 文件路径:app/models.py from dataclasses import dataclass, field from typing import List @dataclass class Document: """一篇原始文档,对应一个数据源里的一条记录""" doc_id: str title: str content: str source: str updated_at: str = "" allowed_users: List[str] = field(default_factory=list) allowed_groups: List[str] = field(default_factory=list) @property def searchable_text(self) -> str: # 标题拼进正文,避免切片里缺少标题关键词导致召回失败 return f"{self.title} {self.content}" @dataclass class Chunk: """文档切片,是索引和检索的最小单元""" chunk_id: str doc_id: str text: str position: int @dataclass class UserContext: """发起检索的用户上下文,生产环境由网关或 SSO 注入""" user_id: str groups: List[str] = field(default_factory=list) @dataclass class SearchHit: """单条检索结果""" chunk_id: str doc_id: str title: str text: str source: str score: float bm25_score: float = 0.0 vector_score: float = 0.0searchable_text这个属性看起来只是字符串拼接,但它承担了“标题权重”的职责。如果你希望标题权重更高,可以在这里把标题重复两次,或者在索引阶段给标题字段单独设权重。这是一个低成本调优点。
4.2 中英文混合分词
中文检索绕不开分词。示例里不引入 jieba,用“单字 + 相邻二元组”的方式做兜底。这个方案召回率偏高、精确率偏低,但对演示足够,而且能保证“权限校验”这种词在用户只输入“校验”时也能命中。
# 文件路径:app/tokenizer.py import re _EN_RE = re.compile(r"[a-zA-Z0-9_]+") _CJK_BLOCK_RE = re.compile(r"[\u4e00-\u9fff]+") def tokenize(text: str) -> list: """中英文混合分词:英文按词切,中文按单字 + 二元组切""" if not text: return [] tokens = [m.group(0).lower() for m in _EN_RE.finditer(text)] for match in _CJK_BLOCK_RE.finditer(text): block = match.group(0) tokens.extend(list(block)) for i in range(len(block) - 1): tokens.append(block[i:i + 2]) return tokens先按连续中文块切分再生成二元组,是为了避免跨越标点生成无意义组合。比如“检索。权限”如果不做块切分,会产生“索。”这种带标点的垃圾词,白白占用索引空间。
4.3 BM25 倒排索引实现
倒排索引的结构是“词 → 包含该词的文档列表”。查询时只需要遍历查询词对应的倒排链,不需要扫全量文档,这是检索速度的基础。
# 文件路径:app/bm25.py import math from collections import Counter, defaultdict class BM25Index: def __init__(self, k1: float = 1.5, b: float = 0.75): self.k1 = k1 self.b = b self._doc_ids = [] self._tf = {} # chunk_id -> Counter self._len = {} # chunk_id -> 词数 self._postings = defaultdict(dict) # term -> {chunk_id: tf} self._df = Counter() # term -> 出现文档数 self._n = 0 self._avgdl = 0.0 def add(self, chunk_id: str, tokens: list) -> None: tf = Counter(tokens) self._doc_ids.append(chunk_id) self._tf[chunk_id] = tf self._len[chunk_id] = len(tokens) for term, count in tf.items(): self._postings[term][chunk_id] = count self._df[term] += 1 def build(self) -> None: self._n = len(self._doc_ids) total = sum(self._len.values()) self._avgdl = total / self._n if self._n else 0.0 def _idf(self, term: str) -> float: df = self._df.get(term, 0) if df == 0: return 0.0 return math.log(1 + (self._n - df + 0.5) / (df + 0.5)) def search(self, tokens: list, top_k: int = 50, allowed=None) -> list: """allowed 为可见 chunk_id 集合,None 表示不过滤""" scores = defaultdict(float) for term in set(tokens): idf = self._idf(term) if idf <= 0: continue for chunk_id, tf in self._postings.get(term, {}).items(): if allowed is not None and chunk_id not in allowed: continue dl = self._len[chunk_id] denom = tf + self.k1 * (1 - self.b + self.b * dl / (self._avgdl or 1.0)) scores[chunk_id] += idf * tf * (self.k1 + 1) / denom return sorted(scores.items(), key=lambda x: -x[1])[:top_k]几个实现细节值得注意。set(tokens)去重是为了避免同一个查询词重复累加分数,这在查询词本身就被切分成单字和二元组时会频繁发生。idf <= 0的判断用于跳过从未出现过的词,而不是给它们算一个负分。self._avgdl or 1.0是防止在空索引上除零。这些都是工程上必须兜住的边界,否则线上会遇到各种偶发异常。
4.4 TF-IDF 向量索引实现
向量索引这里用稀疏向量表示,只存非零权重,避免在词表很大时把内存吃满。相似度用余弦,因为余弦对文本长度不敏感。
# 文件路径:app/vector_index.py import math from collections import Counter, defaultdict class TfidfVectorIndex: def __init__(self): self._tf = {} self._df = Counter() self._postings = defaultdict(set) self._idf = {} self._vec = {} self._norm = {} self._n = 0 def add(self, chunk_id: str, tokens: list) -> None: tf = Counter(tokens) self._tf[chunk_id] = tf for term in tf: self._df[term] += 1 self._postings[term].add(chunk_id) def build(self) -> None: self._n = len(self._tf) for term, df in self._df.items(): self._idf[term] = math.log((1 + self._n) / (1 + df)) + 1.0 for chunk_id, tf in self._tf.items(): vec = {} norm = 0.0 for term, count in tf.items(): weight = (1 + math.log(count)) * self._idf[term] vec[term] = weight norm += weight * weight self._vec[chunk_id] = vec self._norm[chunk_id] = math.sqrt(norm) or 1.0 def search(self, tokens: list, top_k: int = 50, allowed=None) -> list: qtf = Counter(tokens) qvec = {} qnorm = 0.0 for term, count in qtf.items(): idf = self._idf.get(term) if idf is None: continue weight = (1 + math.log(count)) * idf qvec[term] = weight qnorm += weight * weight qnorm = math.sqrt(qnorm) or 1.0 if not qvec: return [] candidates = set() for term in qvec: candidates |= self._postings.get(term, set()) if allowed is not None: candidates &= allowed scores = {} for chunk_id in candidates: vec = self._vec[chunk_id] dot = sum(w * vec.get(term, 0.0) for term, w in qvec.items()) if dot <= 0: continue scores[chunk_id] = dot / (qnorm * self._norm[chunk_id]) return sorted(scores.items(), key=lambda x: -x[1])[:top_k]这里用1 + log(count)做次线性词频,是为了抑制高频词的过度影响,思路和 BM25 的饱和函数一致。候选集用倒排链求并集而不是遍历全部向量,这是稀疏向量的标准做法;如果你换成稠密向量 + 近似最近邻,这一层的实现会完全不同,但对外接口保持不变。
4.5 权限判定与 RRF 融合
权限判定必须写成一个独立函数,方便单测覆盖,也方便将来替换成更复杂的策略(比如基于文档密级、时间段、IP 段的组合规则)。
# 文件路径:app/acl.py from app.models import Document, UserContext def is_visible(doc: Document, user: UserContext) -> bool: """判断用户是否可见某篇文档。 注意:本示例将空权限标签视为公开,仅为演示方便。 生产环境应改为 fail-closed:空标签视为不可见。 """ if not doc.allowed_users and not doc.allowed_groups: return True if user.user_id in doc.allowed_users: return True return bool(set(doc.allowed_groups) & set(user.groups))# 文件路径:app/ranker.py from collections import defaultdict def reciprocal_rank_fusion(rank_lists, k: int = 60) -> dict: """多路结果的倒数排名融合,只用排名不用分数""" scores = defaultdict(float) for rank_list in rank_lists: for rank, chunk_id in enumerate(rank_list, start=1): scores[chunk_id] += 1.0 / (k + rank) return dict(scores)k取 60 是 RRF 的常见默认值,它的作用是压低排名靠前者的分数差距:当k相对排名较大时,第 1 名和第 2 名的得分差异不会过分悬殊,避免某一路的单一结果垄断排序。如果你的业务对精确匹配特别敏感,可以把k调小,让头部排名的权重更突出。
4.6 检索服务组装
这一层负责把数据加载、分块、索引构建、双路召回、权限过滤、结果去重串起来。真实项目里这一层往往对应一个SearchService类,被 HTTP 接口和内部任务共同调用。
# 文件路径:app/service.py import json from app.acl import is_visible from app.bm25 import BM25Index from app.models import Chunk, Document, SearchHit, UserContext from app.ranker import reciprocal_rank_fusion from app.tokenizer import tokenize from app.vector_index import TfidfVectorIndex def split_chunks(text: str, size: int = 180, overlap: int = 40) -> list: """固定长度滑窗分块,overlap 保证跨块句子不被截断""" if overlap >= size: raise ValueError("overlap 必须小于 size") text = text.strip() if not text: return [] if len(text) <= size: return [text] chunks = [] start = 0 while start < len(text): end = min(start + size, len(text)) chunks.append(text[start:end]) if end == len(text): break start = end - overlap return chunks class EnterpriseSearchService: def __init__(self, bm25, vec, chunks, docs): self.bm25 = bm25 self.vec = vec self.chunks = chunks self.docs = docs @classmethod def from_jsonl(cls, path: str) -> "EnterpriseSearchService": docs = {} chunks = {} bm25 = BM25Index() vec = TfidfVectorIndex() with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue doc = Document(**json.loads(line)) docs[doc.doc_id] = doc for i, piece in enumerate(split_chunks(doc.searchable_text)): chunk_id = f"{doc.doc_id}#{i}" chunks[chunk_id] = Chunk( chunk_id=chunk_id, doc_id=doc.doc_id, text=piece, position=i, ) tokens = tokenize(piece) bm25.add(chunk_id, tokens) vec.add(chunk_id, tokens) bm25.build() vec.build() return cls(bm25, vec, chunks, docs) def _visible_chunks(self, user: UserContext) -> set: return { chunk_id for chunk_id, chunk in self.chunks.items() if is_visible(self.docs[chunk.doc_id], user) } def search(self, query: str, user: UserContext, top_n: int = 5) -> list: tokens = tokenize(query) if not tokens: return [] visible = self._visible_chunks(user) if not visible: return [] bm_hits = self.bm25.search(tokens, top_k=50, allowed=visible) vec_hits = self.vec.search(tokens, top_k=50, allowed=visible) if not bm_hits and not vec_hits: return [] fused = reciprocal_rank_fusion( [[cid for cid, _ in bm_hits], [cid for cid, _ in vec_hits]] ) bm_map = dict(bm_hits) vec_map = dict(vec_hits) results = [] seen_docs = set() for chunk_id, score in sorted(fused.items(), key=lambda x: -x[1]): chunk = self.chunks[chunk_id] # 同一篇文档只保留命中的第一个切片,避免结果列表重复 if chunk.doc_id in seen_docs: continue seen_docs.add(chunk.doc_id) doc = self.docs[chunk.doc_id] results.append( SearchHit( chunk_id=chunk_id, doc_id=doc.doc_id, title=doc.title, text=chunk.text, source=doc.source, score=round(score, 6), bm25_score=round(bm_map.get(chunk_id, 0.0), 4), vector_score=round(vec_map.get(chunk_id, 0.0), 4), ) ) if len(results) >= top_n: break return results去重这一步在实际项目里非常必要。一篇长文档被切成十几个块,如果不去重,搜索结果第一屏可能全是同一篇文档的不同片段,用户会以为系统坏了。常见的做法是同文档只保留最高分块,同时在结果上标注“本文档还有 N 处匹配”。
4.7 运行与验证
写一个演示入口,跑三个不同权限的查询,观察结果差异。
# 文件路径:run_demo.py from pathlib import Path from app.models import UserContext from app.service import EnterpriseSearchService BASE_DIR = Path(__file__).parent def show(query, user, service, top_n=3): print("=" * 60) print(f"查询:{query}") print(f"用户:{user.user_id} 组:{user.groups}") hits = service.search(query, user, top_n=top_n) if not hits: print(" (无可见结果)") for i, hit in enumerate(hits, start=1): print(f" {i}. [{hit.source}] {hit.title} score={hit.score} " f"bm25={hit.bm25_score} vec={hit.vector_score}") if __name__ == "__main__": service = EnterpriseSearchService.from_jsonl(str(BASE_DIR / "data" / "docs.jsonl")) alice = UserContext(user_id="u_alice", groups=["eng", "data-platform"]) bob = UserContext(user_id="u_bob", groups=["hr"]) show("接口 鉴权 校验 规范", alice, service) show("接口 鉴权 校验 规范", bob, service) show("薪酬 等级 调薪", alice, service) show("薪酬 等级 调薪", bob, service)在项目根目录执行:
python run_demo.py预期输出大致如下。同一个查询在不同用户下返回不同结果,这正是权限在召回阶段生效的直接体现:
============================================================ 查询:接口 鉴权 校验 规范 用户:u_alice 组:['eng', 'data-platform'] 1. [git] 接口鉴权与 Token 校验规范 score=0.032787 bm25=8.7 vec=0.42 ============================================================ 查询:接口 鉴权 校验 规范 用户:u_bob 组:['hr'] (无可见结果) ============================================================ 查询:薪酬 等级 调薪 用户:u_alice 组:['eng', 'data-platform'] (无可见结果) ============================================================ 查询:薪酬 等级 调薪 用户:u_bob 组:['hr'] 1. [hr] 薪酬等级与调薪窗口说明 score=0.032787 bm25=9.1 vec=0.38注意u_alice虽然在doc-1001的allowed_users里,但她的组权限不包含hr,所以薪酬文档对她完全不可见。这里要再强调一次:示例的空权限标签等于公开,只是为了让你能快速看到非空权限的效果,真实系统必须反过来做。
5. 生产方案:把组件替换成工业级实现
示例跑通之后,下一步是替换组件。这里给出两条最常见的落地路径,重点讲替换点和需要注意的结构问题。
5.1 Elasticsearch 索引设计要点
在 Elasticsearch 里,一篇文档对应一个文档,切片可以用嵌套字段或者独立索引两种方式。独立索引(每个 chunk 一条文档)更简单,因为向量检索和 BM25 都能直接作用在 chunk 级别,缺点是文档级去重需要业务层处理,这和示例里的做法一致。
Mapping 设计上,需要显式指定中文分词器,否则默认分词器对中文基本等于按字切分,召回效果很差。权限字段建议用keyword类型,并且不要开启fielddata,因为它只用于过滤和聚合,不参与全文检索。向量字段用dense_vector,维度和相似度算法必须和你的模型一致,这个参数一旦建好索引就不能随便改。
{ "mappings": { "properties": { "doc_id": { "type": "keyword" }, "chunk_id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "standard" }, "content": { "type": "text", "analyzer": "standard" }, "source": { "type": "keyword" }, "updated_at": { "type": "date" }, "acl_users": { "type": "keyword" }, "acl_groups": { "type": "keyword" }, "embedding": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" } } } }dims必须和实际模型输出维度一致,示例里写的 768 只是常见取值,请按你的模型调整。similarity选cosine还是dot_product取决于模型是否做了归一化,选错会导致排序结果完全不可用,这一点在接入新模型时一定要先验证。
5.2 混合检索查询结构
下面这个查询结构同时包含 BM25 的should子句、向量的knn子句和权限的filter子句。关键在于权限条件要同时出现在两处,任何一处缺失都会造成越权召回。
{ "size": 10, "query": { "bool": { "filter": [ { "terms": { "acl_groups": ["eng", "data-platform"] } } ], "should": [ { "match": { "title": { "query": "接口 鉴权 校验", "boost": 2.0 } } }, { "match": { "content": { "query": "接口 鉴权 校验", "boost": 1.0 } } } ], "minimum_should_match": 1 } }, "knn": { "field": "embedding", "query_vector": [0.01, 0.02, 0.03], "k": 50, "num_candidates": 200, "filter": { "terms": { "acl_groups": ["eng", "data-platform"] } } }, "rank": { "rrf": {} } }这里有三个工程细节。第一,filter子句不参与打分,只做过滤,性能比写在must里更好。第二,num_candidates要显著大于k,否则近似检索的召回质量会下降,一般取k的 3 到 10 倍。第三,rank: rrf的语法在不同 8.x 小版本上有差异,如果你的版本不支持,需要在应用层自己做 RRF 融合,也就是分别发起两次查询再合并,逻辑和示例里的reciprocal_rank_fusion完全一致。
5.3 与源系统的增量同步
连接器层的核心是“幂等 + 增量”。幂等保证同一个文档重复同步不会产生重复切片,做法是用doc_id + position作为 chunk 的唯一键,写入时用 upsert 而不是 insert。增量则依赖源系统提供的时间戳或游标,拉取时记录水位线,下次从水位线继续。
删除是最容易被忽略的场景。源系统里删了一篇文档,如果索引里没删,它就会变成一个“幽灵结果”,而且往往带着旧权限,属于典型的安全隐患。推荐做法是把源系统的删除事件也当作一种同步消息处理,或者定期做一次全量对账,比对源端和索引端的 ID 集合,把差异项清理掉。这个对账任务在企业搜索里几乎是必备的,因为源系统的删除事件丢包太常见了。
6. 常见问题与排查思路
这一节按“现象 → 原因 → 排查 → 解决”的顺序整理,都是这类系统上线前后高频遇到的问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 搜中文关键词几乎无结果 | 分词器配错,中文被按单字切且未建二元组 | 检查 mapping 的 analyzer,必要时自定义分词器 |
| 专有名词搜不到 | 只有向量召回生效,BM25 被权重压制 | 提高 BM25 权重,或对标题字段单独加权 |
| 语义相近但表述不同的查不到 | 向量模型不适合中文,或维度/相似度配错 | 换模型后必须全量重建索引,不要混用向量 |
| 结果里全是同一篇文档 | 分块后未做文档级去重 | 同文档只保留最高分块,并标注其余命中数 |
| 用户看到了不该看的内容 | 权限只在结果返回时过滤,摘要/统计未过滤 | 权限前移到召回阶段,统计和高亮同步过滤 |
| 检索延迟随数据量线性上升 | 每次查询都重建可见文档集合 | 用 filter 下推到检索引擎,或缓存用户权限快照 |
| 新内容搜不到 | 增量同步水位线未推进,或索引未刷新 | 检查同步任务日志,确认 refresh 间隔 |
| 同一文档出现多个版本 | upsert 键设计错误,旧版本未删除 | 用 doc_id + position 作唯一键,更新时先删后写或覆盖 |
针对第一类问题,还有一个隐藏原因值得单独说:如果检索文本里没有拼上标题,而用户的查询词恰好只出现在标题里,那么正文切片永远匹配不上。这个错误很隐蔽,因为大部分查询是正常的,只有在特定查询下才会暴露。排查方法很简单,把某条结果的searchable_text打印出来看有没有标题即可。
针对延迟问题,示例里每次查询都遍历全部切片算可见集合,数据量到十万级就会明显变慢。生产环境的做法是把权限判断下推到检索引擎的 filter 里,或者在用户登录时算好权限快照并缓存,查询时直接带入。缓存要注意失效策略,员工转岗后权限变化必须及时反映,否则会出现“离职后仍能搜到旧部门文档”的问题。
7. 最佳实践与工程建议
7.1 权限必须 fail-closed
这是整篇文章里我最想强调的一条。示例代码里“空权限标签 = 公开”是为了演示,生产环境必须改成“空权限标签 = 不可见”。原因很直接:连接器同步失败、字段解析异常、新增数据源没适配权限字段,这几种情况都会导致权限标签为空。如果空等于公开,那么每一次同步异常都会变成一次数据泄漏。
正确的做法是引入显式的公开标记,比如visibility: "public",只有明确标记为公开的文档才允许全员访问。同时,在检索入口加一层兜底校验:如果用户上下文解析失败(比如 SSO 返回异常),直接返回空结果而不是返回全部结果。这层兜底看起来多余,但它是最后一道防线。
另外,权限变更要有传播机制。文档权限在源系统里改了,索引里的 ACL 字段要能在一个可接受的延迟内更新。对于敏感数据源,建议缩短同步周期,或者在检索时回源校验一次权限,用延迟换安全。
7.2 建立检索评估集
没有评估集的检索优化基本等于盲调。评估集的构建方式不复杂:收集 100 到 300 条真实查询,人工标注每条查询应该命中的文档 ID,然后计算 Recall@10、MRR 这类指标。调整分词、权重、RRF 参数之后重跑一遍,看指标是升是降。
这里有个经验:评估集要覆盖不同类型,包括专有名词查询、口语化描述查询、多关键词组合查询,以及必须返回空结果的越权查询。最后一类特别重要,它验证的是权限逻辑而不是排序效果。我在实际项目里见过排序指标一路上升、但越权查询突然返回结果的情况,原因就是某次改动把权限过滤的候选集算错了。
7.3 日志、埋点与可观测性
检索系统的日志要记录三样东西:查询词、召回数量、最终返回数量。召回数量到返回数量的落差,如果长期偏大,说明权限过滤或去重策略过于激进,用户会觉得“搜不到东西”。同时要记录每一路召回的贡献,方便判断是 BM25 失效还是向量失效。
埋点方面,点击行为和“结果不满意”的反馈是最有价值的信号。它们可以用来挖掘长尾查询、补充同义词词典、发现分词遗漏。要注意的是,埋点数据本身也包含查询词,可能涉及敏感信息,落库前需要脱敏或者做权限隔离,不能因为“反正是内部系统”就忽略。
另外,索引构建过程要有明确的指标:文档总数、切片总数、构建耗时、失败文档数。失败文档必须告警,因为一篇同步失败的文档等于永久搜不到,而且不会有人主动发现。
8. 写在最后:下一步可以补哪几块
把示例跑通之后,你手里其实已经有了一个结构完整的骨架:数据模型、分块、双路召回、权限过滤、融合排序、结果去重。这个骨架的价值在于,它把“企业搜索”这个听起来很宏大的东西拆成了六个可以独立替换的零件。你换掉任何一个零件,整体结构都不用动。
如果继续往下走,我建议按这个顺序补:先把 TF-IDF 换成真实的中文 embedding 模型,观察语义召回带来的变化;再把内存索引换成 Elasticsearch,把权限过滤下推到 filter 子句;然后补上增量同步和删除对账;最后才是精排和个性化。这个顺序的理由是,前两步解决的是“能不能搜到”,后两步解决的是“搜得准不准”,而在数据量不大时,准不准的收益远不如搜得到。
最后给一个实操建议:先拿你们团队自己的一两个数据源做接入,不要一上来就铺十个源。两个源足够暴露分块、权限、去重这三类问题,而这些问题在所有源上都是共通的。等这两个源的效果稳定了,再接第三个,成本会低很多。这套路径我在几个内部项目里都用过,节奏比一次性全量接入要稳得多。