0

Geocoder trả "exact" cho "qwerty 99999 xyz": những cái bẫy khi chuyển khỏi Google Geocoding API

Bài của team ERPFit, đơn vị vận hành map.vapp.vn. Vì thế đây là chuyện về chính sản phẩm của team mình, kể cả những chỗ nó trả lời sai. Mọi đoạn code và mọi kết quả trong bài được chạy lại trên production ngày 04/11/2026, trừ đoạn gọi Google: đoạn đó chạy với một câu trả lời giả lập theo tài liệu của Google.

1. Khi "qwerty" ra một quán cà phê

Lúc viết lớp chuyển đổi từ Google Geocoding API sang API của mình, team mình thử gõ một chuỗi vô nghĩa để xem code xử lý ZERO_RESULTS có chạy không:

curl -s -G "https://map.vapp.vn/api/v1/geocode" \
  --data-urlencode "q=qwerty 99999 xyz" -d limit=1 \
  | jq -c '.results[0] | {name, kind, province, confidence}'
{"name":"XYZ Café","kind":"coffee_shop","province":"Thành phố Hồ Chí Minh","confidence":"exact"}

Không có ZERO_RESULTS nào cả. Geocoder tự tin trả về một quán cà phê ở TP.HCM, kèm nhãn exact.

Đây không hẳn là lỗi. Phần tìm kiếm chạy BM25 có fuzzy để người dùng gõ không dấu vẫn ra: "cho ben thanh" phải ra Chợ Bến Thành. Từ "xyz" hiếm nên điểm của nó cao, và confidence thực chất được gán theo ngưỡng điểm. Nhưng code đang viết cho Google thì không biết điều đó: nó thấy status: "OK" là lưu tọa độ vào đơn hàng.

Bài này kể lại những chỗ khác nhau mà team gặp khi làm lớp chuyển đổi đó. Nếu các bạn đang cân nhắc đổi nhà cung cấp geocoding, dù là sang bên nào, thì phần lớn các bẫy này đều đáng kiểm tra trước.

2. Bài toán: đổi nguồn dữ liệu, không sửa chỗ đọc kết quả

Code đọc Google Geocoding thường trông như thế này, và nó nằm rải rác ở nhiều nơi trong project:

const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(q)}&key=${GOOGLE_KEY}`;
const g = await (await fetch(url)).json();
if (g.status === "OK") save(g.results[0].geometry.location);

Đi sửa từng chỗ đọc results[0].geometry.location thì rủi ro, nên team mình chọn cách quen thuộc: thêm một lớp adapter ở giữa, đổi câu trả lời mới về đúng dạng JSON cũ. Chỗ đọc kết quả giữ nguyên, chỉ thay dòng gọi API:

import { toGoogleGeocode, vapp } from "./src/index.ts";

const v = vapp({ key: process.env.VAPP_MAPS_KEY });
const g = toGoogleGeocode(await v.geocode(q));
if (g.status === "OK") save(g.results[0].geometry.location);

Code đầy đủ nằm ở vappvn/google-maps-migration, giấy phép MIT, không phụ thuộc gói nào. Các bạn clone về chạy thử được ngay, chưa cần khóa API:

bun examples/geocode.ts "54 Nguyễn Huệ, phường Sài Gòn, TP.HCM"
{
  "status": "OK",
  "results": [
    {
      "formatted_address": "54 Nguyễn Huệ, Phường Sài Gòn, Thành phố Hồ Chí Minh",
      "geometry": {
        "location": { "lat": 10.7734498475666, "lng": 106.70408998327721 },
        "location_type": "RANGE_INTERPOLATED"
      },
      "place_id": "vm_a4a7fcf3f71b3078",
      "types": ["street_address"]
    }
  ]
}

Phần lõi của adapter rất ngắn. Toàn bộ công sức nằm ở chỗ quyết định kết quả nào được giữ lại:

export function toGoogleGeocode(res: GeocodeResponse, o: CompatOptions = {}): GoogleGeocodeResponse {
  const floor = CONFIDENCE_ORDER.indexOf(o.minConfidence ?? "district");
  const kept = res.results.filter((h) => CONFIDENCE_ORDER.indexOf(h.confidence ?? "fuzzy") <= floor);
  const results = kept.map((h) => toResult(h, res.parsed?.house ?? null));
  return { status: results.length ? "OK" : "ZERO_RESULTS", results };
}

3. Bẫy 1: "OK" không có nghĩa là tìm thấy

Code cũ dựa vào ZERO_RESULTS để biết địa chỉ sai, nên adapter phải tự tạo lại trạng thái đó. Team mình đo trên 12 câu tìm kiếm thật và 3 chuỗi rác để chọn ngưỡng. Cả 12 câu thật đều đạt mức district trở lên, còn chuỗi rác thì như sau:

Chuỗi Kết quả đầu confidence Sau adapter
zzqxv không có Cỏ Hồng approximate ZERO_RESULTS
asdfgh (không có) - ZERO_RESULTS
qwerty 99999 xyz XYZ Café exact OK

Ngưỡng district chặn được 2 trong 3 chuỗi. Chuỗi thứ ba vẫn lọt, và đó là phần đáng nói nhất của bài này.

4. Bẫy 2: exact chỉ là một mức điểm

Cách vá đầu tiên ai cũng nghĩ tới là thêm tên tỉnh vào câu hỏi. Team mình thử rồi, và nó chỉ làm kết quả sai trông hợp lý hơn: "qwerty 99999 xyz, Đà Nẵng" ra một địa điểm có thật ở Đà Nẵng, vẫn với nhãn exact.

Điều cần nhớ là geocoder trả lời câu hỏi "cái gì giống chuỗi này nhất?", trong khi lúc đặt hàng bạn cần trả lời một câu khác: "đây có phải một địa chỉ giao được không?". Hai câu này cần hai API. Với địa chỉ khách nhập, team mình tách riêng một endpoint kiểm tra theo từng thành phần:

for q in "54 Nguyễn Huệ, Phường Sài Gòn, TP.HCM" \
         "12 Lê Lợi, phường Bến Nghé, quận 1, TP.HCM" \
         "qwerty 99999 xyz, Đà Nẵng" \
         "qwerty 99999 xyz"; do
  curl -s -G "https://map.vapp.vn/api/v1/verify" --data-urlencode "q=$q" \
    | jq -c '{status, reasons, address}'
done
{"status":"verified","reasons":[],"address":"54 Nguyễn Huệ, Phường Sài Gòn, Thành phố Hồ Chí Minh"}
{"status":"corrected","reasons":["old_units_converted"],"address":"12 Lê Lợi, Phường Sài Gòn, Thành phố Hồ Chí Minh"}
{"status":"ambiguous","reasons":["unit_unknown","no_street","no_house","match_not_street"],"address":null}
{"status":"invalid","reasons":["no_components"],"address":null}

Bốn trạng thái này khớp với bốn hành động trên giao diện: cho qua, đề nghị khách dùng địa chỉ mới, hỏi lại khách, và báo lỗi. Google Geocoding API có partial_match để báo kết quả khớp một phần, nhưng nếu các bạn đang dùng nó để chặn địa chỉ sai thì nên thử lại với chuỗi rác của chính domain mình.

5. Bẫy 3: số nhà không phải lúc nào cũng có

Tọa độ số nhà ở đây được nội suy trên tim đường, không phải trên nóc nhà như ROOFTOP của Google. Khi so với ghim của Google, team mình coi mức chênh khoảng 150 m là bình thường. Bẫy lớn hơn là đoạn đường chưa đủ mốc để nội suy:

import { toGoogleGeocode, vapp } from "./src/index.ts";

const v = vapp();
for (const q of ["54 Nguyễn Huệ, Phường Sài Gòn, TP.HCM", "12 Lê Lợi, Phường Sài Gòn, TP.HCM"]) {
  const r = toGoogleGeocode(await v.geocode(q, { limit: 1 })).results[0];
  console.log(r.formatted_address, "|", r.types[0], "|", r.geometry.location_type);
}
54 Nguyễn Huệ, Phường Sài Gòn, Thành phố Hồ Chí Minh | street_address | RANGE_INTERPOLATED
Lê Lợi, Phường Sài Gòn, Thành phố Hồ Chí Minh | route | GEOMETRIC_CENTER

Dòng thứ hai vẫn là status: "OK", nhưng số 12 đã biến mất và tọa độ là một điểm trên con đường. Code gửi shipper tới tọa độ đó sẽ sai mà không ai báo lỗi. Nếu cần chính xác tới số nhà, hãy kiểm tra types[0] === "street_address" thay vì chỉ nhìn status.

Tiện đây cũng nói luôn: ở đây không có address_components. Từng thành phần của địa chỉ nằm trong parsed, đã bỏ dấu và viết thường, nên code cũ đọc address_components là chỗ phải viết lại thật sự, adapter không che được.

6. Bẫy 4: district không còn là quận

Từ 01/07/2025, Việt Nam bỏ cấp huyện và sáp nhập nhiều tỉnh, nhưng dữ liệu cũ và thói quen gõ địa chỉ thì chưa đổi theo. Hãy nhìn hai trường của cùng một địa điểm:

curl -s -G "https://map.vapp.vn/api/v1/geocode" \
  --data-urlencode "q=qwerty 99999 xyz" -d limit=1 \
  | jq -c '.results[0] | {district, address}'
{"district":"Phường Gia Định","address":"Quận Bình Thạnh"}

Trường district giữ tên cũ cho khỏi vỡ API, nhưng giá trị là phường mới, còn address lại có thể mang quận cũ. Vì vậy adapter bỏ hẳn address khi ghép formatted_address, tránh tạo ra một chuỗi vừa có phường mới vừa có quận cũ. Nếu database của các bạn còn địa chỉ cũ, có thể đổi hàng loạt qua endpoint chuyển đổi, không cần khóa:

curl -s -G "https://map.vapp.vn/api/v1/address/convert" \
  --data-urlencode "q=12 Lê Lợi, phường Bến Nghé, quận 1, TP.HCM" \
  | jq -c '{status, address, province_new}'
{"status":"ok","address":"12 Lê Lợi, Phường Sài Gòn, Thành phố Hồ Chí Minh","province_new":"Thành phố Hồ Chí Minh"}

7. Bẫy 5: reverse trả về địa điểm, không phải địa chỉ

Với Google, truyền latlng thì formatted_address là một địa chỉ. Ở đây reverse trả về điểm gần nhất, và điểm đó có thể chỉ có mỗi cái tên:

import { toGoogleReverse, vapp } from "./src/index.ts";

const res = await vapp().reverse(10.772521, 106.698019);
console.log(toGoogleReverse(res).results[0].formatted_address, "|", res.result?.kind, "|", res.result?.province ?? "không có tỉnh");
Chợ Bến Thành | marketplace | không có tỉnh

Adapter giữ đúng hình dạng JSON, nhưng không thể biến tên một cái chợ thành địa chỉ. Đây là chỗ team mình chưa làm tốt. Nếu code của các bạn in formatted_address lên hóa đơn thì đừng dùng reverse cho việc đó.

8. Bẫy 6: lat,lng hay lng,lat

Adapter nhận lat, lng như Google, còn các API chỉ đường thì nhận lng,lat như GeoJSON. Đảo nhầm thứ tự thì may là có lỗi rõ ràng, không âm thầm trả về một tuyến đường sai:

for p in "from=106.7040,10.7734&to=106.6980,10.7725" \
         "from=10.7734,106.7040&to=10.7725,106.6980"; do
  curl -s -o /dev/null -w "%{http_code}\n" "https://map.vapp.vn/api/v1/route?$p&profile=car"
done
200
404

Nhược điểm là mã lỗi: một tọa độ đảo ngược trả về 404, nên đừng vội kết luận là không có đường đi.

9. Bẫy 7: code mẫu chạy được trên máy mình

Bẫy cuối cùng là của chính team mình, và cũng chỉ lộ ra khi chuẩn bị đăng bài này. README ghi là code chạy trên "Bun, Node 18+, Deno". Team mình chỉ chạy nó trên Bun. Khi thử lại đúng như lời hứa, Node dừng ngay khi vừa nạp file:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

Node chạy file .ts bằng cách xóa phần khai báo kiểu, nên cú pháp nào phải sinh thêm code JavaScript đều bị từ chối, và constructor(readonly status: number) là một trong số đó. Bản sửa rất nhỏ:

  readonly status: number;
  readonly body: string;
  constructor(status: number, body: string) {
    super(`map.vapp.vn HTTP ${status}: ${body.slice(0, 200)}`);
    this.status = status;
    this.body = body;
  }

Còn "Node 18+" thì đơn giản là sai: Node 18 và 20 không chạy thẳng được file .ts, và team mình đã thử cả hai để chắc chắn. README giờ ghi đúng là Bun, Deno và Node 22.18 trở lên chạy thẳng, còn Node 18, 20 thì build trước. Trong lúc thử lại, bản nháp bài này cũng có một câu sai vì dữ liệu đã đổi: một địa chỉ từng ra sai tỉnh nay đã ra đúng. Bài học này nhỏ nhưng rẻ: đừng viết "chạy được trên X" khi chưa chạy trên X, và đừng đăng con số đo từ hai tuần trước mà không đo lại.

10. Test mà không cần mạng

Câu trả lời của một geocoder thay đổi theo dữ liệu, nên test gọi API thật sẽ đỏ vào những ngày chẳng ai sửa code. Team mình ghi lại câu trả lời thật thành fixture, xóa các trường có thể chứa thông tin liên hệ, rồi test adapter trên đó:

  test("a nonsense query is ZERO_RESULTS by default, and its fuzzy hits come back flagged when asked for", () => {
    expect(toGoogleGeocode(fx("geocode-nonsense"))).toEqual({ status: "ZERO_RESULTS", results: [] });
bun test 2>&1 | grep -E "^ *[0-9]+ (pass|fail)"
 9 pass
 0 fail

Một script riêng chạy cùng các trường hợp đó trên production, để phát hiện lúc câu trả lời thật không còn giống fixture. Hai lớp này tách nhau: test trong repo cho biết adapter đúng, script kia cho biết thế giới bên ngoài đã đổi.

11. Lời kết

Tóm lại, khi đổi nhà cung cấp geocoding, đoạn code đổi JSON là phần dễ nhất. Phần khó là những giả định ngầm: OK nghĩa là tìm thấy, exact nghĩa là đúng, có tọa độ nghĩa là có số nhà. Trước khi chuyển, các bạn nên chạy thử vài chuỗi rác và vài địa chỉ cũ trong database của mình, rồi đọc kỹ từng câu trả lời.

Nếu muốn thử, code ở GitHub, hướng dẫn từng bước ở đây, và khóa API lấy ở map.vapp.vn/dev. Gói Developer miễn phí: 20.000 yêu cầu mỗi tháng, tối đa 1.000 mỗi ngày, 60 mỗi phút, bắt buộc ghi nguồn map.vapp.vn.

Nếu các bạn gặp một địa chỉ cho kết quả sai, hãy để lại bình luận kèm địa chỉ đó (đừng ghi tên hay số điện thoại của khách). Team mình dùng các trường hợp như vậy để sửa bộ tách địa chỉ. Cảm ơn các bạn đã đọc bài.

Nội dung có hỗ trợ AI, biên tập bởi ERPFit.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí