Tất cả bài viết

Search trong HMS — LIKE, FULLTEXT INDEX, và Elasticsearch

LIKE '%nguyen%' không scale. FULLTEXT cho tên bệnh nhân vừa. Elasticsearch khi cần fuzzy, facet, relevance — đừng over-engineer ngày đầu.

case-studysearchmysqlelasticsearchdatabase

Receptionist gõ “Nguyen Van” vào ô tìm bệnh nhân. Mày viết:

@Query("SELECT p FROM Patient p WHERE LOWER(p.fullName) LIKE LOWER(CONCAT('%', :q, '%'))")
List<Patient> search(@Param("q") String query);

10.000 bệnh nhân — chạy nhanh. 500.000 bệnh nhân — receptionist bấm tìm, uống cà phê, quay lại spinner vẫn quay. EXPLAIN cho thấy type: ALL, rows: 500000.

Không phải MySQL chậm. Mày yêu cầu database quét toàn bộ bảng vì leading wildcard % khiến B-tree index vô dụng.

Search trong HMS không phải một query — là trade-off giữa độ chính xác, tốc độ, và chi phí vận hành.


LIKE ‘%query%’ — khi nào đủ, khi nào chết

-- ❌ Không dùng index — full table scan
SELECT * FROM patient WHERE full_name LIKE '%nguyen%';

-- ✅ Prefix search — có thể dùng index
SELECT * FROM patient WHERE full_name LIKE 'Nguyen%';

Leading wildcard = scan. Chỉ chấp nhận được:

Nếu product yêu cầu “tìm ở giữa tên” — LIKE không phải tool đúng ở scale.


FULLTEXT INDEX — sweet spot cho HMS phase đầu

MySQL FULLTEXT (InnoDB từ 5.6+) cho search từ trong text column:

ALTER TABLE patient
  ADD FULLTEXT INDEX ft_patient_name (full_name);

SELECT id, full_name,
       MATCH(full_name) AGAINST('Nguyen Van' IN NATURAL LANGUAGE MODE) AS score
FROM patient
WHERE MATCH(full_name) AGAINST('Nguyen Van' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT 20;

JPA:

@Query(value = """
    SELECT * FROM patient
    WHERE MATCH(full_name) AGAINST(:query IN NATURAL LANGUAGE MODE)
    ORDER BY MATCH(full_name) AGAINST(:query IN NATURAL LANGUAGE MODE) DESC
    LIMIT :limit
    """, nativeQuery = true)
List<Patient> fullTextSearch(@Param("query") String query, @Param("limit") int limit);

Ưu: Không thêm infrastructure. Đủ cho tên bệnh nhân, ghi chú ngắn, mã hồ sơ kết hợp column riêng.
Nhược: Minimum word length (mặc định 3 ký tự với InnoDB) và stopword list — MySQL English stopword list mặc định có chứa “van”, tức Nguyen Van có thể chỉ search trên “Nguyen”. Config innodb_ft_enable_stopword=0 để tắt nếu search tên người Việt. Không fuzzy typo (NguyneNguyen). Không facet phức tạp (lọc theo department + age range + sort relevance đa field).

HMS giai đoạn đầu với vài trăm nghìn patient — FULLTEXT + index trên medical_record_number exact match thường đủ.

// Exact match — luôn nhanh với unique index
Optional<Patient> findByMedicalRecordNumber(String number);

Search box UI: nếu input match pattern BN-\d+ → query exact trước; còn lại → FULLTEXT.


Elasticsearch — khi nào justify

Đưa ES vào khi có ít nhất một nhu cầu thật:

MySQL (source of truth)
    → Debezium / application event / batch job
    → Elasticsearch index "patients"
    → Search API query ES, hydrate detail từ MySQL bằng ID

Không sync hai chiều thủ công mỗi request — eventual consistency, handle stale index.

Ví dụ document:

{
  "id": "uuid",
  "fullName": "Nguyen Van A",
  "medicalRecordNumber": "BN-2024-001234",
  "phone": "090...",
  "dateOfBirth": "1990-01-15"
}

Search API:

public List<PatientSearchHit> search(String q, int page, int size) {
  var response = elasticsearchClient.search(s -> s
      .index("patients")
      .query(qb -> qb.multiMatch(m -> m
          .fields("fullName^3", "medicalRecordNumber^2", "phone")
          .query(q)
          .fuzziness("AUTO")
      ))
      .from(page * size)
      .size(size),
    PatientDocument.class);
  return mapHits(response);
}

Chi phí: cluster ES, mapping migration, monitor lag sync, debug “tìm không ra vì index chưa update”.


Decision framework cho HMS

Nhu cầuGiải pháp
Tìm theo mã hồ sơ chính xácB-tree index, = query
Tìm tên, < 500k patients, ít typo toleranceFULLTEXT
Prefix autocomplete “Ngu…”LIKE 'Ngu%' hoặc dedicated autocomplete index
Tìm substring giữa tên ở scale lớnFULLTEXT hoặc ES, không %...%
Multi-field, fuzzy, facet, analytics searchElasticsearch
Full-text trên PDF/image OCRES + pipeline ingest riêng

Đừng install ES ngày đầu vì “scale sau này” — operational debt từ ngày một. Đừng dùng LIKE % vì “sau này refactor” — receptionist suffer từ ngày một.


Pagination và limit

Search box luôn LIMIT 20. Không trả 5000 row cho frontend filter. Cursor pagination nếu infinite scroll.


Search patient leak PHI nếu log query string chứa tên thật. Audit ai search ai. Role receptionist vs doctor scope khác nhau — ABAC filter sau search hoặc filter trong index theo departmentId.


Takeaway

LIKE '%x%' là hammer cho bài toán cần screwdriver — biết khi nào bảng nhỏ chấp nhận được. FULLTEXT là bước upgrade hợp lý cho HMS tên bệnh nhân. Elasticsearch khi product search thực sự cần relevance và scale mà MySQL không đáp ứng — không phải vì resume đẹp. Trước khi chọn, chạy EXPLAIN trên production-size data — số liệu quyết định, không phải blog post.


Bài tiếp theo: (tiếp series Case Studies hoặc Phần 6 Database)