搜索引擎返回的结果必须尊重访问权限——用户不该看到无权访问的文档,哪怕那个文档与查询高度相关。同时需要过滤垃圾页和限制恶意查询,防止低质量内容和滥用行为影响系统。

本篇在检索内部实现权限过滤,而不是在结果页面上隐藏。同时处理租户隔离、垃圾页过滤、查询限额和缓存泄漏。

文档级权限过滤

为什么必须在检索阶段过滤

错误做法:先检索 Top-10,再过滤掉无权限的文档。

问题:如果 Top-10 中有 8 条无权限,过滤后只剩 2 条结果。用户看到的结果数量不可预测,体验极差。更严重的是,排序位置本身就泄露了信息——"第一条结果被过滤了"告诉用户那个位置有内容存在。

正确做法:在 Lucene 检索遍历倒排列表和 HNSW 图时过滤。过滤后的文档不参与评分和排序,就像它们不存在一样。

权限模型

每个文档存储一个访问控制列表(ACL),记录哪些用户或角色可以访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void indexDocumentWithAcl(IndexWriter writer, String docId,
String title, String body, List<String> allowedRoles)
throws IOException {
Document doc = new Document();
doc.add(new StringField("id", docId, Field.Store.YES));
doc.add(new TextField("title", title, Field.Store.YES));
doc.add(new TextField("body", body, Field.Store.YES));

// 为每个允许的角色添加一个 term
for (String role : allowedRoles) {
doc.add(new StringField("acl", role, Field.Store.NO));
}
// 公开文档用特殊标记
if (allowedRoles.contains("public")) {
doc.add(new StringField("acl", "__public__", Field.Store.NO));
}

writer.updateDocument(new Term("id", docId), doc);
}

BM25 查询中的权限过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Query buildAclFilteredQuery(String queryText, List<String> userRoles) {
// 内容查询
Query contentQuery = parseQuery(queryText);

// ACL 过滤器:用户角色匹配或公开文档
BooleanQuery.Builder aclFilter = new BooleanQuery.Builder();
for (String role : userRoles) {
aclFilter.add(new TermQuery(new Term("acl", role)),
BooleanClause.Occur.SHOULD);
}
aclFilter.add(new TermQuery(new Term("acl", "__public__")),
BooleanClause.Occur.SHOULD);

// 组合:内容查询 + ACL 过滤
return new BooleanQuery.Builder()
.add(contentQuery, BooleanClause.Occur.MUST)
.add(aclFilter.build(), BooleanClause.Occur.FILTER) // FILTER 不影响评分
.build();
}

BooleanClause.Occur.FILTER 的作用:参与过滤但不参与评分。ACL 匹配不会给文档加分。

向量搜索中的权限过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TopDocs vectorSearchWithAcl(float[] queryVec, int topK,
List<String> userRoles, IndexSearcher searcher)
throws IOException {
// 构造 ACL 过滤器
BooleanQuery.Builder aclFilter = new BooleanQuery.Builder();
for (String role : userRoles) {
aclFilter.add(new TermQuery(new Term("acl", role)),
BooleanClause.Occur.SHOULD);
}
aclFilter.add(new TermQuery(new Term("acl", "__public__")),
BooleanClause.Occur.SHOULD);

// 向量查询带过滤器
KnnFloatVectorQuery knnQuery = new KnnFloatVectorQuery(
"embedding", queryVec, topK, aclFilter.build());

return searcher.search(knnQuery, topK);
}

Lucene 在 HNSW 图遍历时应用过滤器——跳过不符合条件的节点,继续搜索直到找到 K 个符合条件的邻居。

租户隔离

字段级隔离

多租户共享一个索引,用 tenant_id 字段区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void indexForTenant(IndexWriter writer, String tenantId,
String docId, String title, String body) throws IOException {
Document doc = new Document();
doc.add(new StringField("tenant_id", tenantId, Field.Store.YES));
doc.add(new StringField("id", docId, Field.Store.YES));
doc.add(new TextField("title", title, Field.Store.YES));
doc.add(new TextField("body", body, Field.Store.YES));
writer.updateDocument(new Term("id", tenantId + ":" + docId), doc);
}

Query buildTenantQuery(String queryText, String tenantId) {
Query contentQuery = parseQuery(queryText);
Query tenantFilter = new TermQuery(new Term("tenant_id", tenantId));

return new BooleanQuery.Builder()
.add(contentQuery, BooleanClause.Occur.MUST)
.add(tenantFilter, BooleanClause.Occur.FILTER)
.build();
}

每个查询都带 tenant_id 过滤——租户 A 看不到租户 B 的文档。

垃圾页过滤

索引时过滤

在写入索引前检查文档质量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class SpamFilter {
private final Set<String> urlBlacklist;
private static final int MIN_CONTENT_LENGTH = 50;
private static final double MAX_KEYWORD_DENSITY = 0.3;

boolean isSpam(String url, String body) {
// URL 黑名单
if (urlBlacklist.contains(extractDomain(url))) {
return true;
}

// 内容过短
if (body.length() < MIN_CONTENT_LENGTH) {
return true;
}

// 关键词密度异常
if (maxKeywordDensity(body) > MAX_KEYWORD_DENSITY) {
return true;
}

return false;
}

double maxKeywordDensity(String body) {
String[] words = body.split("\\s+");
if (words.length == 0) return 0;

Map<String, Integer> freq = new HashMap<>();
for (String w : words) {
freq.merge(w.toLowerCase(), 1, Integer::sum);
}

int maxFreq = freq.values().stream()
.mapToInt(Integer::intValue).max().orElse(0);
return (double) maxFreq / words.length;
}
}

索引流程集成

1
2
3
4
5
6
7
8
void indexIfNotSpam(String url, String title, String body,
IndexWriter writer, SpamFilter spamFilter) throws IOException {
if (spamFilter.isSpam(url, body)) {
System.out.printf("垃圾页已过滤: %s\n", url);
return;
}
indexPage(url, title, body, writer);
}

垃圾页不进入索引——索引更小,查询更快,结果更干净。

恶意查询限额

令牌桶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class QueryRateLimiter {
private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
private final int maxTokens;
private final int refillPerSecond;

QueryRateLimiter(int maxTokens, int refillPerSecond) {
this.maxTokens = maxTokens;
this.refillPerSecond = refillPerSecond;
}

boolean tryAcquire(String userId) {
TokenBucket bucket = buckets.computeIfAbsent(userId,
k -> new TokenBucket(maxTokens, refillPerSecond));
return bucket.tryConsume();
}

static class TokenBucket {
private double tokens;
private final int maxTokens;
private final int refillPerSecond;
private long lastRefillNanos;

TokenBucket(int maxTokens, int refillPerSecond) {
this.tokens = maxTokens;
this.maxTokens = maxTokens;
this.refillPerSecond = refillPerSecond;
this.lastRefillNanos = System.nanoTime();
}

synchronized boolean tryConsume() {
refill();
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}

private void refill() {
long now = System.nanoTime();
double elapsed = (now - lastRefillNanos) / 1e9;
tokens = Math.min(maxTokens, tokens + elapsed * refillPerSecond);
lastRefillNanos = now;
}
}
}

查询入口集成

1
2
3
4
5
6
7
8
9
10
11
12
SearchResult handleQuery(String queryText, String userId) {
if (!rateLimiter.tryAcquire(userId)) {
return SearchResult.rateLimited();
}

// 查询长度限制
if (queryText.length() > 200) {
return SearchResult.badRequest("查询过长");
}

return search(queryText, 10);
}

每用户每秒 10 次查询,突发允许 50 次。超过限额返回 429 Too Many Requests。

缓存泄漏

问题

查询结果缓存如果只用 queryText 做 key,用户 A 的查询结果会被返回给用户 B——即使 B 没有权限看到某些文档。

缓存 key 必须包含权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class SecureResultCache {
private final Map<String, SearchResult> cache = new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, SearchResult> e) {
return size() > 10000;
}
};

String cacheKey(String queryText, String userId, List<String> roles) {
// 包含用户身份和角色
String roleStr = String.join(",", roles.stream().sorted().toList());
return queryText + "|" + userId + "|" + roleStr;
}

SearchResult getOrSearch(String queryText, String userId,
List<String> roles, Supplier<SearchResult> searchFn) {
String key = cacheKey(queryText, userId, roles);
synchronized (cache) {
SearchResult cached = cache.get(key);
if (cached != null) return cached;
}

SearchResult result = searchFn.get();
synchronized (cache) {
cache.put(key, result);
}
return result;
}
}

embedding 缓存不受影响

query embedding 缓存只缓存查询文本到向量的映射——不包含文档信息,不存在权限泄漏。

验证

场景 操作 预期
权限过滤 用户 A 搜索,文档属于角色 B 文档不出现在结果中
公开文档 任何用户搜索公开文档 文档正常出现
租户隔离 租户 A 搜索 只看到租户 A 的文档
垃圾页 写入 30 字符文档 被过滤,不进入索引
限额 1 秒内发 100 次查询 前 50 次成功,后 50 次 429
缓存泄漏 A 搜索后 B 搜同一词 B 的结果不含 A 的私有文档

当前局限

  • ACL 粒度是文档级——不支持字段级权限
  • 垃圾页规则是硬编码——生产环境需要机器学习分类器
  • 令牌桶在单进程内——分布式场景需要 Redis 令牌桶
  • 缓存命中率下降——权限使 cache key 空间爆炸

练习

  1. 为文档添加 ACL 字段,验证权限过滤在 BM25 和向量搜索中都生效
  2. 实现租户隔离,验证跨租户不可见
  3. 构造垃圾页(超短内容、关键词堆砌),验证索引时被过滤
  4. 实现查询限额,用压测工具触发 429
  5. 测试缓存泄漏:用不同用户搜索同一查询,确认结果不同

延伸阅读

  • Elasticsearch: Document Level Security
  • Apache Lucene: FilteredQuery, BooleanClause.Occur.FILTER
  • OWASP: Broken Access Control