นากการ์ตูนใส่ฮู้ดสีเขียวเทียบจิ๊กซอว์สองชิ้นที่หน้าตาต่างกัน ชิ้นหนึ่งเป็นชิ้นเดียว อีกชิ้นประกอบจากสองส่วน และเครื่องอัดการ์ตูนที่กดทั้งสองให้เป็นรูปเดียวกัน

Java Normalizer: ค้นหาชื่อไทยให้ตรงกัน เข้าใจ NFC กับ NFKC ก่อนใช้

น้องกายEngineering

ลูกค้าพิมพ์ชื่อเหมือนที่พนักงานเห็นในระบบ แต่ค้นหาไม่เจอ หรือระบบแจ้งว่ามีชื่อบัญชีซ้ำหลังคัดลอกจากอีกแอป ปัญหาอาจอยู่ที่ลำดับรหัส Unicode ซึ่งเป็นรหัสแทนตัวอักษร ไม่ใช่หน้าตาที่แสดงบนจอครับ บทความนี้จะสร้างคีย์สำหรับเทียบข้อความด้วย Java พร้อมเทสต์ให้เห็นว่าเทคนิคนี้ช่วยตรงไหน และกรณีชื่อไทยแบบไหนยังต้องมีนโยบายเพิ่ม

WHY: ข้อความที่ดูเหมือนกันอาจเทียบแล้วไม่เท่ากัน

การใช้ String.equals() กับข้อความดิบอาจไม่ตรงกับสิ่งที่ผู้ใช้มองว่าเป็นคำเดียวกัน เอกสาร Normalizer ยกตัวอย่าง Á ซึ่งเก็บได้ทั้งรหัสตัวเดียวและ A ตามด้วยเครื่องหมายเน้นเสียง การทำ normalization คือการแปลงข้อความให้อยู่ในรูปมาตรฐานที่เลือก เพื่อช่วยงานเปรียบเทียบและค้นหา

  • งานบริการลูกค้าควรค้นเจอข้อมูล แม้ชื่อที่รับเข้ามาจะมีรูปแทนตัวอักษรต่างกันในกรณีที่เรารองรับ
  • ข้อมูลคัดลอกอาจมี 123 แทน 123 การกำหนดกติกาเดียวกันทั้งตอนบันทึกและค้นหาช่วยลดความต่างนี้
  • งานตรวจรายการซ้ำต้องมีคีย์ที่อธิบายได้ พร้อมเก็บข้อความต้นฉบับไว้ตรวจสอบย้อนหลัง

อย่าเพิ่งสรุปว่าชื่อที่ได้คีย์เดียวกันคือคนเดียวกัน ชื่อเป็นเพียงตัวช่วยค้นหา การรวมประวัติลูกค้ายังต้องอาศัยข้อมูลยืนยันตามกติกาของงาน

ทำงานยังไง

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

Normalizer.Form มีสี่รูปแบบ คำว่า decomposition คือแยกองค์ประกอบ ส่วน composition คือประกอบกลับตามกฎ:

  • NFD: แยกแบบ canonical หรือความเทียบเท่าตามรูปมาตรฐาน
  • NFC: แยกแบบ canonical แล้วประกอบกลับแบบ canonical
  • NFKD: แยกแบบ compatibility ซึ่งครอบคลุมรูปที่มีไว้เพื่อความเข้ากันได้ด้วย
  • NFKC: แยกแบบ compatibility แล้วประกอบกลับแบบ canonical

จุดสำคัญของไทยคือ ำ หรือ U+0E33 มีการแยกแบบ compatibility ในกรณีที่ทดสอบ NFC และ NFD ยังเก็บ U+0E33 ไว้ ขณะที่ NFKC และ NFKD ให้ U+0E4D (นิคหิต) ตามด้วย U+0E32 (สระอา) จึงใช้ NFC อย่างเดียวเพื่อรวมสองรูปนี้ไม่ได้

แต่คำว่า “น้ำ” มีเรื่องลำดับวรรณยุกต์เพิ่มเข้ามา เทสต์ด้านล่างระบุรหัสอย่างชัดเจน: น + ้ + ำ ผ่าน NFKC แล้วได้ น + ้ + ํ + า ส่วน น + ํ + ้ + า ยังอยู่ในลำดับเดิม สองผลลัพธ์นี้ไม่เท่ากัน เอกสารอธิบายการจัดลำดับเครื่องหมายตามกฎ canonical แต่ไม่ได้หมายความว่าสลับเครื่องหมายไทยทุกแบบแล้วจะรวมกันได้

เราจะเลือก NFKC ตามด้วยตัดช่องว่างหัวท้ายและยุบช่องว่างสำหรับงานค้นหาตัวอย่างนี้ กติกาช่องว่างเป็นสิ่งที่เราเพิ่มเอง ไม่ใช่งานของ Normalizer และยังไม่รวมการแปลงตัวพิมพ์ใหญ่เล็กหรือแก้ลำดับวรรณยุกต์ไทยเอง

โค้ดตัวอย่างพร้อมเทสต์

บันทึกเป็น Main.java แล้วรัน javac -encoding UTF-8 Main.java ตามด้วย java -ea Main ต้องมี -ea เพื่อเปิดการตรวจ assert ตัวอย่างจำลองรายชื่อลูกค้าและรหัสเอกสาร เก็บชื่อเดิมแยกจากคีย์ค้นหา ใช้ escape ระบุรหัสในคู่ทดสอบที่ดูด้วยตาแล้วแยกยาก

import java.text.Normalizer;
import java.text.Normalizer.Form;
import java.util.List;
import java.util.Objects;

public class Main {
    static String normalizeForMatching(String text) {
        Objects.requireNonNull(text, "text");
        return Normalizer.normalize(text, Form.NFKC)
                .trim().replaceAll("\\s+", " ");
    }

    record Customer(String originalName, String searchKey) {
        static Customer from(String name) {
            return new Customer(name, normalizeForMatching(name));
        }
    }

    public static void main(String[] args) {
        boolean assertionsEnabled = false;
        assert assertionsEnabled = true;
        if (!assertionsEnabled) {
            throw new IllegalStateException("Run with java -ea Main");
        }

        String saraAm = "\u0E33";
        String splitAm = "\u0E4D\u0E32";
        for (Form form : List.of(Form.NFC, Form.NFD)) {
            assert Normalizer.normalize(saraAm, form).equals(saraAm);
            assert !Normalizer.normalize(saraAm, form)
                    .equals(Normalizer.normalize(splitAm, form));
        }
        for (Form form : List.of(Form.NFKC, Form.NFKD)) {
            assert Normalizer.normalize(saraAm, form).equals(splitAm);
            assert Normalizer.normalize(splitAm, form).equals(splitAm);
        }
        assert Normalizer.isNormalized(saraAm, Form.NFC);
        assert !Normalizer.isNormalized(saraAm, Form.NFKC);
        assert Normalizer.isNormalized(splitAm, Form.NFKC);

        String nam = "\u0E19\u0E49\u0E33"; // น + ้ + ำ
        String sameOrder = "\u0E19\u0E49\u0E4D\u0E32";
        String otherOrder = "\u0E19\u0E4D\u0E49\u0E32";
        assert Normalizer.normalize(nam, Form.NFKC).equals(sameOrder);
        assert Normalizer.normalize(otherOrder, Form.NFKC).equals(otherOrder);
        assert normalizeForMatching(nam).equals(normalizeForMatching(sameOrder));
        assert !normalizeForMatching(nam).equals(normalizeForMatching(otherOrder));

        assert Normalizer.normalize("\u00C1", Form.NFD).equals("A\u0301");
        assert Normalizer.normalize("A\u0301", Form.NFC).equals("\u00C1");
        assert normalizeForMatching("123ABC").equals("123ABC");
        assert normalizeForMatching("①").equals("1");
        assert !normalizeForMatching("ABC").equals(normalizeForMatching("abc"));
        assert normalizeForMatching(" \tสมชาย   ใจดี\n").equals("สมชาย ใจดี");
        assert normalizeForMatching("   ").isEmpty();

        String original = "  " + nam + "   ใจดี  ";
        List<Customer> customers = List.of(
                Customer.from(original), Customer.from("สมชาย ใจดี"));
        String query = normalizeForMatching(sameOrder + " ใจดี");
        List<Customer> found = customers.stream()
                .filter(c -> c.searchKey().equals(query)).toList();
        assert found.size() == 1;
        assert found.get(0).originalName().equals(original);
        assert normalizeForMatching(found.get(0).searchKey())
                .equals(found.get(0).searchKey());
        assert normalizeForMatching(" ใบสั่งซื้อ  123 ")
                .equals("ใบสั่งซื้อ 123");
        System.out.println("OK: all assertions passed");
    }
}

ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 ทั้งคอมไพล์และ java -ea Main แล้ว เทสต์ผ่านและพิมพ์ OK: all assertions passed ครับ

Normalizer.isNormalized(text, form) ใช้ตรวจว่าข้อความอยู่ในรูปที่เลือกแล้วหรือยัง ตาม เอกสารเมธอด ส่วน normalize() คืนข้อความที่แปลงแล้ว ตัวตรวจไม่ได้บอกว่าข้อความสะอาดตามกติกาธุรกิจ เช่น ช่องว่างที่เรายังต้องจัดเอง และทั้งสองเมธอดรับ null ไม่ได้

helper นี้ยอมให้ข้อความว่างได้ตามเทสต์ หากใช้สร้างบัญชีต้องตรวจความว่างหลังแปลงอีกขั้น ส่วน trim() กับ \s+ ในโค้ดตั้งใจรองรับช่องว่างทั่วไปตามตัวอย่าง เช่น space, tab และขึ้นบรรทัดใหม่ อย่าใช้เทสต์นี้เป็นหลักฐานว่าจัดการช่องว่าง Unicode ทุกชนิดแล้ว

จุดที่คนพลาดบ่อย

  • คิดว่า NFC รวมสระอำสองรูปเสมอ — ใช้เทสต์รหัส U+0E33 เทียบกับ U+0E4D U+0E32 ก่อนเลือกฟอร์ม ตัวอย่างนี้ต้องใช้ compatibility normalization จึงรวมคู่นี้ได้
  • คิดว่า NFKC แก้ลำดับวรรณยุกต์ไทยทั้งหมด — เก็บกรณีที่ยังไม่เท่ากันเป็นเทสต์ ถ้าจะเพิ่มกฎเฉพาะภาษา ต้องกำหนดคู่ที่ยอมให้เท่ากันและทดสอบผลข้างเคียงก่อน
  • เก็บเฉพาะข้อความหลัง normalize — แยกชื่อเดิมกับคีย์สำหรับค้นหา เพราะ NFKC เปลี่ยน ① เป็น 1 ตามเทสต์ รายละเอียดรูปสัญลักษณ์จึงอาจหายไปจากข้อมูลที่แปลงแล้ว
  • เอา helper ไปครอบรหัสผ่านทันที — อย่าเปลี่ยนความลับที่ผู้ใช้ตั้งไว้หรือกติกาการตรวจรหัสผ่านอย่างเงียบ ๆ นี่เป็นการตัดสินใจออกแบบระบบยืนยันตัวตน ต้องมีนโยบายและแผนรองรับข้อมูลเดิม
  • แปลงเฉพาะคำค้น — ใช้กติกาเดียวกันกับข้อมูลที่ทำดัชนีด้วย ถ้ามีฐานข้อมูลเดิม ให้เตรียมคีย์ย้อนหลังและตรวจรายการที่ชนกันก่อนบังคับเงื่อนไขห้ามซ้ำ

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

  • ทำช่องค้นหาลูกค้าสำหรับเจ้าหน้าที่ โดยแสดงชื่อจาก originalName และเทียบด้วย searchKey
  • ตรวจรายการนำเข้าจากไฟล์ว่าอาจซ้ำกัน แล้วให้พนักงานตรวจยืนยันก่อนรวมข้อมูล
  • จัดรูปแบบรหัสเอกสารที่คัดลอกมามีเลขเต็มความกว้าง เมื่อธุรกิจยอมรับให้เป็นรหัสเดียวกับเลขปกติ
  • เตรียมนโยบายชื่อบัญชีตอนสมัครและเข้าสู่ระบบให้สอดคล้องกัน พร้อมตรวจคีย์ชนกันก่อนเปิดใช้กับบัญชีเดิม

ตัวอย่างใช้การเท่ากันทั้งคีย์ ถ้าต้องการค้นหาบางส่วนหรือเรียงตามภาษาไทย ให้กำหนดวิธีค้นหาและเรียงเพิ่มต่างหาก การ normalize ไม่ใช่เครื่องมือค้นหาครบชุด และไม่ได้พิสูจน์ตัวตนของเจ้าของชื่อครับ

เทียบกับทางเลือกอื่น

วิธีดีตรงไหนระวังอะไร
เทียบข้อความดิบรักษาความต่างทุกตัว เหมาะกับงานที่ต้องตรงเป๊ะรูปแทนตัวอักษรต่างกันอาจค้นไม่พบ
NFCรวมรูป canonical เช่น Á ในเทสต์สระอำสองรูปในตัวอย่างยังต่างกัน
NFKC + กฎช่องว่างรวมคู่สระอำและเลขเต็มความกว้างที่ทดสอบสัญลักษณ์บางรูปถูกรวม และลำดับไทยบางคู่ยังต่าง
กฎแปลงเฉพาะงานควบคุมคู่ข้อความที่ยอมให้ตรงกันได้ต้องดูแลกฎและเทสต์ ไม่ควรเดาสุ่มแล้วลบเครื่องหมาย

Recap

  • เลือกฟอร์มจากคู่ข้อความจริงที่งานต้องการให้เท่ากัน
  • จำว่า NFC ไม่แยก U+0E33 ในเทสต์นี้ แต่ NFKC แยก
  • มีเทสต์ลำดับวรรณยุกต์ที่ยังไม่ตรงกัน เพื่อเห็นขอบเขตชัดเจน
  • แยกข้อความต้นฉบับสำหรับแสดงผลออกจากคีย์ค้นหา
  • ใช้กฎเดียวกันทั้งฝั่งข้อมูลและคำค้น แล้วตรวจคีย์ที่ชนกัน
  • ใช้ isNormalized ตรวจรูป Unicode และตรวจความว่างตามกติกางานเพิ่ม
  • รันด้วย -ea และทบทวนนโยบายก่อนใช้กับชื่อบัญชีหรือความลับ