搜索引擎返回的结果必须尊重访问权限——用户不该看到无权访问的文档,哪怕那个文档与查询高度相关。同时需要过滤垃圾页和限制恶意查询,防止低质量内容和滥用行为影响系统。
本篇在检索内部实现权限过滤,而不是在结果页面上隐藏。同时处理租户隔离、垃圾页过滤、查询限额和缓存泄漏。
文档级权限过滤
为什么必须在检索阶段过滤
错误做法:先检索 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));
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);
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);
return new BooleanQuery.Builder() .add(contentQuery, BooleanClause.Occur.MUST) .add(aclFilter.build(), BooleanClause.Occur.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 { 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) { 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 空间爆炸
练习
- 为文档添加 ACL 字段,验证权限过滤在 BM25 和向量搜索中都生效
- 实现租户隔离,验证跨租户不可见
- 构造垃圾页(超短内容、关键词堆砌),验证索引时被过滤
- 实现查询限额,用压测工具触发 429
- 测试缓存泄漏:用不同用户搜索同一查询,确认结果不同
延伸阅读
- Elasticsearch: Document Level Security
- Apache Lucene: FilteredQuery, BooleanClause.Occur.FILTER
- OWASP: Broken Access Control