# Reasoning Retrieval: เราสอนให้การค้นหาโค้ดคิด ไม่ใช่แค่จับคู่

> ตอนนี้ Octocode มีขั้นตอนการให้เหตุผลด้วย LLM แบบเลือกเปิด ที่อ่านโค้ดที่ค้นเจอแล้วจัดอันดับใหม่ตามความเกี่ยวข้องจริง แล้วหลอมรวมกับ hybrid search ด้วย RRF บนเบนช์มาร์ก 127 คำค้น มันดัน MRR ขึ้น 36% และ Hit@5 เป็น 0.953 — ทุกเมตริกดีขึ้น นี่คือตัวเลข การจูน และสิ่งที่ไม่ได้ผล

Vector search จัดอันดับด้วยความคล้าย hybrid search จัดอันดับด้วยความคล้ายบวกการซ้อนทับของคีย์เวิร์ด แต่สิ่งที่คุณต้องการจริง ๆ จากการค้นหาโค้ดคือ **ความเกี่ยวข้อง** — และความคล้ายไม่ใช่ความเกี่ยวข้อง สนิปเป็ตที่มีโทเคนตรงกับคำค้นของคุณมากที่สุด บ่อยครั้งไม่ใช่โค้ดที่ตอบคำถามนั้น

ลองถามว่า "เราตัดสินใจตรงไหนว่าคำขอนี้ retry ได้" แล้วตัวจัดอันดับตามความคล้ายจะยินดียื่น struct คอนฟิกของ retry, ค่าคงที่ของ retry และเทสต์ที่มีคำนั้นอยู่สี่ครั้งให้คุณ ขณะที่ฟังก์ชันเดียวที่ตัดสินใจจริงนั่งอยู่อันดับเจ็ด โทเคนตรงกันหมด แต่ไม่มีอะไรตอบคำถามเลย

PageIndex เสนอประเด็นนี้ไว้สำหรับเอกสาร: แทนที่ top-k แบบความคล้ายด้วย LLM ที่ _ให้เหตุผล_ ว่าส่วนไหนเกี่ยวข้อง เราอยากได้แบบเดียวกันสำหรับโค้ด ซึ่งโครงสร้างรวยกว่าสารบัญของ PDF มาก — มีสัญลักษณ์ มีกราฟการเรียก มีตัวโค้ดจริง ๆ

เราจึงสร้างมันขึ้นมา: ขั้นตอนการให้เหตุผลแบบเลือกเปิด ที่วางไว้หลัง hybrid retrieval อ่านโค้ดของผู้เข้าชิง แล้วจัดอันดับใหม่ตามว่ามันตอบคำค้นจริงหรือไม่ มันปิดอยู่โดยค่าเริ่มต้น เปิดด้วยแฟล็กในคอนฟิกอันเดียว และปล่อยให้ `structural_search` เป็น grep แบบกำหนดผลได้แน่นอนอย่างที่ควรเป็น บนเบนช์มาร์กของเรา นี่คือชัยชนะก้อนใหญ่ที่ดีขึ้นทุกด้าน โพสต์นี้คือบันทึกแบบตรงไปตรงมา — ตัวเลข วิธีที่เราจูน และสองอย่างที่ _ไม่_ ได้ผล

---

## แนวคิดหลัก และทริกเดียวที่ทำให้มันใช้ได้

ไปป์ไลน์สั้นมาก คำค้นวิ่งผ่าน hybrid retrieval เหมือนเดิมทุกอย่าง — ความคล้ายเชิงเวกเตอร์บวกการจับคู่คีย์เวิร์ดบนดัชนีในเครื่อง ผู้เข้าชิงอันดับต้น ๆ กลับมาพร้อมตัวโค้ดเต็ม LLM อ่านมันแล้วจัดอันดับตามว่าตอบคำถามหรือไม่ จากนั้นอันดับสองชุดถูกหลอมรวมกันและคืนผลลัพธ์อันดับต้นออกไป ไม่มีดัชนีใหม่ ไม่มีการทำ embedding รอบสอง ไม่ต้อง reindex

เวอร์ชันตรงไปตรงมาของขั้นตอนตรงกลางนั้นชัดเจนอยู่แล้ว: เอาผู้เข้าชิงมา ถามว่า "อันไหนตอบคำค้นบ้าง เรียงมา" แล้วใช้ลำดับนั้น เราทำแบบนั้นก่อน และมันคือหายนะสำหรับ recall

นี่คือตัวจัดอันดับใหม่แบบใช้เหตุผลล้วนบนคำค้น 127 รายการ:

| เมตริก    | hybrid อย่างเดียว | เหตุผลล้วน | Δ          |
| --------- | ----------------- | ---------- | ---------- |
| MRR       | 0.595             | 0.752      | **+0.157** |
| NDCG@10   | 0.658             | 0.758      | **+0.100** |
| Hit@10    | 0.913             | 0.843      | **−0.071** |
| Recall@10 | 0.886             | 0.811      | **−0.075** |

เมตริกด้านการจัดอันดับพุ่งขึ้น — คำตอบที่ถูกต้องถูกดึงขึ้นมาบนสุด แต่ Hit@10 กับ Recall@10 _ตก_ เพราะโมเดลตัดทิ้ง มันคืนเฉพาะไม่กี่รายการที่มันตัดสินว่าเกี่ยวข้อง แล้วโยนที่เหลือทิ้งอย่างเงียบ ๆ รวมถึงผลที่ถูกต้องซึ่งนั่งอยู่อันดับ 6–10 ตัวจัดอันดับใหม่ที่เพิ่ม precision ด้วยการทิ้ง recall ไม่ใช่ตัวที่คุณจะปล่อยขึ้นโปรดักชัน มันดูเก่งกาจกับคำค้นที่มันทายถูก และทำให้คำค้นยาก ๆ กลายเป็นไม่มีคำตอบ

วิธีแก้เล็กมาก และมันคือเหตุผลทั้งหมดที่ทำให้เรื่องนี้เวิร์ก: **อย่าให้ LLM มาแทนที่การจัดอันดับ — หลอมรวมมันกับอันดับจาก hybrid ด้วย Reciprocal Rank Fusion**

```
score(candidate) = 1 / (k + hybrid_rank)
                 + reasoning_weight * 1 / (k + reasoning_rank)
```

`k` คือค่าคงที่หน่วงมาตรฐานของ RRF ที่กันไม่ให้อันดับตำแหน่งใดตำแหน่งหนึ่งครอบงำผลรวม ประเด็นสำคัญคือแต่ละพจน์ทำอะไร อันดับจาก hybrid มีส่วนร่วมเสมอ มันจึงทำหน้าที่เป็นพื้นของ recall — ผลที่ถูกต้องซึ่ง LLM ลดอันดับหรือมองข้ามจะถูกดันลง ไม่ใช่ถูกลบ ส่วนอันดับจากการให้เหตุผลพร้อมน้ำหนักเป็นตัวกำหนดหัวของลิสต์ คุณได้ผลดีด้านการจัดอันดับไว้ และหยุดเลือด recall ไปพร้อมกัน

การเปลี่ยนแค่จุดเดียวนั้นเปลี่ยนการแลกได้แลกเสียของเหตุผลล้วนให้กลายเป็นชัยชนะสะอาด ๆ ในทุกเมตริก

---

## ตัวเลข

เบนช์มาร์ก: คำค้น 127 รายการที่กำกับด้วยมือ ทดสอบกับโค้ดเบส Octocode ที่ตรึงไว้ที่คอมมิตหนึ่ง (มาตรฐาน 100 + คำค้นที่ตั้งใจทำให้ยาก 27 ซึ่งใช้ภาษาธรรมชาติโดยไม่มีคีย์เวิร์ดซ้อนทับเลย) ใช้ embedding ในเครื่องด้วย `fastembed`, เปิด hybrid search, คำค้นชุดเดียวกันทั้งสองฝั่ง และใช้ `deepseek:deepseek-v4-flash` เป็นโมเดลให้เหตุผล เกณฑ์ความถูกต้องคือการซ้อนทับของช่วงบรรทัดกับตำแหน่งในซอร์สที่ตรวจสอบแล้ว

| เมตริก    | hybrid อย่างเดียว | + การให้เหตุผล | Δ             |
| --------- | ----------------- | -------------- | ------------- |
| MRR       | 0.595             | **0.809**      | +0.214 (+36%) |
| NDCG@10   | 0.658             | **0.833**      | +0.175 (+27%) |
| Hit@5     | 0.827             | **0.953**      | +0.126        |
| Hit@10    | 0.913             | **0.969**      | +0.055        |
| Recall@5  | 0.777             | **0.924**      | +0.147        |
| Recall@10 | 0.886             | **0.944**      | +0.058        |

ทุกเมตริกดีขึ้น สองตัวที่สำคัญที่สุดสำหรับโจทย์ "หาโค้ดที่ทำ X ให้หน่อย" — MRR (ผลลัพธ์ถูกตัวแรกอยู่สูงแค่ไหน) และ NDCG@10 (ผลที่เกี่ยวข้อง _มากที่สุด_ ได้อยู่บนสุดหรือไม่) — ขยับขึ้น 36% และ 27% ส่วน Hit@5 ขึ้นจาก 0.83 เป็น 0.95: สิบเก้าในยี่สิบครั้ง คำตอบอยู่ในห้าอันดับแรกแล้ว

ตัวเลขสุดท้ายนี่แหละที่เปลี่ยนพฤติกรรมของเอเจนต์ เมื่อคำตอบอยู่ในห้าอันดับแรกอย่างเชื่อถือได้ เอเจนต์อ่านผลห้ารายการแล้วลงมือทำ เมื่อไม่เป็นแบบนั้น เอเจนต์จะขยายการค้นหา อ่านไฟล์เพิ่ม เผาคอนเท็กซ์ไปกับผู้เข้าชิงที่ไม่เคยเกี่ยวข้อง และบางครั้งก็ยอมแพ้แล้วหันไป grep คุณภาพการจัดอันดับที่ต้นทางคือคอนเท็กซ์ที่ประหยัดได้ที่ปลายทาง

---

## เราจูนมันอย่างไร (และตรงไหนที่ "มากกว่า" คือแย่กว่า)

เราไม่ได้เดาค่าคอนฟิก เรากวาดค่าทีละมิติบนดัชนีชุดเดียวกัน

**น้ำหนักการให้เหตุผล** — การหลอมรวมจะเอนไปทาง LLM มากแค่ไหนเทียบกับพื้นของ hybrid กวาดที่ 1, 2, 3, 5 การจัดอันดับดีขึ้นตามน้ำหนักและนิ่งราว ๆ 2–3 ส่วน 5 ไม่ได้อะไรเพิ่มและเสีย recall ไปเล็กน้อย เพราะถึงจุดนั้นพื้นจาก hybrid แทบไม่มีความหมาย และคุณก็กลับไปเชื่อโมเดลตัวเดียวอีก บนเบนช์มาร์กเต็ม **2.0** คือค่าที่ดีที่สุดโดยรวม

**จะให้เหตุผลกับผู้เข้าชิงกี่รายการ** กวาดที่ 15, 25, 35, 40 มากกว่า _ไม่ได้_ ดีกว่า — 40 แย่กว่า 25 อย่างวัดได้ การป้อนผู้เข้าชิงเยอะขึ้นทำให้การตัดสินใจเจือจาง แถมเปลืองโทเคนกว่าเพื่อผลที่แย่กว่า **25** คือจุดที่พอดี

**โมเดลเห็นเนื้อหาของผู้เข้าชิงมากแค่ไหน** ข้อนี้ชี้ขาด เห็นแค่ลายเซ็น เห็นสนิปเป็ต หรือเห็นตัวโค้ดเต็ม:

| คอนเท็กซ์       | Hit@5     | MRR       | Recall@5  |
| --------------- | --------- | --------- | --------- |
| ลายเซ็น         | 0.933     | 0.806     | 0.900     |
| สนิปเป็ต        | 0.933     | 0.849     | 0.883     |
| **ตัวโค้ดเต็ม** | **1.000** | **0.857** | **0.967** |

_(ชุดย่อย 30 คำค้น ตัวเลขสัมบูรณ์บนชุดย่อยจะสูงกว่า แต่ประเด็นอยู่ที่ลำดับ)_

ตัวโค้ดเต็มชนะขาด โมเดลต้องเห็นโค้ดจริงถึงจะตัดสินความเกี่ยวข้องได้ — ชื่อกับลายเซ็นไม่พอ ตัวเลือก "ลายเซ็นอย่างเดียว" แย่ที่สุด และควรหยุดคิดตรงนี้สักหน่อย: มันเป็นตัวเลือกที่ถูกที่สุด เป็นตัวที่มือคุณจะคว้าเพื่อประหยัดโทเคน และมันโยนทิ้งข้อมูลที่ขั้นตอนการให้เหตุผลมีไว้เพื่อใช้พอดี

**อุณหภูมิของ LLM** เราเดาว่าค่าต่ำ (กำหนดผลได้แน่นอนกว่า) น่าจะจัดอันดับดีกว่า ผิด — อุณหภูมิ 1.0 ชนะทั้ง 0.3 และ 0.0 โมเดลให้เหตุผลได้ดีกว่าเมื่อสุ่มแบบปกติ

---

## สิ่งที่ไม่ได้ผล: คำอธิบายเชิงบริบทตอนสร้างดัชนี

วิธีที่ชัดเจนในการดัน recall ขึ้นคือทำให้ดัชนีรวยขึ้น: ให้ LLM เขียนบรรทัดเดียวว่า "โค้ดนี้ทำอะไร" สำหรับทุกชิ้นส่วน แล้ว embed มันไปพร้อมกับโค้ด (contextual retrieval ของ Anthropic รายงานว่าการดึงข้อมูลที่ล้มเหลวลดลงมากด้วยวิธีนี้เป๊ะ ๆ) เรามีกลไกอยู่แล้ว จึงลองทดสอบ — reindex ใหม่ทั้งหมดพร้อมคำอธิบาย แล้ว A/B แบบเดิม

ผลออกมาแย่ลงเล็กน้อย:

|                                | Hit@5  | MRR    | NDCG@10 | Recall@10 |
| ------------------------------ | ------ | ------ | ------- | --------- |
| ดัชนีธรรมดา (+การให้เหตุผล)    | 0.953  | 0.809  | 0.833   | 0.944     |
| ดัชนีเชิงบริบท (+การให้เหตุผล) | 0.945  | 0.836  | 0.859   | 0.925     |
| Δ                              | −0.008 | +0.027 | +0.026  | −0.019    |

มันแลก recall ไปกับการจัดอันดับ: MRR กับ NDCG ขยับขึ้น แต่ Hit กับ Recall ขยับลง การเติมคำอธิบายจาก LLM ไว้ข้างหน้าทำให้โทเคนของโค้ดเองเจือจางลงใน embedding และบนโค้ดที่สะอาดและมีโครงสร้างดีพร้อมตัว embed โค้ดที่ใช้ได้ นั่นทำให้ recall ไหลลง ส่วน recall ที่ Anthropic วัดได้นั้นมาจากงานร้อยแก้วและคลังข้อมูลผสม — ไม่ใช่แบบนี้ แล้วคุณค่าด้านการจัดอันดับที่ดัชนีเชิงบริบทจะเพิ่มให้ล่ะ? ขั้นตอนการให้เหตุผลเก็บมันไปหมดแล้ว การซ้อนดัชนีเชิงบริบทลงไปอีกจึงมีค่าตั้งแต่ "ซ้ำซ้อน" ไปจนถึง "เป็นโทษ"

เราจึงไม่ปล่อยมันออกไป มันปิดไว้แบบนั้น และควรพูดต้นทุนของการตัดสินใจนี้ให้ชัด: ดัชนีเชิงบริบทหมายถึงการเรียก LLM หนึ่งครั้งต่อชิ้นส่วน ทุกครั้งที่ reindex เพื่อผลลัพธ์ที่วัดแล้วแย่กว่า ต้องพูดตรง ๆ เพราะ "เติม contextual retrieval สิ" ถูกทำตาม ๆ กันไปทั่วแบบลัทธิคาร์โก้ — มันไม่ใช่ชัยชนะสากล และบนโค้ดมันอาจทำให้คุณเสียของ

---

## วิธีเปิดใช้งาน

การให้เหตุผลปิดอยู่โดยค่าเริ่มต้น (มันมีต้นทุนหนึ่งการเรียก LLM ต่อการค้นหา) เปิดได้ใน `config.toml`:

```toml
[search.reasoning]
enabled          = true
model            = "deepseek:deepseek-v4-flash"  # provider:model ใดก็ได้
max_candidates   = 25       # 25 ชนะ 40 — มากกว่านั้นทำให้เจือจาง
context_level    = "full"   # ตัวโค้ดเต็มชนะ; สนิปเป็ต/ลายเซ็นแย่กว่า
reasoning_weight = 2.0      # น้ำหนัก RRF เทียบกับพื้น recall ของ hybrid
final_top_k      = 10
```

ทุกพารามิเตอร์ถูกนิยามไว้ในเทมเพลต — คอนฟิกแบบเข้มงวด ไม่มีค่าเริ่มต้นซ่อนอยู่ในโค้ด `deepseek-v4-flash` ถูกและเร็ว ผู้ให้บริการเจ้าไหนก็ใช้ได้ และตัวเลขข้างบนคือสิ่งที่โมเดลราคาถูกทำได้ ไม่ใช่โมเดลระดับแนวหน้า มันทำงานเฉพาะในเส้นทางการค้นหาเชิงความหมาย — `structural_search` ยังเป็น grep ล้วน ไม่มี AI อย่างที่ควรเป็น ถ้าคุณอยากได้การค้นหาที่กำหนดผลได้แน่นอนและต้นทุนศูนย์ ก็ปิดแฟล็กไว้ แล้วทุกอย่างในระบบเดิมของคุณจะไม่เปลี่ยนเลย

---

## เพดานตามจริง

ตอนนี้เราอยู่ที่ Hit@5 0.953 การไปให้ถึง 1.0 บนเบนช์มาร์กนี้ไม่สมจริง — คำค้น 27 ข้อสุดท้ายตั้งใจทำมาให้โหด และเกณฑ์ความถูกต้องบางส่วนก็กำกวมจริง ๆ ช่องว่างที่เหลือไม่ใช่ recall ของกองผู้เข้าชิง (การทดสอบดัชนีเชิงบริบทยืนยันแล้วว่ากองผู้เข้าชิงครอบคลุมดีอยู่แล้ว) แต่เป็นคำค้นที่ยากจริง ๆ ตัว embedder พื้นฐานที่แรงกว่าในโปรดักชัน (โมเดล embedding ที่ทำมาเพื่อโค้ดโดยเฉพาะ แทน `fastembed` ในเครื่องที่เป็นค่าเริ่มต้น) จะยกพื้นขึ้นได้อีก แต่ตัววิธีการให้เหตุผลเองมาถึงจุดที่ดีที่สุดในทางปฏิบัติแล้ว

สิ่งที่เราชอบในผลลัพธ์นี้: มันคือการสังเคราะห์ของจริง hybrid search ให้ recall แบบถูกและกำหนดผลได้แน่นอน การให้เหตุผลให้ความเกี่ยวข้อง ในจุดที่คุณจ่ายค่า LLM อยู่แล้ว RRF หลอมทั้งสองเข้าด้วยกันเพื่อให้คุณไม่ต้องเลือก ไม่มีดัชนีใหม่ ไม่ต้องเขียนใหม่แบบไร้เวกเตอร์ ไม่ต้อง reindex — แฟล็กเดียว เรียก LLM หนึ่งครั้ง แล้วโค้ดที่ถูกต้องก็ขึ้นมาอยู่บนสุด

## คำถามที่พบบ่อย

**Reasoning retrieval คืออะไรในหนึ่งประโยค?**

ขั้นตอนแบบเลือกเปิดหลัง hybrid retrieval ที่ LLM อ่านตัวโค้ดของผู้เข้าชิง จัดอันดับตามว่าตอบคำค้นหรือไม่ แล้วอันดับนั้นถูกหลอมรวมกับอันดับของ hybrid ด้วย Reciprocal Rank Fusion แทนที่จะเข้าไปแทนที่มัน

**มันมาแทน vector หรือ hybrid search ไหม?**

ไม่ และนั่นคือประเด็น การปล่อยให้ LLM ถือการจัดอันดับทำให้เราเสีย Recall@10 ไป 7 จุด อันดับจาก hybrid ยังอยู่ในคะแนนในฐานะพื้นของ recall ผลที่ถูกต้องซึ่งโมเดลมองข้ามจึงถูกลดอันดับ ไม่ใช่ถูกลบ

**ต้นทุนเท่าไร?**

เรียก LLM เพิ่มหนึ่งครั้งต่อการค้นหาเชิงความหมาย บนผู้เข้าชิงไม่เกิน 25 รายการ มันปิดอยู่โดยค่าเริ่มต้น และตัวเลขเบนช์มาร์กได้มาจาก `deepseek:deepseek-v4-flash` ซึ่งเป็นโมเดลถูกและเร็ว ใช้ `provider:model` ใดก็ได้ถ้าอยากจ่ายมากกว่านั้น

**มันทำให้ `structural_search` ช้าลงหรือเปลี่ยนไปไหม?**

ไม่ การให้เหตุผลทำงานเฉพาะในเส้นทางการค้นหาเชิงความหมาย `structural_search` ยังเป็น grep ที่กำหนดผลได้แน่นอน โดยไม่มี LLM อยู่ในวงจร

**ควรเปิดคำอธิบายเชิงบริบทตอนสร้างดัชนีด้วยไหม?**

สำหรับโค้ด ผลวัดของเราบอกว่าไม่: มันช่วย MRR กับ NDCG นิดหน่อย แต่เสีย Hit@5 กับ Recall@10 และผลด้านการจัดอันดับก็ถูกขั้นตอนการให้เหตุผลครอบไว้อยู่แล้ว แถมยังเพิ่มการเรียก LLM ต่อชิ้นส่วนในทุกครั้งที่ reindex

**ต้อง reindex ก่อนใช้ไหม?**

ไม่ต้อง การให้เหตุผลเป็นขั้นตอนตอนคิวรีที่วางทับดัชนีที่คุณมีอยู่แล้ว เปิดแฟล็ก รีสตาร์ต จบ

— Don

---

_[Octocode](https://github.com/Muvon/octocode) เป็นโอเพนซอร์สภายใต้ Apache-2.0 — มีทั้งเบนช์มาร์ก เกณฑ์ความถูกต้อง และผลดิบรวมอยู่ด้วย คุณจึงรันตัวเลขเหล่านี้ซ้ำบนโค้ดเบสของคุณเองได้ มันคือเอนจินค้นหาโค้ดที่อยู่เบื้องหลัง [Octomind](https://octomind.run) และทั้งสองคุยกันผ่านเซิร์ฟเวอร์ MCP_
