feat: score-based search, LLM re-ranking endpoint, video title search, pipeline module

Core search changes:
- Replace RRF with score-based merge (max of semantic/keyword/identity)
- Add video title ILIKE search for brand/name queries (score 0.9)
- Add /api/v1/search/llm-smart endpoint with Gemma 4 re-ranking
- Fix LLM JSON parsing (markdown fences, empty responses)

Infrastructure:
- Rebuild Qdrant collection (clear 347K contaminated points)
- Add dotenv loading to main.rs for config parity
- Implement store_pre_chunk in postgres_db.rs

Pipeline module (WordPress):
- store-asrx, rule1, vectorize, phase1, complete endpoints
- CLI commands for pipeline operations

Docs:
- SEARCH_SCORE_IMPROVEMENT.md (score-based merge proposal)
This commit is contained in:
Accusys
2026-06-04 07:40:41 +08:00
parent e1572907ae
commit 834b0d4865
14 changed files with 835 additions and 31 deletions

View File

@@ -0,0 +1,134 @@
# Search Scoring Improvement: Score-based Merge for search/smart
## 發現者
WordPress 前端專案search-chat 頁面)
## 問題描述
### 症狀
跨語言搜尋結果不一致:
- 搜尋「槍」(中文)→ 回傳無關結果如「讓T-shirt」、「靠直的後製神器」
- 搜尋 `gun`(英文)→ 回傳 "So where's your gun?"、"He has a gun"
- 兩者應該找到相同語意主題的結果(武器相關片段),但實際回傳完全不同的集合
### 影響範圍
`GET/POST /api/v1/search/smart` endpoint
## 根因分析
### 1. Qdrant 語意搜尋本身是正確的
直接查詢 Qdrant 驗證:
```
cos(search_query: 槍, search_document: "So where's your gun?") = 0.6905
cos(search_query: 槍, search_document: "這是一把槍") = 0.8256
cos(search_query: gun, search_document: "So where's your gun?") = 0.7435
```
**embedding model (EmbeddingGemma-300m) 的 cross-lingual 對齊正常。**
### 2. 問題在 RRF 合併邏輯
`search/smart`**RRF (Reciprocal Rank Fusion)** 合併三組結果:
```rust
let rrf_k = 60.0;
// RRF 貢獻 = 1 / (60 + rank + 1)
// Semantic rank 0: 貢獻 1/61 = 0.016
// Keyword rank 0: 貢獻 1/61 = 0.016
```
RRF 的權重只看**排名位置**,不看**實際相似度分數**。
- cosine similarity = 0.69 的語意結果 → RRF 貢獻 0.016
- ILIKE 隨便撈到的 keyword 匹配 → RRF 貢獻也是 0.016
- 兩者在排序中權重完全相等
### 3. Keyword (ILIKE) 對跨語言有害
- `ILIKE '%槍%'` 只找到中文文字包含「槍」的 chunks
- `ILIKE '%gun%'` 只找到英文文字包含 "gun" 的 chunks
- 這兩組結果在語意上完全不同,卻透過 RRF 被提升到與語意結果同權重
- 導致「槍」和 `gun` 的結果各自被自己的 ILIKE 匹配汙染
## 建議方案
### 核心原則
向量高信心度時應該優先。
### 合併方式
將 RRF 改為 score-based merge各來源分數定義
| 來源 | 分數 | 說明 |
|---|---|---|
| **Semantic (Qdrant)** | `cosine_similarity` (0~1) | 原始 Qdrant 分數,不加權 |
| **Identity** | 固定 `0.85` | 人名精準匹配,維持高度信心 |
| **Keyword (ILIKE)** | 固定 `0.5` | 降權至低分,只作為語意找不到時的補底 |
最終分數 = `max(semantic, keyword, identity)`
依最終分數降冪排序。
### 預期效果
| 情況 | 排序行為 |
|---|---|
| cosine > 0.5 的語意結果 | 排在 keyword 前面 ✅ |
| cosine 在 0.3~0.5 | 與 keyword 穿插(都不太確定,合理) |
| cosine < 0.3 | keyword 補底(語意沒找到,靠文字比對) |
| 跨語言查詢(槍 vs gun | 各自的高分 cross-lingual 結果優先呈現 ✅ |
### 不建議的方案
- **不要用 weight-based average**(如 `0.7*semantic + 0.3*keyword`):兩種模型的 score scale 不同,加權無法通用
- **不要保留 RRF 只調 k 值**k 值調再高也無法區分品質,只能稀釋影響
## 修改範圍
### 檔案
`src/api/search.rs` 中的 `smart_search()` 函數
### 需要修改的區塊
1. **移除 RRF 常數**`rrf_k = 60.0`
2. **Semantic 結果**:保留 Qdrant 回傳的 `score`(已在 `h.score as f64` 取得)
3. **Keyword 結果**:固定設為 `0.5_f64`(忽略原本 `combined_score`
4. **Identity 結果**:固定設為 `0.85_f64`(忽略原本硬編碼的 `0.85` 但保留值)
5. **排序邏輯**:改為 `max(semantic, keyword, identity)` 降冪
6. **輸出 similarity**:改為回傳最終分數,而非 `rrf_score`
### 注意事項
- Qdrant 回傳的 `score``f32`,需 cast 為 `f64`
- `keyword_results``combined_score` 實際上是 `1.0``search_bm25` 固定值),不應使用
- 修改後需 **`cargo build --release`** 再重啟 server
## 驗證測試
### 手動測試
```bash
# 1. 槍 vs gun 應該回傳相似主題
curl -X POST 'http://localhost:3002/api/v1/search/smart' \
-H 'X-API-Key: {KEY}' -H 'Content-Type: application/json' \
-d '{"query":"槍","limit":10}'
curl -X POST 'http://localhost:3002/api/v1/search/smart' \
-H 'X-API-Key: {KEY}' -H 'Content-Type: application/json' \
-d '{"query":"gun","limit":10}'
# 2. 確認 similarity 值為實際 cosine (e.g. 0.6~0.9) 而非 RRF 值 (~0.016)
```
### 預期結果
| Query | Top 結果應包含 |
|---|---|
| `槍` | gun 相關片段、「這是一把槍」、武器相關語意匹配 |
| `gun` | 與 `槍` 主題一致(都是武器) |
| `車` / `car` | 行車相關片段,非姓名含「車」的人物 |
| `So where's your gun?` | 自身為 top-1self-match cosine ≈ 1.0 |
## 附錄:前端處理
WordPress 側 (`snippet #37`) 已配合修正:`mode=semantic` 不再疊加 `search/universal`ILIKE結果僅回傳 `search/smart` 的輸出。這部分無需 backend 配合。