Choose Search Matching Rules Before Adding Fuzziness
Drafted and reviewed using AI assistance.
On this page
Before asking an agent to make search “more forgiving,” decide which differences should count as equal. Two labels can look the same while using different Unicode sequences. Other labels differ in case, accents or character width. Treating all those differences as one problem makes the search requirement difficult to review.
A useful first contract can be deliberately small: normalize a derived key to NFC, then compare the whole label with case and accents preserved. That is the policy in this fictional example. It is one product choice, not a claim that this behavior fits every language or search task.
The technical basis is Unicode Standard Annex #15, revision 57 for Unicode 17.0.0. It distinguishes canonical equivalence from compatibility equivalence and defines the normalization forms. The JavaScript normalize() reference describes the string operation used by the small model below.
Choose equality before relevance
This search compares an entire query to an entire label. It does not rank partial matches, tokenize words or guess typing mistakes.
The selected rules are:
- Normalize both the query and a derived label key to NFC.
- Compare the resulting strings exactly, preserving case and accents.
- Return no records for the empty query.
- Do not trim spaces or collapse whitespace.
- Return matches in the supplied record order.
- Preserve each original label and ID, even when two labels have the same normalized key.
NFC performs canonical decomposition followed by canonical composition. It allows the composed é and the sequence e plus a combining acute accent in this example to compare equally. It does not turn Café into Cafe, CAFÉ into Café, or halfwidth characters into their fullwidth counterparts.
NFKC additionally uses compatibility mappings. Those mappings can be useful for a separately chosen policy, but they intentionally erase distinctions that NFC preserves. Choosing NFKC would therefore change this example's expected matches. It is not a universal fix for search.
Keep visible labels and their exact sequences together
The ten records below are original fictional teaching data. The code-point column specifies their content even when a font makes two labels appear identical. s2 contains a combining accent; its escaped JavaScript spelling is "Cafe\u0301". The composed spelling for s1 is "Caf\u00E9".
| ID | Original label | Exact code points |
|---|---|---|
| s1 | Café |
U+0043 U+0061 U+0066 U+00E9 |
| s2 | Café |
U+0043 U+0061 U+0066 U+0065 U+0301 |
| s3 | Cafe |
U+0043 U+0061 U+0066 U+0065 |
| s4 | CAFÉ |
U+0043 U+0041 U+0046 U+00C9 |
| s5 | カフェ |
U+30AB U+30D5 U+30A7 |
| s6 | カフェ |
U+FF76 U+FF8C U+FF6A |
| s7 | ABC |
U+FF21 U+FF22 U+FF23 |
| s8 | ABC |
U+0041 U+0042 U+0043 |
| s9 | 工作 |
U+5DE5 U+4F5C |
| s10 | Work |
U+0057 U+006F U+0072 U+006B |
Do not rewrite the stored display labels merely to make comparison convenient. Derive a search key from a label and keep the original record as the display source. Matching s1 and s2 does not establish that they are the same person, object or database row, so this fixture returns both IDs.
The Japanese and Latin width pairs are included to expose a policy decision. Under the chosen NFC equality they stay separate. That does not establish what a Japanese reader would prefer in a broader discovery interface; that would require a different requirement and evidence.
Write the full match and nonmatch matrix
Every row below specifies outcomes against all ten records. “None” is an empty result set. Query q13 contains one U+0020 space before and after Café; q12 is the empty string. The exact sequences prevent visually similar queries from silently becoming interchangeable during review.
| ID | Query | Exact code points | Matched IDs | Nonmatched IDs |
|---|---|---|---|---|
| q01 | "Café" |
U+0043 U+0061 U+0066 U+00E9 | s1, s2 | s3, s4, s5, s6, s7, s8, s9, s10 |
| q02 | "Café" |
U+0043 U+0061 U+0066 U+0065 U+0301 | s1, s2 | s3, s4, s5, s6, s7, s8, s9, s10 |
| q03 | "Cafe" |
U+0043 U+0061 U+0066 U+0065 | s3 | s1, s2, s4, s5, s6, s7, s8, s9, s10 |
| q04 | "CAFÉ" |
U+0043 U+0041 U+0046 U+00C9 | s4 | s1, s2, s3, s5, s6, s7, s8, s9, s10 |
| q05 | "cafe" |
U+0063 U+0061 U+0066 U+0065 | None | s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 |
| q06 | "カフェ" |
U+30AB U+30D5 U+30A7 | s5 | s1, s2, s3, s4, s6, s7, s8, s9, s10 |
| q07 | "カフェ" |
U+FF76 U+FF8C U+FF6A | s6 | s1, s2, s3, s4, s5, s7, s8, s9, s10 |
| q08 | "ABC" |
U+0041 U+0042 U+0043 | s8 | s1, s2, s3, s4, s5, s6, s7, s9, s10 |
| q09 | "ABC" |
U+FF21 U+FF22 U+FF23 | s7 | s1, s2, s3, s4, s5, s6, s8, s9, s10 |
| q10 | "工作" |
U+5DE5 U+4F5C | s9 | s1, s2, s3, s4, s5, s6, s7, s8, s10 |
| q11 | "Work" |
U+0057 U+006F U+0072 U+006B | s10 | s1, s2, s3, s4, s5, s6, s7, s8, s9 |
| q12 | "" |
(empty sequence) | None | s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 |
| q13 | " Café " |
U+0020 U+0043 U+0061 U+0066 U+00E9 U+0020 | None | s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 |
| q14 | "Caf" |
U+0043 U+0061 U+0066 | None | s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 |
| q15 | "work" |
U+0077 U+006F U+0072 U+006B | None | s1, s2, s3, s4, s5, s6, s7, s8, s9, s10 |
The two canonically equivalent queries, q01 and q02, return the same two records. Removing the accent changes the result to s3. Uppercasing changes it to s4, while lowercase cafe matches nothing. The width pairs remain separate, q14 rejects a prefix-only match, and the whitespace example exposes the decision not to trim.
This matrix is a matching contract. A future ranking or pagination change should not silently redefine it. If a product later introduces case folding, accent-insensitive matching or substring search, give the revised policy its own expected results.
Use a small model to inspect the policy
This JavaScript function implements only the selected equality rule:
function matchLabels(records, query) {
if (query === "") return [];
const queryKey = query.normalize("NFC");
return records
.filter(record => record.label.normalize("NFC") === queryKey)
.map(record => record.id);
}
The function derives keys during comparison and returns IDs in input order. It does not mutate labels, deduplicate records, or introduce a sort. It expects the strings and record shape shown in the fixture; handling non-string input belongs to a separate input contract.
For this article, an offline run compared the function's results with all 15 authored query expectations over the ten records: 150 record/query comparisons agreed. The run also checked the recorded code points, the canonical pair, the retained accent and width distinctions, and that the input records were unchanged. The observed runtime was Node v24.18.0, reporting Unicode 17.0 and ICU 78.3.
Those are checks of this educational model and fixture. They are not tests of an application's search service, database collation, browser rendering or deployed index. A real implementation might delegate matching to a different component with different behavior, so verify the component that actually decides matches.
Keep the next questions separate
“Case-insensitive” still requires a concrete rule. Lowercasing one string is not a complete specification of language-sensitive comparison, and this article's model does neither lowercasing nor case folding. Similarly, removing combining marks to approximate accent-insensitive matching would be another transformation with its own consequences.
A typo-tolerant search needs further decisions about candidates, distance, limits and ordering. Transliteration and word segmentation also introduce new behavior. They should not appear accidentally because a brief used the word “fuzzy.” Start with the equality baseline, then show which additional inputs should match and how their results should be ordered.
Keep identity and security comparisons outside this search policy. Returning two canonically equivalent labels is useful for this search example; it does not authorize combining accounts or treating visually similar identifiers as interchangeable.
For implementation, inventory the actual comparison path: where keys are derived, how existing indexed labels would be updated, and whether another layer applies its own normalization or collation. Confirm the runtime and data version used there. The runtime observed for the model is not evidence of what another service has installed.
Copy the search investigation request
Specify whole-label search matching for the supplied ten fictional
records and fifteen queries. Keep every original label and ID intact.
Do not connect to an application, database, live index or external API.
Selected policy:
- Derive NFC keys for labels and query, then compare exact strings.
- Preserve case and accents. Use no compatibility normalization.
- Empty query returns no records.
- Do not trim or collapse whitespace.
- Return matches in original record order; no deduplication.
- No substring search, case folding, transliteration or fuzziness.
Include visible labels and exact Unicode code points. Preserve the
Café/Cafe-plus-combining-accent pair as two records, both returned by
either canonically equivalent query. Keep Cafe and CAFÉ separate.
Keep halfwidth/fullwidth kana and Latin characters separate under NFC.
Show empty, space-wrapped, prefix-only and lowercase nonmatches.
Return matched AND nonmatched IDs for every query. Preserve the original
display sequence; a derived key must not replace it. Keep ranking and
pagination outside this matching change.
If checking a tiny offline model, record the actual runtime, Unicode
version, fixture and actual-vs-expected results. Do not describe such
checks as a production search or database result. Identify missing
input validation, index migration and real comparison-layer evidence.
Do not use this search rule for account or security identity equality.
A precise search request can be modest and still useful. Once the baseline explains why each pair matches or stays separate, additional matching behavior becomes a visible product decision with examples to review.