iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 19 篇

Day 19:Reranker 的提示詞工程與幻覺防線:讓本地小模型只做裁判

  • 分享至 

  • xImage
  •  

昨天我們為檢索管線掛上了由本地模型(gemma4:e4b)驅動的第二階段重排,並驗證了自然語言查詢的排名提升。

但在實際壓力測試中,本地端跑 4B/9B 等級的小型模型時,我們常會撞上三個「模型天性」:

  1. 角色漂移(Role Drift):看到使用者的自然語言問題(例如「如何設定資料庫路徑?」),模型忍不住開始用親切的語氣給出建置教學,完全忘記自己是來「排序」的。
  2. 索引幻覺(Index Hallucination):輸入的候選片段只有 4 筆(索引 0~3),輸出卻噴出 [0, 1, 2, 3, 4, 5],甚至自己無中生有寫出不存在的候選。
  3. Markdown 格式污染:即使在提示詞要求 JSON,模型仍常常自作主張加上 ````json`、換行縮排或結尾客套話(「希望這對您有幫助!」),導致嚴格的 JSON Parser 直接噴錯。

今天我們要深入打磨 Reranker Prompt,並在程式端建立一套「零容忍」的幻覺防線,確保本地模型只做好公正、沉默的排序裁判。

提示詞工程的三大硬規則

為了壓制本地模型的生成衝動,提示詞設計必須遵循以下原則:

  • 閉合任務定義(Closed-World Ranking):嚴格限定模型只能在傳入的候選集合內進行排序,不准引入外部訓練記憶。

  • 強迫負向約束(Negative Constraints):明文禁止「解釋程式碼」、「補齊函式」與「Markdown 裝飾」。

  • 結構化錨點(Anchored Schema):在 Prompt 結尾提供固定的語法前導詞(如 Ranked JSON Array:),引導模型第一個生成的 Token 就是 [。

打磨後的 Reranker 提示詞實作

我們在 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

程式端的多層幻覺防護網(Guardrails)

不能把所有希望寄託在小模型的自律上。我們在 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],讓系統乾淨提取出最具代表性的程式碼片段。

總結與下一步

今天我們為本地小模型加上了工程防護籠:

  1. 防角色漂移:封閉式 Prompt 強迫模型保持沉默,只回傳純數字陣列。

  2. 防索引幻覺:集合邊界檢查過濾掉所有不存在的越界數字與重複項。

  3. 無損解析:即使輸出夾帶少許自然語言或 Markdown,正則萃取仍能穩定救回排序。

現在 Reranker 已經具備了工業級的穩定度。

明天,我們將把這套 LLM Reranker 接回 Day 17 的自動化評估管線,進行一場正面的 A/B 基準對決:

對比「純 Hybrid Search」vs「Hybrid + LLM Reranker」在全部題庫上的 MRR 與延遲表現,用真實數據驗證這套重排器究竟是否值得部署!


上一篇
Day 18:讓本地 LLM 幫候選結果重新排序:LLM Reranker 與防呆降級機制
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言