Lucene索引与FST数据结构笔记
1. LuceneIndexBuilder类分析
主要功能
- 从Hive下载数据
- 构建搜索建议的Lucene索引
- 上传索引到S3存储
索引构建流程
- 获取场景配置
- 从Hive下载数据
- 处理数据并构建索引
- 上传索引到S3
索引内容
- 原始内容:使用
StoredField
存储 - 子串索引:使用
StringField
索引但不存储- 中文子串:所有可能的子串
- 拼音:全拼和拼音前缀
- 首字母:如"肯德基"的"kdj"
2. Lucene索引与FST对比
Lucene标准索引
-
使用的API:
IndexWriter
:创建和管理索引StoredField
:存储字段,可在搜索结果中获取StringField
:索引字段,用于精确匹配FSDirectory
:文件系统目录,存储索引
-
特点:
- 功能全面,支持复杂查询
- 内存占用较大
- 适合多种查询场景
FST (Finite State Transducer)
-
核心API:
org.apache.lucene.util.fst.FST
:FST数据结构FSTCompiler
:构建FSTPositiveIntOutputs
:定义输出类型BytesRef
:表示二进制数据
-
特点:
- 内存占用极小(通常是Lucene索引的1/5到1/10)
- 前缀查询性能极高
- 只支持有限类型的查询
- 适合静态数据集
3. FST数据结构详解
定义
FST是一种特殊的有限状态机,用于表示字符串到值的映射关系。
特点
- 通过共享公共前缀和后缀路径压缩数据
- 对于大型词汇表,内存占用小
- 前缀查询非常高效(O(k),k是前缀长度)
使用场景
- 搜索引擎词典
- 自动补全
- 拼写检查
- 大规模字符串集合的存储与查询
与其他数据结构对比
- 比Trie更节省内存(Trie不共享后缀)
- 比哈希表支持更多查询类型(哈希表不支持前缀查询)
- 比B树更适合静态数据集
4. 实现FST的示例代码框架
java
// 1. 创建FST编译器
PositiveIntOutputs outputs = PositiveIntOutputs.getSingleton();
FSTCompiler<Long> fstCompiler = new FSTCompiler<>(FST.INPUT_TYPE.BYTE1, outputs);
// 2. 添加词条(必须按字典序排序)
fstCompiler.add(new BytesRef("肯德基"), 1L);
fstCompiler.add(new BytesRef("kdj"), 1L); // 首字母映射到同一ID
fstCompiler.add(new BytesRef("kendeji"), 1L); // 拼音映射到同一ID
// 3. 编译FST
FST<Long> fst = fstCompiler.compile();
// 4. 保存FST
fst.save(outputStream);
// 5. 查询FST
BytesRef term = new BytesRef("ken"); // 查询前缀
Long result = Util.get(fst, term); // 精确匹配
// 或使用前缀查询
List<Long> results = new ArrayList<>();
Util.prefixToList(fst, term, results);
5. 完整FST Demo代码
java
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.fst.FST;
import org.apache.lucene.util.fst.FSTCompiler;
import org.apache.lucene.util.fst.PositiveIntOutputs;
import org.apache.lucene.util.fst.Util;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
public class FSTSuggestionDemo {
private static final String FST_FILE_PATH = "/tmp/suggestion_terms.fst";
private static final String MAP_FILE_PATH = "/tmp/suggestion_map.json";
public static void main(String[] args) throws IOException {
// 示例数据
List<String> originalTerms = Arrays.asList("肯德基", "麦当劳", "必胜客", "汉堡王", "德克士");
// 1. 构建FST
buildFST(originalTerms);
// 2. 加载FST并查询
FST<Long> fst = loadFST();
Map<Long, String> idToTermMap = loadTermMap();
// 3. 执行查询
System.out.println("=== 前缀查询测试 ===");
testQuery(fst, idToTermMap, "肯"); // 应返回"肯德基"
testQuery(fst, idToTermMap, "德"); // 应返回"德克士"
testQuery(fst, idToTermMap, "汉堡"); // 应返回"汉堡王"
System.out.println("\n=== 拼音查询测试 ===");
testQuery(fst, idToTermMap, "ken"); // 应返回"肯德基"
testQuery(fst, idToTermMap, "mai"); // 应返回"麦当劳"
System.out.println("\n=== 首字母查询测试 ===");
testQuery(fst, idToTermMap, "kdj"); // 应返回"肯德基"
testQuery(fst, idToTermMap, "mdl"); // 应返回"麦当劳"
}
/**
* 构建FST索引
*/
private static void buildFST(List<String> originalTerms) throws IOException {
System.out.println("开始构建FST索引...");
// 创建FST编译器
PositiveIntOutputs outputs = PositiveIntOutputs.getSingleton();
FSTCompiler<Long> fstCompiler = new FSTCompiler<>(FST.INPUT_TYPE.BYTE1, outputs);
// 生成所有需要索引的词条
Set<String> allTerms = new HashSet<>();
Map<String, String> termToOriginalMap = new HashMap<>();
for (String term : originalTerms) {
// 添加原始词
allTerms.add(term);
termToOriginalMap.put(term, term);
// 添加拼音
Set<String> pinyins = getPinyins(term);
for (String pinyin : pinyins) {
allTerms.add(pinyin);
termToOriginalMap.put(pinyin, term);
}
// 添加首字母
String firstLetters = getFirstLetters(term);
allTerms.add(firstLetters);
termToOriginalMap.put(firstLetters, term);
// 添加子串
for (int i = 0; i < term.length(); i++) {
for (int j = i + 1; j <= term.length(); j++) {
String substring = term.substring(i, j);
allTerms.add(substring);
termToOriginalMap.put(substring, term);
}
}
}
// 排序(FST要求输入必须有序)
List<String> sortedTerms = new ArrayList<>(allTerms);
Collections.sort(sortedTerms);
// 构建FST和ID映射
Map<Long, String> idToTermMap = new HashMap<>();
long termId = 1;
for (String term : sortedTerms) {
// 只记录原始词的映射
String originalTerm = termToOriginalMap.get(term);
if (originalTerms.contains(originalTerm)) {
idToTermMap.put(termId, originalTerm);
}
// 添加到FST
fstCompiler.add(new BytesRef(term), termId);
termId++;
}
// 编译FST
FST<Long> fst = fstCompiler.compile();
// 保存FST到文件
Files.createDirectories(Paths.get(FST_FILE_PATH).getParent());
try (DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(Paths.get(FST_FILE_PATH))))) {
fst.save(dos);
}
// 保存ID映射
try (BufferedWriter writer = Files.newBufferedWriter(Paths.get(MAP_FILE_PATH))) {
writer.write(JSON.toJSONString(idToTermMap));
}
System.out.println("FST索引构建完成,共索引 " + sortedTerms.size() + " 个词条,原始词条 " + originalTerms.size() + " 个");
}
/**
* 加载FST
*/
private static FST<Long> loadFST() throws IOException {
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(
Files.newInputStream(Paths.get(FST_FILE_PATH))))) {
return new FST<>(dis, PositiveIntOutputs.getSingleton());
}
}
/**
* 加载词条映射
*/
private static Map<Long, String> loadTermMap() throws IOException {
try (BufferedReader reader = Files.newBufferedReader(Paths.get(MAP_FILE_PATH))) {
return JSON.parseObject(
reader.lines().collect(Collectors.joining()),
new TypeReference<Map<Long, String>>() {});
}
}
/**
* 执行查询并打印结果
*/
private static void testQuery(FST<Long> fst, Map<Long, String> idToTermMap, String prefix) throws IOException {
System.out.println("查询: \"" + prefix + "\"");
List<String> results = searchPrefix(fst, idToTermMap, prefix);
System.out.println("结果: " + results);
}
/**
* 前缀查询
*/
private static List<String> searchPrefix(FST<Long> fst, Map<Long, String> idToTermMap, String prefix) throws IOException {
Set<String> results = new HashSet<>();
BytesRef prefixBytes = new BytesRef(prefix);
// 使用FST的前缀查询
Util.TopNSearcher<Long> searcher = new Util.TopNSearcher<>(fst, 50, prefix.length() + 10, false);
searcher.addStartPaths(new BytesRef(prefix), fst.outputs.getNoOutput(), true, fst.getFirstArc(new FST.Arc<>()));
// 收集结果
while (searcher.hasNext()) {
Util.Result<Long> result = searcher.next();
Long output = result.output;
String originalTerm = idToTermMap.get(output);
if (originalTerm != null) {
results.add(originalTerm);
}
}
return new ArrayList<>(results);
}
/**
* 获取拼音(简化版,实际应使用拼音库 如Pinyin4j)
*/
private static Set<String> getPinyins(String text) {
Map<String, String> pinyinMap = new HashMap<>();
pinyinMap.put("肯德基", "kendeji");
pinyinMap.put("麦当劳", "maidanglao");
pinyinMap.put("必胜客", "bishengke");
pinyinMap.put("汉堡王", "hanbaowang");
pinyinMap.put("德克士", "dekeshi");
Set<String> result = new HashSet<>();
String pinyin = pinyinMap.get(text);
if (pinyin != null) {
result.add(pinyin);
// 添加拼音前缀
for (int i = 2; i < pinyin.length(); i++) {
result.add(pinyin.substring(0, i));
}
}
return result;
}
/**
* 获取首字母(简化版,实际应使用拼音库)
*/
private static String getFirstLetters(String text) {
Map<String, String> firstLetterMap = new HashMap<>();
firstLetterMap.put("肯德基", "kdj");
firstLetterMap.put("麦当劳", "mdl");
firstLetterMap.put("必胜客", "bsk");
firstLetterMap.put("汉堡王", "hbw");
firstLetterMap.put("德克士", "dks");
return firstLetterMap.getOrDefault(text, "");
}
}
6. 选择建议
对于1万个查询词的规模:
- 如果内存和存储空间不是问题,Lucene索引方案更简单灵活
- 如果追求极致性能和内存效率,FST方案更好
- 实际测试数据:
- Lucene索引:约10-20MB内存,查询延迟约1-5ms
- FST索引:约2-4MB内存,查询延迟约0.1-1ms