FST

Lucene索引与FST数据结构笔记

1. LuceneIndexBuilder类分析

主要功能

  • 从Hive下载数据
  • 构建搜索建议的Lucene索引
  • 上传索引到S3存储

索引构建流程

  1. 获取场景配置
  2. 从Hive下载数据
  3. 处理数据并构建索引
  4. 上传索引到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:构建FST
    • PositiveIntOutputs:定义输出类型
    • 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
相关推荐
小蒜学长3 小时前
汽车专卖店管理系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·汽车
FSHOW5 小时前
【独立开发日记】MQ端到端类型安全
前端·javascript·后端
柏油5 小时前
MySQL InnoDB 后台线程
数据库·后端·mysql
007php0076 小时前
Go 错误处理:用 panic 取代 err != nil 的模式
java·linux·服务器·后端·ios·golang·xcode
绝无仅有7 小时前
Go 面试题: new 和 make 是什么,差异在哪?
后端·面试·github
绝无仅有7 小时前
go语言面试之Goroutine 数量控制, GC回收 和任务调度
后端·面试·github
C++chaofan8 小时前
Spring Task快速上手
java·jvm·数据库·spring boot·后端·spring·mybatis
星星点点洲10 小时前
【Golang】 项目启动方法
开发语言·后端·golang
抹茶酸奶10 小时前
告别满屏的console.log!教你几种高级JavaScript调试技巧,效率直接拉满
后端