昨天我們為檢索管線掛上了由本地模型(gemma4:e4b)驅動的第二階段重排,並驗證了自然語言查詢的排名提升。
但在實際壓力測試中,本地端跑 4B/9B 等級的小型模型時,我們常會撞上三個「模型天性」:
[0, 1, 2, 3, 4, 5],甚至自己無中生有寫出不存在的候選。今天我們要深入打磨 Reranker Prompt,並在程式端建立一套「零容忍」的幻覺防線,確保本地模型只做好公正、沉默的排序裁判。
為了壓制本地模型的生成衝動,提示詞設計必須遵循以下原則:
閉合任務定義(Closed-World Ranking):嚴格限定模型只能在傳入的候選集合內進行排序,不准引入外部訓練記憶。
強迫負向約束(Negative Constraints):明文禁止「解釋程式碼」、「補齊函式」與「Markdown 裝飾」。
結構化錨點(Anchored Schema):在 Prompt 結尾提供固定的語法前導詞(如 Ranked JSON Array:),引導模型第一個生成的 Token 就是 [。
我們在 app/llm_reranker.py 中更新 Prompt 構建邏輯:
# app/llm_reranker.py (更新提示詞模板)
class LLMReranker:
# ... 省略 __init__ ...
def build_prompt(self, query: str, candidates: List[Dict[str, Any]]) -> str:
"""
為本地小模型高度優化的重排提示詞:
- 移除冗長客套話
- 採用緊湊編號
- 強制指定嚴格的輸出前綴
"""
candidate_blocks = []
for idx, item in enumerate(candidates):
# 限制每個候選長度,防止超出本地小模型 Context Window
snippet = item.get("content", "").strip()[:250]
candidate_blocks.append(
f"[ID {idx}] Path: {item.get('path')}:{item.get('start_line')}\n"
f"Code:\n{snippet}\n"
)
candidates_text = "\n".join(candidate_blocks)
max_idx = len(candidates) - 1
prompt = f"""[System: You are an unbiased code ranking engine. Your ONLY function is to output candidate IDs ordered by relevance to the query.]
[Input Query]
{query}
[Candidate Snippets]
{candidates_text}
[Strict Constraints]
1. Rank candidates from MOST relevant to LEAST relevant to the query.
2. Output MUST be a single line containing ONLY a JSON array of integer IDs.
3. Every ID in your output must be an integer between 0 and {max_idx}.
4. Do NOT output explanations, markdown backticks, apologies, or comments.
5. If no candidates are relevant, preserve the original order: {list(range(len(candidates)))}.
[Response]
Ranked JSON Array:"""
return prompt
不能把所有希望寄託在小模型的自律上。我們在 Python 端落實三道鋼鐵防禦:
模型原始輸出字串
│
▼
【第 1 道:前導字串剪裁】
清除 "Ranked JSON Array:" 與 markdown 標記
│
▼
【第 2 道:正則萃取與語法解析】
只抓取最外層完整的 [ ... ] 數字清單
│
▼
【第 3 道:集合論邊界過濾】
過濾重複值、剔除負數與越界值 (>= N)
│
▼
若輸出格式無效 ──(觸發 Fallback)──> 回退原第一階段排序
我們升級 _parse_json_indices 實作:
# app/llm_reranker.py (擴充嚴格驗證機制)
import json
import re
from typing import List, Any
class LLMReranker:
# ...
def _parse_json_indices(self, reply: str, total_candidates: int) -> List[int]:
"""
強健的防呆解析器:
1. 抵禦 Markdown 程式碼區塊包裹
2. 抵禦未依規定輸出多餘說明的文字
3. 剔除幻覺越界數字與重複項
"""
# 移除前導標籤與 Markdown 殘留
cleaned = reply.replace("Ranked JSON Array:", "").strip()
cleaned = re.sub(r"^```(?:json)?", "", cleaned).rstrip("`").strip()
# 尋找第一個完整的整數陣列特徵
match = re.search(r"\[(?:\s*\d+\s*,?)*\s*\]", cleaned)
if not match:
return []
try:
raw_list = json.loads(match.group(0))
if not isinstance(raw_list, list):
return []
except json.JSONDecodeError:
return []
# 嚴格過濾合法索引
valid_indices: List[int] = []
for item in raw_list:
if isinstance(item, int):
# 剔除負數與超出候選長度的幻覺數字
if 0 <= item < total_candidates and item not in valid_indices:
valid_indices.append(item)
# 檢查解析結果是否足夠合理
if not valid_indices:
return []
return valid_indices
tests/unit/test_reranker_prompt_guards.py在測試中模擬本地模型在日常維運時可能產生的各種極端回覆:
# tests/unit/test_reranker_prompt_guards.py
from app.llm_reranker import LLMReranker
def test_prompt_contains_closed_world_constraints():
reranker = LLMReranker()
candidates = [{"path": "a.py", "start_line": 1, "content": "pass"}]
prompt = reranker.build_prompt("test query", candidates)
assert "between 0 and 0" in prompt
assert "Ranked JSON Array:" in prompt
def test_guard_filters_hallucinated_extra_ids():
reranker = LLMReranker()
# 候選只有 3 筆 (0, 1, 2),模型卻幻覺出 3, 4, 100
hallucinated = "Ranked JSON Array: [2, 0, 3, 4, 1, 100]"
parsed = reranker._parse_json_indices(hallucinated, total_candidates=3)
assert parsed == [2, 0, 1]
def test_guard_filters_duplicate_ids():
reranker = LLMReranker()
# 模型產生重複 ID
duplicate_reply = "[1, 1, 0, 0, 2]"
parsed = reranker._parse_json_indices(duplicate_reply, total_candidates=3)
assert parsed == [1, 0, 2]
def test_guard_recovers_from_conversational_wrapping():
reranker = LLMReranker()
conversational = (
"Sure! Based on the query, candidate 1 is the best match.\n"
"```json\n[1, 0]\n```\nLet me know if you need anything else!"
)
parsed = reranker._parse_json_indices(conversational, total_candidates=2)
assert parsed == [1, 0]
執行測試確認防線完備:
uv run pytest tests/unit/test_reranker_prompt_guards.py -v
tests/unit/test_reranker_prompt_guards.py::test_prompt_contains_closed_world_constraints PASSED [ 25%]
tests/unit/test_reranker_prompt_guards.py::test_guard_filters_hallucinated_extra_ids PASSED [ 50%]
tests/unit/test_reranker_prompt_guards.py::test_guard_filters_duplicate_ids PASSED [ 75%]
tests/unit/test_reranker_prompt_guards.py::test_guard_recovers_from_conversational_wrapping PASSED [100%]
============================== 4 passed in 0.04s ==============================
使用 CLI 傳入容易誘發模型聊天的模糊提問,驗證輸出是否依然被拘束在緊湊的 Evidence 清單內:
uv run python -m app.cli search-rerank "這個專案主要在解決什麼問題?請詳細說明"
終端機輸出:
[Reranker Execution]
Ollama response received: "[0, 2, 1]" (Filtered: 0 duplicates, 0 out-of-bounds)
Fallback triggered: NO (clean parse)
Top Evidence:
1. src/build_index.py:1-60 (Reranked #1)
2. src/rag_common.py:1-25 (Reranked #2)
3. src/rag_chat.py:1-30 (Reranked #3)
模型沒有自作主張寫出一篇 500 字的專案自白,而是精準按照約束回傳 [0, 2, 1],讓系統乾淨提取出最具代表性的程式碼片段。
今天我們為本地小模型加上了工程防護籠:
防角色漂移:封閉式 Prompt 強迫模型保持沉默,只回傳純數字陣列。
防索引幻覺:集合邊界檢查過濾掉所有不存在的越界數字與重複項。
無損解析:即使輸出夾帶少許自然語言或 Markdown,正則萃取仍能穩定救回排序。
現在 Reranker 已經具備了工業級的穩定度。
明天,我們將把這套 LLM Reranker 接回 Day 17 的自動化評估管線,進行一場正面的 A/B 基準對決:
對比「純 Hybrid Search」vs「Hybrid + LLM Reranker」在全部題庫上的 MRR 與延遲表現,用真實數據驗證這套重排器究竟是否值得部署!