古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
引言:在古籍的海洋中精准导航
作为一款专注于古典文学学习的App,古文观芷需要处理从《诗经》到明清小说的海量古文数据。用户可能搜索一首诗、一位作者、一句名言、一个成语,甚至一段文化常识。如何在这个庞大的知识库中实现毫秒级精准搜索?这是我作为独立开发者面临的核心挑战。
经过深入分析和技术选型,我摒弃了传统的数据库搜索和云服务方案,自主研发了一套基于内存的搜索系统。这套系统不仅性能卓越,而且成本极低,完美契合个人开发项目的需求。
第一章:技术选型的深度思考
1.1 三种技术路线的对比分析
在项目初期,我系统评估了三种主流搜索方案:
方案一:MySQL全文搜索
/* by 01022.hk - online tools website : 01022.hk/zh/barcode.html */ -- 简单的实现方式 SELECT * FROM poems WHERE MATCH(title, content) AGAINST('李白' IN NATURAL LANGUAGE MODE);- 优点:开发简单,无需额外组件
- 缺点:性能差(查询耗时>100ms),分词效果差,不支持搜索多个关键字,无法支持复杂的古文分词需求
方案二:Elasticsearch
- 优点:功能强大,分布式扩展性好
- 缺点:
- 部署复杂,需要单独维护
- 内存占用高(基础部署>1GB)
- 云服务成本高(每月$50+)
- 对古文特殊字符支持不佳
方案三:自研内存搜索
- 优势分析:
- 数据量可控:古文总数约50万条,完全可加载到内存
- 只读特性:古文数据基本不变,无需实时更新
- 性能极致:内存操作比磁盘快1000倍以上
- 零成本:仅需服务器内存,无需额外服务
1.2 为什么最终选择自研方案?
数据特征决定了技术选型:
- 总量有限:古文作品不会无限增长,50万条是稳定上限
- 更新频率极低:古籍内容不会变更,每月更新<100条,内容更新后重启就行,基本不变,所有数据都是自读,没有并发读写
- 搜索维度多:需要支持标题、作者、内容、注释等多维度搜索,内容也是多个维度:诗文、作者、名句、成语、文化常识、歇后语等;搜索方式多位:文本搜索和拍照搜索
- 实时性要求高:用户期望"输入即得"的搜索体验
成本效益分析:
- Elasticsearch年成本:$600+,项目还没有收益,能省就省
- 自研方案年成本:$0(仅服务器内存)
- 性能对比:自研方案平均响应时间<0.1ms,ES平均>50ms
第二章:系统架构全景图
2.1 整体架构设计
┌─────────────────────────────────────────────────────────────┐ │ 古文观芷搜索系统架构 │ ├─────────────────────────────────────────────────────────────┤ │ 应用层 │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │综合搜索 │ │诗文搜索 │ │作者搜索 │ │成语搜索 │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ ├─────────────────────────────────────────────────────────────┤ │ 索引层 │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 倒排索引管理器 (searchMgr) │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ │ │诗文索引 │ │作者索引 │ │名句索引 │ │成语索引 │ │ │ │ │ │mPoemWord│ │mAuthor- │ │mSentence│ │mIdiom │ │ │ │ │ │ │ │ Word │ │ Word │ │ Index │ │ │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ ┌─────────┐ ┌─────────┐ │ │ │ │ │文化常识 │ │歇后语 │ │ │ │ │ │mCulture │ │mXhyWord │ │ │ │ │ │ Word │ │ │ │ │ │ │ └─────────┘ └─────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ ├─────────────────────────────────────────────────────────────┤ │ 数据层 │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │诗文数据 │ │作者数据 │ │成语数据 │ │名句数据 │ │ │ │50,000+ │ │5,000+ │ │30,000+ │ │10,000+ │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ ┌─────────┐ ┌─────────┐ │ │ │文化常识 │ │歇后语 │ │ │ │3,000+ │ │14,000+ │ │ │ └─────────┘ └─────────┘ │ └─────────────────────────────────────────────────────────────┘2.2 核心数据结构设计
/* by 01022.hk - online tools website : 01022.hk/zh/barcode.html */ // searchMgr - 搜索管理器(核心类) type searchMgr struct { // 1. 分词与过滤组件 jieba *gojieba.Jieba // 结巴分词器(高性能C++实现) pin *pinyin.Pinyin // 拼音转换器(支持多音字) mFilterWords map[string]bool // 停用词表(60+个字符) // 2. 六大内容索引(核心倒排索引) mPoemWord map[string][]uint32 // 诗文索引:15万+词条 mAuthorWord map[string][]uint32 // 作者索引:2万+词条 mSentenceWord map[string][]uint32 // 名句索引:3千+词条 mCultureWord map[string][]uint32 // 文化常识:2千+词条 mXhyWord map[string][]uint32 // 歇后语:1.4万+词条 // 3. 缓存与优化 searchFileName string // 索引缓存文件路径 hotQueryCache map[string][]uint32 // 热门查询缓存 queryStats map[string]int // 查询统计(用于优化) // 4. 数据引用(避免重复存储) poemList []*pb.EntityXsPoem // 诗文原始数据(只读引用) authorList []*pb.EntityXsAuthor // 作者原始数据 // ... 其他数据引用 }2.3 内存占用优化策略
数据规模统计:
- 总数据量:约50万条记录
- 原始数据大小:~300MB
- 索引数据大小:~100MB
- 总内存占用:~400MB(现代服务器完全可接受,服务器2G内存完全够用)
内存优化技巧:
- 使用uint32存储ID:最大支持42亿条记录,足够使用且节省空间
- 字符串驻留技术:相同字符串只存储一份
- 预分配容量:避免map动态扩容开销
- 压缩存储:对低频词使用更紧凑的存储格式
第三章:索引构建的艺术
3.1 并行构建:充分利用多核CPU
func (sm *searchMgr) initSearch() { // 预分配map容量,避免扩容 mPoemWord := make(map[string][]uint32, 154252) // 根据历史数据预估 mAuthorWord := make(map[string][]uint32, 21603) mSentenceWord := make(map[string][]uint32, 3429) mCultureWord := make(map[string][]uint32, 2700) mXhyWord := make(map[string][]uint32, 14032) var wg sync.WaitGroup wg.Add(6) // 6种内容类型并发构建 // 并发构建各种索引(充分利用多核) go sm.buildPoemIndexAsync(&wg, mPoemWord) go sm.buildAuthorIndexAsync(&wg, mAuthorWord) go sm.buildSentenceIndexAsync(&wg, mSentenceWord) go sm.buildCultureIndexAsync(&wg, mCultureWord) go sm.buildXhyIndexAsync(&wg, mXhyWord) go sm.buildIdiomIndexAsync(&wg) // 成语索引特殊处理 wg.Wait() // 合并结果到主索引 sm.mPoemWord = mPoemWord sm.mAuthorWord = mAuthorWord // ... 其他索引 sm.saveIndexToFile() // 序列化到文件供下次快速加载 runtime.GC() // 构建完成后立即GC,释放临时内存 }3.2 针对古文的分词优化
古文与现代汉语分词有很大不同,我实现了多级分词策略:
func (sm *searchMgr) tokenizeForAncientChinese(text string) []string { var tokens []string // 第一级:结巴分词(基础分词) words := sm.jieba.Cut(text, true) tokens = append(tokens, words...) // 第二级:按字符切分(应对分词器遗漏) runes := []rune(text) for i := 0; i < len(runes); i++ { token := string(runes[i]) if !sm.isStopWord(token) { tokens = append(tokens, token) } // 对2-4字词语,额外生成所有可能组合 for length := 2; length <= 4 && i+length <= len(runes); length++ { token := string(runes[i:i+length]) if sm.isMeaningfulToken(token) { tokens = append(tokens, token) } } } // 第三级:特殊处理(作者名、地名等) tokens = sm.specialTokenize(text, tokens) return removeDuplicates(tokens) }3.3 作者名智能分词
作者名搜索是高频需求,我实现了专门的优化:
func (sm *searchMgr) tokenizeAuthorName(name string) []string { tokens := []string{name} // 完整名字 runes := []rune(name) length := len(runes) // 根据名字长度采用不同策略 switch { case length == 3: // 单字名,如"操"(曹操) // 已包含完整名字 case length == 6: // 双字名,如"李白" tokens = append(tokens, string(runes[0:3]), // "李" string(runes[3:6]), // "白" name) // "李白" case length == 9: // 三字名,如"白居易" tokens = append(tokens, string(runes[0:3]), // "白" string(runes[3:6]), // "居" string(runes[6:9]), // "易" string(runes[0:6]), // "白居" string(runes[3:9]), // "居易" name) // "白居易" case length >= 12: // 多字名或带字、号,如"欧阳修(永叔)" // 提取主要部分 mainName := sm.extractMainName(name) tokens = append(tokens, mainName) tokens = append(tokens, sm.tokenizeAuthorName(mainName)...) } // 添加拼音支持 pinyins := sm.pin.Convert(name) tokens = append(tokens, pinyins...) return removeDuplicates(tokens) }3.4 停用词表的精心设计
古文中有大量虚词和常见字需要过滤:
func initStopWords() map[string]bool { stopWords := map[string]bool{ // 标点符号类(45个) "": true, " ": true, "\t": true, "\n": true, "\r": true, "。": true, ",": true, "!": true, "?": true, ";": true, ":": true, "「": true, "」": true, "『": true, "』": true, "【": true, "】": true, "〔": true, "〕": true, "(": true, ")": true, "《": true, "》": true, "〈": true, "〉": true, "―": true, "─": true, "-": true, "~": true, "‧": true, "·": true, "﹑": true, "﹒": true, ".": true, "、": true, "...": true, "……": true, "——": true, "----": true, // 常见虚词类(20个) "之": true, "乎": true, "者": true, "也": true, "矣": true, "焉": true, "哉": true, "兮": true, "耶": true, "欤": true, "尔": true, "然": true, "而": true, "则": true, "乃": true, "且": true, "若": true, "虽": true, "因": true, "故": true, // 数词和量词(10个) "一": true, "二": true, "三": true, "十": true, "百": true, "千": true, "万": true, "个": true, "首": true, "篇": true, // 其他高频无意义词 "曰": true, "云": true, "谓": true, "对": true, "曰": true, } // 动态调整:根据词频统计自动更新 if enableDynamicStopWords { stopWords = mergeDynamicStopWords(stopWords) } return stopWords }第四章:搜索算法的精妙设计
4.1 多级搜索策略
func (sm *searchMgr) Search(query *SearchQuery) *SearchResult { result := &SearchResult{} // 第1级:精确匹配(最高优先级) if exactMatches := sm.exactSearch(query); len(exactMatches) > 0 { result.ExactMatches = exactMatches } // 第2级:前缀匹配(次优先级) if prefixMatches := sm.prefixSearch(query); len(prefixMatches) > 0 { result.PrefixMatches = prefixMatches } // 第3级:包含匹配(一般优先级) if containMatches := sm.containSearch(query); len(containMatches) > 0 { result.ContainMatches = containMatches } // 第4级:拼音匹配(兜底方案) if len(result.All()) == 0 { if pinyinMatches := sm.pinyinSearch(query); len(pinyinMatches) > 0 { result.PinyinMatches = pinyinMatches } } // 第5级:智能重试(针对长查询) if len(result.All()) == 0 && len(query.Text) >= 6 { result = sm.smartRetrySearch(query) } return result }4.2 成语搜索的黑科技
成语搜索需要支持任意位置匹配,我实现了特殊的子串索引:
type IdiomIndex struct { index map[string][]uint32 // 子串->成语ID idioms map[uint32]*IdiomDetail // ID->成语详情 charIndex map[rune][]uint32 // 单字索引(快速过滤) lengthIndex map[int][]uint32 // 长度索引(按成语长度分组) } func (idx *IdiomIndex) BuildIndex(idioms []*IdiomDetail) { for _, idiom := range idioms { id := idiom.ID text := idiom.Text // 如"画蛇添足" // 1. 添加到主索引 runes := []rune(text) for i := 0; i < len(runes); i++ { for j := i + 1; j <= len(runes); j++ { substr := string(runes[i:j]) idx.index[substr] = append(idx.index[substr], id) } } // 2. 添加到单字索引(用于快速过滤) for _, r := range runes { idx.charIndex[r] = append(idx.charIndex[r], id) } // 3. 按长度分组 length := len(runes) idx.lengthIndex[length] = append(idx.lengthIndex[length], id) // 4. 存储详情 idx.idioms[id] = idiom } // 优化:对结果去重和排序 idx.optimizeIndex() }古文观芷成语搜索技术简述
核心数据结构:全子串倒排索引
type IdiomIndex struct { // 主索引:所有子串 -> 成语ID列表 // 例:"画蛇添足"会索引所有子串:"画"、"蛇"、"添"、"足"、"画蛇"、"蛇添"... index map[string][]uint32 }1.子串全量索引法
- 原理:为每个成语生成所有可能的子串组合
- 算法复杂度:O(n²),但成语最长4字,实际O(16)
- 示例:"画蛇添足" → 索引"画"、"蛇"、"添"、"足"、"画蛇"、"蛇添"、"添足"、"画蛇添"...
2.搜索流程
func (idx *IdiomIndex) Search(substr string) []uint32 { // 直接map查找:O(1)时间复杂度 return idx.index[substr] // 如输入"画蛇" → 返回包含"画蛇"的所有成语ID }3.内存优化
- 使用
uint32存储ID(支持42亿条,足够) - 预分配容量,避免动态扩容
- 结果去重,避免重复成语
优势特点:
- 极速响应:直接内存map查找,<0.01ms
- 全面匹配:支持任意位置、任意长度子串
- 简单可靠:无复杂算法,代码简洁
- 零外部依赖:纯Go实现,部署简单
性能数据:
- 3万成语 → 约50万索引项
- 内存占用:~50MB
- 搜索速度:<0.1ms/次
- 并发能力:单机10000+ QPS
这就是为什么用户输入"画蛇"能秒级找到"画蛇添足"的技术原理。
4.3 OCR识别搜索优化
用户拍照识别古诗时,往往有识别错误,我设计了容错算法:
func (sm *searchMgr) SearchByOCR(ocrText string, maxDistance int) []*PoemResult { // 1. 分词 words := sm.jieba.Cut(ocrText, true) // 2. 统计每首诗被命中的次数 poemHitCount := make(map[uint32]int) meaningfulWords := make([]string, 0) for _, word := range words { if len([]rune(word)) <= 1 || sm.isStopWord(word) { continue // 过滤短词和停用词 } meaningfulWords = append(meaningfulWords, word) // 查找包含这个词的诗文 if poemIDs, exists := sm.mPoemWord[word]; exists { for _, id := range poemIDs { poemHitCount[id]++ } } // 模糊匹配:允许1-2个字的编辑距离 if maxDistance > 0 { fuzzyMatches := sm.fuzzyMatch(word, maxDistance) for _, id := range fuzzyMatches { poemHitCount[id]++ } } } // 3. 计算权重分数 type ScoredPoem struct { ID uint32 Score float64 } scoredPoems := make([]ScoredPoem, 0, len(poemHitCount)) for poemID, hitCount := range poemHitCount { poem := sm.getPoemByID(poemID) if poem == nil { continue } // 分数 = 命中次数 * 权重系数 score := float64(hitCount) // 增加长词的权重 for _, word := range meaningfulWords { if len([]rune(word)) >= 3 && containsPoemText(poem, word) { score += 0.5 } } // 考虑诗句位置权重(标题权重高于内容) if containsPoemTitle(poem, meaningfulWords) { score *= 1.5 } scoredPoems = append(scoredPoems, ScoredPoem{poemID, score}) } // 4. 排序并返回Top N sort.Slice(scoredPoems, func(i, j int) bool { return scoredPoems[i].Score > scoredPoems[j].Score }) return sm.buildResults(scoredPoems[:min(10, len(scoredPoems))]) }4.4 搜索结果排序算法
func (sm *searchMgr) rankResults(results []*SearchItem, query string) []*SearchItem { type ScoredItem struct { Item *SearchItem Score float64 } scoredItems := make([]ScoredItem, len(results)) queryRunes := []rune(query) for i, item := range results { score := 0.0 // 1. 完全匹配得分(最高) if item.Text == query { score += 1000 } // 2. 开头匹配得分(次高) if strings.HasPrefix(item.Text, query) { score += 500 } // 3. 长度相似性得分 itemRunes := []rune(item.Text) lengthDiff := abs(len(itemRunes) - len(queryRunes)) score += 50 / (float64(lengthDiff) + 1) // 4. 词频权重(TF-IDF简化版) wordFrequency := sm.calculateWordFrequency(item, query) score += wordFrequency * 10 // 5. 热度权重(热门内容优先) if item.ViewCount > 1000 { score += math.Log10(float64(item.ViewCount)) } // 6. 时间权重(新内容适当提升) if item.CreateTime > time.Now().Add(-30*24*time.Hour).Unix() { score += 10 } scoredItems[i] = ScoredItem{item, score} } // 排序 sort.Slice(scoredItems, func(i, j int) bool { return scoredItems[i].Score > scoredItems[j].Score }) // 返回排序后的结果 rankedItems := make([]*SearchItem, len(scoredItems)) for i, scored := range scoredItems { rankedItems[i] = scored.Item } return rankedItems }第五章:性能优化深度剖析
5.1 并发安全与性能平衡
只读架构的优势:
// 所有索引数据只读,无需锁保护 var SearchMgr = &searchMgr{ mPoemWord: make(map[string][]uint32), // 启动时初始化,之后只读 mAuthorWord: make(map[string][]uint32), // ... 其他索引 } // 搜索函数是纯函数,线程安全 func (sm *searchMgr) searchPoem(keyword string) []*PoemResult { // 直接读取,无锁开销 poemIDs := sm.mPoemWord[keyword] // O(1)时间复杂度 results := make([]*PoemResult, 0, len(poemIDs)) for _, id := range poemIDs { poem := sm.poemList[id] // 数组直接索引,O(1) if poem != nil { results = append(results, convertToResult(poem)) } } return results }5.2 内存优化实战
优化前:每个索引项都存储完整字符串
优化后:使用字符串驻留和整数ID
// 字符串驻留池 type StringPool struct { strings map[string]string // 原始->规范映射 ids map[string]uint32 // 字符串->ID映射 values []string // ID->字符串反向映射 } func (sp *StringPool) Intern(s string) uint32 { if id, exists := sp.ids[s]; exists { return id } // 新字符串,分配ID id := uint32(len(sp.values)) sp.values = append(sp.values, s) sp.ids[s] = id sp.strings[s] = s return id } // 使用字符串池优化后的索引 type OptimizedIndex struct { pool *StringPool index map[uint32][]uint32 // 字符串ID->内容ID列表 } func (oi *OptimizedIndex) Search(s string) []uint32 { strID := oi.pool.Intern(s) return oi.index[strID] }5.3 缓存策略的多层设计
type SearchCache struct { // L1缓存:热点查询结果(内存) l1Cache *lru.Cache // 最近最少使用,容量1000 // L2缓存:高频词索引(内存) l2HotWords map[string][]uint32 // L3缓存:持久化索引(文件) indexPath string // 查询统计 stats struct { totalQueries int64 l1Hits int64 l2Hits int64 l3Hits int64 } } func (sc *SearchCache) Get(query string) ([]uint32, bool) { sc.stats.totalQueries++ // 1. 检查L1缓存 if result, ok := sc.l1Cache.Get(query); ok { sc.stats.l1Hits++ return result.([]uint32), true } // 2. 检查L2缓存(高频词) if result, ok := sc.l2HotWords[query]; ok { sc.stats.l2Hits++ // 同时放入L1缓存 sc.l1Cache.Add(query, result) return result, true } // 3. 从L3(主索引)加载 if result := sc.loadFromIndex(query); result != nil { sc.stats.l3Hits++ // 放入L1和L2缓存 sc.l1Cache.Add(query, result) if sc.isHotWord(query) { sc.l2HotWords[query] = result } return result, true } return nil, false }5.4 性能监控与调优
type PerformanceMonitor struct { metrics struct { searchLatency prometheus.Histogram cacheHitRate prometheus.Gauge memoryUsage prometheus.Gauge queryPerSecond prometheus.Counter } history struct { dailyStats map[string]*DailyStat slowQueries []*SlowQueryLog } } func (pm *PerformanceMonitor) RecordSearch(query string, latency time.Duration, hitCache bool) { // 记录延迟 pm.metrics.searchLatency.Observe(latency.Seconds() * 1000) // 转换为毫秒 // 记录QPS pm.metrics.queryPerSecond.Inc() // 记录慢查询 if latency > 50*time.Millisecond { pm.history.slowQueries = append(pm.history.slowQueries, &SlowQueryLog{ Query: query, Latency: latency, Timestamp: time.Now(), }) // 保留最近1000条慢查询 if len(pm.history.slowQueries) > 1000 { pm.history.slowQueries = pm.history.slowQueries[1:] } } // 更新缓存命中率 if hitCache { // 计算并更新命中率 pm.updateCacheHitRate() } }第六章:实际效果与性能数据
6.1 性能基准测试
测试环境:
- CPU: 4核 Intel Xeon 2.5GHz
- 内存: 8GB
- Go版本: 1.19
- 数据量: 50万条古文记录
性能数据:
| 指标 | 数值 | 说明 |
|---|---|---|
| 索引构建时间 | 3.5秒 | 首次构建(并行优化) |
| 索引加载时间 | 0.8秒 | 从文件加载(后续启动) |
| 平均搜索延迟 | 3.2毫秒 | 50万条数据中搜索 |
| P99延迟 | 9.8毫秒 | 99%请求低于此值 |
| 内存占用 | 400MB | 包含所有数据和索引 |
| 并发QPS | 15,000+ | 4核CPU测试结果 |
| 缓存命中率 | 99%+ | 热点查询优化后 |
6.2 与竞品对比
| 特性 | 古文观芷(自研) | 某竞品(Elasticsearch) |
|---|---|---|
| 搜索响应时间 | 3.2ms | 45ms |
| 冷启动时间 | 0.8s | 3.5s |
| 内存占用 | 400MB | 2.5GB+ |
| 部署复杂度 | 单二进制文件 | 需要ES集群 |
| 运维成本 | 接近零 | 需要专业运维 |
| 年费用 | $0(仅服务器) | $600+(云服务) |
6.3 用户反馈数据
- 搜索成功率:98.7%(包含模糊匹配)
- 用户满意度:4.8/5.0(基于应用商店评价)
- 日活跃用户:50,000+
- 日均搜索量:1,200,000+次
- 峰值QPS:8,000+(考试季期间)
第七章:技术方案的普适性与扩展性
7.1 适用场景总结
这种自研内存搜索方案特别适合:
- 数据量有限:百万级以下数据量
- 更新频率低:日更新<1%的数据
- 性能要求高:需要毫秒级响应
- 成本敏感:个人或小团队项目
- 特定领域:需要深度定制分词和搜索逻辑
7.2 可扩展性设计
虽然当前设计是单机方案,但可以扩展为分布式,每台机器都是全量加载数据,全量索引
7.3 未来优化方向
- 向量搜索集成:结合BERT等模型实现语义搜索
- 个性化推荐:基于用户历史优化搜索排序
- 实时索引更新:支持增量更新而不重建全量索引
- 多语言支持:扩展支持古文注释的现代汉语翻译
- 语音搜索:集成语音识别,支持语音输入搜索
第八章:总结与启示
古文观芷的搜索方案是一个典型的技术务实主义案例。通过深入分析需求特点,我选择了一条不同于主流但极其有效的技术路线。这个方案证明了:
- 简单即有效:最直接的数据结构(map+slice)往往能提供最佳性能
- 定制化优势:针对特定领域深度优化的效果超过通用方案
- 成本意识:个人开发者需要精打细算,选择性价比最高的方案
- 性能为王:用户体验的核心是响应速度,技术应为体验服务
这套方案已经稳定运行两年多,服务了数百万用户,证明了其可靠性和优越性。对于面临类似场景的开发者,我建议:
- 深入分析需求:不要盲目选择技术,先理解数据特点和用户需求
- 勇于自研:当现有方案不够匹配时,自己动手可能是最好的选择
- 持续优化:从实际使用数据中学习,不断改进算法和实现
- 保持简洁:最简单的解决方案往往最可靠、最易维护
技术方案没有绝对的好坏,只有适合与否。古文观芷的搜索方案,正是"适合的才是最好的"这一理念的完美体现。
古文观芷-拍照搜古文功能:比竞品快10000倍