|
| 1 | +# CBDB_NAME_LIST 全文索引使用指南 |
| 2 | + |
| 3 | +## 概述 |
| 4 | + |
| 5 | +为 `CBDB_NAME_LIST.name` 字段添加了全文索引,使用 MySQL 的 ngram 解析器以支持中文分词搜索。 |
| 6 | + |
| 7 | +## Migration 文件 |
| 8 | + |
| 9 | +```bash |
| 10 | +database/migrations/2025_11_09_143732_add_fulltext_index_to_cbdb_name_list_table.php |
| 11 | +``` |
| 12 | + |
| 13 | +## 执行 Migration |
| 14 | + |
| 15 | +```bash |
| 16 | +# 应用 migration |
| 17 | +php artisan migrate |
| 18 | + |
| 19 | +# 如果需要回滚 |
| 20 | +php artisan migrate:rollback --step=1 |
| 21 | +``` |
| 22 | + |
| 23 | +执行后将创建索引:`idx_name_fulltext` |
| 24 | + |
| 25 | +## 使用方法 |
| 26 | + |
| 27 | +### 1. 基本全文搜索 |
| 28 | + |
| 29 | +```php |
| 30 | +use Illuminate\Support\Facades\DB; |
| 31 | + |
| 32 | +// 搜索包含"張三"的姓名 |
| 33 | +$personIds = DB::table('CBDB_NAME_LIST') |
| 34 | + ->whereRaw('MATCH(name) AGAINST(? IN NATURAL LANGUAGE MODE)', ['張三']) |
| 35 | + ->distinct() |
| 36 | + ->pluck('c_personid'); |
| 37 | +``` |
| 38 | + |
| 39 | +### 2. 布尔模式搜索(更灵活) |
| 40 | + |
| 41 | +```php |
| 42 | +// 必须包含"張"且包含"三" |
| 43 | +$personIds = DB::table('CBDB_NAME_LIST') |
| 44 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', ['+張 +三']) |
| 45 | + ->distinct() |
| 46 | + ->pluck('c_personid'); |
| 47 | + |
| 48 | +// 包含"張"或"王" |
| 49 | +$personIds = DB::table('CBDB_NAME_LIST') |
| 50 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', ['張 王']) |
| 51 | + ->distinct() |
| 52 | + ->pluck('c_personid'); |
| 53 | + |
| 54 | +// 包含"張"但不包含"三" |
| 55 | +$personIds = DB::table('CBDB_NAME_LIST') |
| 56 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', ['+張 -三']) |
| 57 | + ->distinct() |
| 58 | + ->pluck('c_personid'); |
| 59 | + |
| 60 | +// 前缀搜索 |
| 61 | +$personIds = DB::table('CBDB_NAME_LIST') |
| 62 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', ['張*']) |
| 63 | + ->distinct() |
| 64 | + ->pluck('c_personid'); |
| 65 | +``` |
| 66 | + |
| 67 | +### 3. 相关性排序 |
| 68 | + |
| 69 | +全文搜索可以返回相关性评分: |
| 70 | + |
| 71 | +```php |
| 72 | +$results = DB::table('CBDB_NAME_LIST') |
| 73 | + ->selectRaw('c_personid, name, MATCH(name) AGAINST(? IN NATURAL LANGUAGE MODE) AS relevance_score', ['張三']) |
| 74 | + ->whereRaw('MATCH(name) AGAINST(? IN NATURAL LANGUAGE MODE)', ['張三']) |
| 75 | + ->orderByDesc('relevance_score') |
| 76 | + ->limit(20) |
| 77 | + ->get(); |
| 78 | +``` |
| 79 | + |
| 80 | +## 在 BiogMainRepository::namesByQuery() 中的应用 |
| 81 | + |
| 82 | +### 优化方案:结合三种搜索策略 |
| 83 | + |
| 84 | +```php |
| 85 | +static public function namesByQuery(Request $request, $num=20) |
| 86 | +{ |
| 87 | + $request->q = addslashes($request->q); |
| 88 | + $query = $request->q; |
| 89 | + |
| 90 | + if (!$query) { |
| 91 | + // 保持原有逻辑... |
| 92 | + return ...; |
| 93 | + } |
| 94 | + |
| 95 | + $queryLength = mb_strlen($query); |
| 96 | + |
| 97 | + // 策略选择 |
| 98 | + if ($queryLength >= 3) { |
| 99 | + // 长查询(3+ 字符):使用全文索引 |
| 100 | + // 优势:支持中间匹配,有相关性排序 |
| 101 | + $personIds = DB::table('CBDB_NAME_LIST') |
| 102 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', [$query . '*']) |
| 103 | + ->distinct() |
| 104 | + ->limit(500) |
| 105 | + ->pluck('c_personid') |
| 106 | + ->toArray(); |
| 107 | + } elseif ($queryLength == 2) { |
| 108 | + // 2字查询:使用前缀索引(最快) |
| 109 | + $personIds = DB::table('CBDB_NAME_LIST') |
| 110 | + ->where('name', 'like', $query . '%') |
| 111 | + ->distinct() |
| 112 | + ->limit(500) |
| 113 | + ->pluck('c_personid') |
| 114 | + ->toArray(); |
| 115 | + } else { |
| 116 | + // 单字查询:使用原有 BIOG_MAIN 查询(避免匹配过多) |
| 117 | + $names = BiogMain::select(...) |
| 118 | + ->leftJoin(...) |
| 119 | + ->where('BIOG_MAIN.c_name_chn', 'like', '%'.$query.'%') |
| 120 | + ->orWhere(...) |
| 121 | + ->paginate($num); |
| 122 | + |
| 123 | + $names->appends(['q' => $query])->links(); |
| 124 | + return $names; |
| 125 | + } |
| 126 | + |
| 127 | + if (empty($personIds)) { |
| 128 | + return response()->json(['data' => [], 'total' => 0]); |
| 129 | + } |
| 130 | + |
| 131 | + // 用 personid 列表查询完整信息 |
| 132 | + $names = BiogMain::select( |
| 133 | + 'BIOG_MAIN.c_personid', |
| 134 | + 'BIOG_MAIN.c_name_chn', |
| 135 | + 'BIOG_MAIN.c_name', |
| 136 | + 'DYNASTIES.c_dynasty_chn', |
| 137 | + 'BIOG_MAIN.c_index_year', |
| 138 | + 'ADDR_CODES.c_name_chn AS ADDR_c_name_chn', |
| 139 | + 'A1.c_alt_name_chn as c_alt_name_chn_zi', |
| 140 | + 'A2.c_alt_name_chn as c_alt_name_chn_hao' |
| 141 | + ) |
| 142 | + ->leftJoin('DYNASTIES', 'DYNASTIES.c_dy', '=', 'BIOG_MAIN.c_dy') |
| 143 | + ->leftJoin('ADDR_CODES', 'ADDR_CODES.c_addr_id', '=', 'BIOG_MAIN.c_index_addr_id') |
| 144 | + ->leftJoin('ALTNAME_DATA as A1', function($join) { |
| 145 | + $join->on('A1.c_personid', '=', 'BIOG_MAIN.c_personid') |
| 146 | + ->where('A1.c_alt_name_type_code', '=', 4); |
| 147 | + }) |
| 148 | + ->leftJoin('ALTNAME_DATA as A2', function($join) { |
| 149 | + $join->on('A2.c_personid', '=', 'BIOG_MAIN.c_personid') |
| 150 | + ->where('A2.c_alt_name_type_code', '=', 5); |
| 151 | + }) |
| 152 | + ->whereIn('BIOG_MAIN.c_personid', $personIds) |
| 153 | + ->orderBy('BIOG_MAIN.c_personid', 'ASC') |
| 154 | + ->groupBy('BIOG_MAIN.c_personid') |
| 155 | + ->paginate($num); |
| 156 | + |
| 157 | + $names->appends(['q' => $query])->links(); |
| 158 | + return $names; |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +## ngram 解析器说明 |
| 163 | + |
| 164 | +### 什么是 ngram? |
| 165 | + |
| 166 | +ngram 是一种将文本分解为连续 n 个字符片段的方法。MySQL 的 ngram 解析器默认使用 `ngram_token_size=2`(bigram),适合中文搜索。 |
| 167 | + |
| 168 | +### 示例 |
| 169 | + |
| 170 | +对于姓名 "張三豐": |
| 171 | +- ngram (n=2) 分词结果:`张三`, `三豐` |
| 172 | +- 这意味着搜索 "三豐"、"張三" 都能匹配到 "張三豐" |
| 173 | + |
| 174 | +### 配置 |
| 175 | + |
| 176 | +默认的 `ngram_token_size=2` 对中文姓名搜索已经很合适。如果需要调整: |
| 177 | + |
| 178 | +```sql |
| 179 | +-- 查看当前配置 |
| 180 | +SHOW VARIABLES LIKE 'ngram_token_size'; |
| 181 | + |
| 182 | +-- 修改需要在 my.cnf 中设置(需要重启 MySQL) |
| 183 | +[mysqld] |
| 184 | +ngram_token_size=2 |
| 185 | +``` |
| 186 | + |
| 187 | +## 性能特点 |
| 188 | + |
| 189 | +### 全文索引 vs B-Tree 索引 vs 全表扫描 |
| 190 | + |
| 191 | +| 搜索类型 | B-Tree (`LIKE "query%"`) | 全文索引 (`MATCH AGAINST`) | 全表扫描 (`LIKE "%query%"`) | |
| 192 | +|----------|--------------------------|----------------------------|------------------------------| |
| 193 | +| 前缀匹配 | ✅ 最快 (ms 级) | ✅ 快 (ms 级) | ❌ 慢 (秒级) | |
| 194 | +| 中间匹配 | ❌ 不支持 | ✅ 快 (ms 级) | ❌ 慢 (秒级) | |
| 195 | +| 相关性排序 | ❌ 不支持 | ✅ 支持 | ❌ 不支持 | |
| 196 | +| 布尔操作 | ❌ 不支持 | ✅ 支持 (+, -, *) | ❌ 不支持 | |
| 197 | +| 索引大小 | 小 | 较大 | - | |
| 198 | + |
| 199 | +### 推荐使用场景 |
| 200 | + |
| 201 | +1. **前缀匹配(2字)**:优先使用 B-Tree 索引 `LIKE "query%"` |
| 202 | + - 最快的查询方式 |
| 203 | + - 适合常见的姓名查询 |
| 204 | + |
| 205 | +2. **完整姓名(3+ 字)**:使用全文索引 `MATCH AGAINST` |
| 206 | + - 支持更灵活的匹配 |
| 207 | + - 提供相关性排序 |
| 208 | + |
| 209 | +3. **单字查询**:使用 BIOG_MAIN 直接查询 |
| 210 | + - 避免 CBDB_NAME_LIST 匹配过多结果 |
| 211 | + |
| 212 | +## 监控和优化 |
| 213 | + |
| 214 | +### 检查索引使用情况 |
| 215 | + |
| 216 | +```sql |
| 217 | +-- 查看全文索引信息 |
| 218 | +SHOW INDEX FROM CBDB_NAME_LIST WHERE Key_name = 'idx_name_fulltext'; |
| 219 | + |
| 220 | +-- 查看表大小 |
| 221 | +SELECT |
| 222 | + table_name, |
| 223 | + ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb, |
| 224 | + ROUND(data_length / 1024 / 1024, 2) AS data_mb, |
| 225 | + ROUND(index_length / 1024 / 1024, 2) AS index_mb |
| 226 | +FROM information_schema.tables |
| 227 | +WHERE table_schema = 'CBDB' AND table_name = 'CBDB_NAME_LIST'; |
| 228 | +``` |
| 229 | + |
| 230 | +### 性能测试示例 |
| 231 | + |
| 232 | +```php |
| 233 | +// 测试脚本 |
| 234 | +$queries = ['張', '張三', '蘇軾', '東坡居士']; |
| 235 | + |
| 236 | +foreach ($queries as $query) { |
| 237 | + echo "查询: {$query}\n"; |
| 238 | + |
| 239 | + // 方法1: B-Tree 前缀索引 |
| 240 | + $start = microtime(true); |
| 241 | + $result1 = DB::table('CBDB_NAME_LIST') |
| 242 | + ->where('name', 'like', $query . '%') |
| 243 | + ->distinct() |
| 244 | + ->pluck('c_personid'); |
| 245 | + $time1 = round((microtime(true) - $start) * 1000, 2); |
| 246 | + |
| 247 | + // 方法2: 全文索引 |
| 248 | + $start = microtime(true); |
| 249 | + $result2 = DB::table('CBDB_NAME_LIST') |
| 250 | + ->whereRaw('MATCH(name) AGAINST(? IN BOOLEAN MODE)', [$query . '*']) |
| 251 | + ->distinct() |
| 252 | + ->pluck('c_personid'); |
| 253 | + $time2 = round((microtime(true) - $start) * 1000, 2); |
| 254 | + |
| 255 | + echo " B-Tree: {$time1} ms, {$result1->count()} 结果\n"; |
| 256 | + echo " Fulltext: {$time2} ms, {$result2->count()} 结果\n\n"; |
| 257 | +} |
| 258 | +``` |
| 259 | + |
| 260 | +## 注意事项 |
| 261 | + |
| 262 | +1. **索引构建时间**:对于 200 万+ 条记录,创建全文索引可能需要几分钟时间 |
| 263 | +2. **磁盘空间**:全文索引会占用额外的磁盘空间(约为数据大小的 20-30%) |
| 264 | +3. **维护开销**:插入/更新操作会稍慢,因为需要更新索引 |
| 265 | +4. **最小搜索长度**:ngram token size 为 2,意味着单字符搜索可能不走全文索引 |
| 266 | + |
| 267 | +## 回滚方案 |
| 268 | + |
| 269 | +如果遇到问题,可以安全地删除全文索引: |
| 270 | + |
| 271 | +```bash |
| 272 | +php artisan migrate:rollback --step=1 |
| 273 | +``` |
| 274 | + |
| 275 | +或手动删除: |
| 276 | + |
| 277 | +```sql |
| 278 | +ALTER TABLE CBDB_NAME_LIST DROP INDEX idx_name_fulltext; |
| 279 | +``` |
| 280 | + |
| 281 | +删除索引不会影响数据,只会移除搜索加速功能。 |
| 282 | + |
| 283 | +## 参考资料 |
| 284 | + |
| 285 | +- [MySQL Full-Text Search Functions](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html) |
| 286 | +- [MySQL ngram Full-Text Parser](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search-ngram.html) |
| 287 | +- [Full-Text Search Boolean Mode](https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html) |
0 commit comments