นากการ์ตูนสีมิ้นต์ใส่เอี๊ยมยีนส์ถือบัตรสองใบที่เกือบเหมือนกันส่องด้วยแว่นขยาย ข้างตู้บัตรรายการห้องสมุดและกล่องแนะนำที่มีลูกศรชี้ไปยังลิ้นชักที่ตรงกัน

Python difflib.get_close_matches: เดาคำที่ผู้ใช้พิมพ์ผิด แล้วถามว่า "หมายถึงอันนี้ไหม?"

น้องกายEngineering

เคยพิมพ์ git stauts แล้วได้คำตอบกลับมาว่า “หมายถึง status ใช่ไหม” ไหมครับ ฟีเจอร์เล็กๆ แบบนี้ช่วยให้คนใช้ไม่หงุดหงิด ใน Python ไม่ต้องลงไลบรารีเพิ่ม เพราะไลบรารีมาตรฐานมี difflib.get_close_matches ให้ใช้อยู่แล้ว

วันนี้ผมจะใช้มันกับ 2 งาน: แนะนำชื่อจังหวัดเมื่อพิมพ์ผิด และแนะนำคำสั่งในโปรแกรม CLI

WHY: ทำไมต้องแนะนำคำ ไม่ใช่ตอบ error เฉยๆ

  • ฟอร์มที่ตอบแค่ “ไม่พบจังหวัด” ทำให้คนต้องเดาเองว่าพิมพ์ผิดตรงไหน
  • คำไทยพิมพ์ผิดง่าย เช่น สลับ ใ กับ ไ หรือลืมไม้ไต่คู้
  • การ “แนะนำ” ดีกว่า “แก้ให้เลย” เพราะผู้ใช้ยังเป็นคนยืนยันเอง ไม่มีการเปลี่ยนข้อมูลแบบเงียบๆ

HOW: ฟังก์ชันนี้ทำงานยังไง

รูปแบบคือ get_close_matches(word, possibilities, n=3, cutoff=0.6)

  • word คือคำที่ผู้ใช้พิมพ์
  • possibilities คือรายการคำที่ถูกต้องทั้งหมด
  • n คือจำนวนคำแนะนำสูงสุด (ค่าเริ่มต้น 3)
  • cutoff คือคะแนนความเหมือนขั้นต่ำ ตั้งแต่ 0 ถึง 1 (ค่าเริ่มต้น 0.6) คำที่ได้คะแนนต่ำกว่านี้จะถูกตัดทิ้ง

เบื้องหลังมันใช้ SequenceMatcher ให้คะแนนด้วยค่า ratio() คิดง่ายๆ ว่าเป็นสัดส่วนตัวอักษรที่ตรงกันเมื่อเทียบสองคำ ผลลัพธ์เรียงจากคำที่เหมือนที่สุดก่อน

ตัวอย่างจริงตอนผมรัน: "เชียงไหม่" เทียบกับ "เชียงใหม่" ได้ ratio ราว 0.89 เพราะต่างกันแค่ตัวเดียว จึงผ่าน cutoff 0.6 สบายๆ

จุดที่ต้องรู้ก่อนใช้

  • ภาษาไทยเทียบทีละ “ตัวอักษร Unicode” สระบน สระล่าง และวรรณยุกต์นับเป็นตัวแยก คำสั้นมากๆ ผิดหนึ่งตัวจะได้คะแนนตกเยอะ
  • ถ้าตั้ง cutoff ต่ำไป จะได้คำแนะนำมั่วๆ ถ้าสูงไป คำที่พิมพ์ผิดนิดเดียวก็ไม่ถูกแนะนำ ค่าเริ่มต้น 0.6 เป็นจุดเริ่มที่ดี
  • คำที่พิมพ์ไม่ครบ เช่น "เชียง" จะได้ทั้ง เชียงราย และ เชียงใหม่ เป็นเรื่องดี ให้ผู้ใช้เลือกเอง แต่ถ้าดัน cutoff ขึ้นเป็น 0.8 จะไม่เหลือสักคำ
  • ไม่เจอคำที่ใกล้พอ จะได้ลิสต์ว่าง [] ต้องเตรียมข้อความตอบกลับกรณีนี้ด้วย
  • เทียบตัวพิมพ์เล็ก/ใหญ่ตรงตัว ถ้าเป็นภาษาอังกฤษ ควร .lower() ทั้งสองฝั่งก่อน
  • รายการคำหลายหมื่นคำ ฟังก์ชันนี้จะช้าลงเพราะต้องเทียบทุกคำ เหมาะกับรายการขนาดเล็กถึงกลาง เช่น จังหวัด เมนู หรือคำสั่ง

โค้ดพร้อม unittest

import difflib
import unittest

PROVINCES = ["กรุงเทพมหานคร", "เชียงใหม่", "เชียงราย", "ขอนแก่น",
             "ภูเก็ต", "นครราชสีมา", "สงขลา"]
COMMANDS = ["status", "commit", "checkout", "cherry-pick", "push", "pull"]


def suggest(word, choices, n=3, cutoff=0.6):
    """คืนคำที่ใกล้เคียง ถ้าพิมพ์ถูกอยู่แล้วคืนคำนั้นคำเดียว"""
    word = word.strip()
    if word in choices:
        return [word]
    return difflib.get_close_matches(word, choices, n=n, cutoff=cutoff)


def reply(word, choices):
    hits = suggest(word, choices, n=1)
    if not hits:
        return f"ไม่พบ '{word}' ลองพิมพ์ใหม่อีกครั้งครับ"
    if hits[0] == word.strip():
        return f"ตกลง: {word.strip()}"
    return f"หมายถึง '{hits[0]}' ใช่ไหมครับ?"


class SuggestTest(unittest.TestCase):
    def test_thai_typo(self):
        self.assertEqual(suggest("เชียงไหม่", PROVINCES), ["เชียงใหม่"])
        self.assertEqual(suggest("ภูเกต", PROVINCES), ["ภูเก็ต"])

    def test_exact_match_wins(self):
        self.assertEqual(suggest(" สงขลา ", PROVINCES), ["สงขลา"])

    def test_cutoff_controls_noise(self):
        # พิมพ์ไม่ครบ ได้ 2 ตัวเลือก ให้ผู้ใช้เลือกเอง
        self.assertEqual(sorted(suggest("เชียง", PROVINCES)), ["เชียงราย", "เชียงใหม่"])
        # cutoff สูงขึ้น ตัดคำที่เหมือนไม่พอทิ้ง
        self.assertEqual(suggest("เชียง", PROVINCES, cutoff=0.8), [])

    def test_cli_commands(self):
        self.assertEqual(suggest("stauts", COMMANDS), ["status"])
        self.assertEqual(suggest("chekcout", COMMANDS), ["checkout"])
        self.assertEqual(suggest("xyz", COMMANDS), [])

    def test_ratio_explains_score(self):
        ratio = difflib.SequenceMatcher(None, "เชียงไหม่", "เชียงใหม่").ratio()
        self.assertGreater(ratio, 0.85)

    def test_reply_messages(self):
        self.assertEqual(reply("เชียงไหม่", PROVINCES), "หมายถึง 'เชียงใหม่' ใช่ไหมครับ?")
        self.assertEqual(reply("สงขลา", PROVINCES), "ตกลง: สงขลา")
        self.assertTrue(reply("abc", PROVINCES).startswith("ไม่พบ"))


if __name__ == "__main__":
    unittest.main()

ตอนตรวจบทความ ผมรันโค้ดนี้ด้วย python3 -I -B บน Python 3.14 ของเครื่องนี้ เทสต์ผ่านทุกข้อ

ใช้ตรงไหนในงานจริง

งานตัวอย่าง
ฟอร์มที่อยู่พิมพ์จังหวัด/อำเภอผิด แล้วแนะนำชื่อที่ถูก
โปรแกรม CLIพิมพ์คำสั่งผิด แล้วถามว่าหมายถึงคำสั่งไหน
ช่องค้นหาเมนูร้านอาหารที่มีเมนูไม่กี่ร้อยรายการ
ไฟล์ configkey สะกดผิด เช่น timout แนะนำ timeout

ข้อควรระวัง: อย่าแก้ข้อมูลให้อัตโนมัติในงานที่ผิดแล้วเสียหาย เช่น ชื่อผู้รับโอนเงิน ให้ถามผู้ใช้ยืนยันทุกครั้ง

Recap

  • ลองใช้ difflib.get_close_matches ก่อนลงไลบรารี fuzzy matching ตัวใหญ่
  • เริ่มที่ cutoff=0.6 แล้วค่อยปรับตามข้อมูลจริง
  • เตรียมข้อความตอบเมื่อได้ลิสต์ว่าง
  • ภาษาอังกฤษให้ .lower() ทั้งสองฝั่งก่อนเทียบ
  • ให้ผู้ใช้ยืนยันคำที่แนะนำ อย่าแก้ข้อมูลเงียบๆ
  • ก๊อปเทสต์ด้านบนไปรันบน Python ของคุณ ดูว่าผลตรงกันไหม