I agree with the hybrid approach, especially keeping the canonical service ID separate from the original business wording. In my experience working with local service websites, the difficult part isn’t always finding a semantic match — it’s making sure the normalized service still preserves the details that matter to the customer.
For example, two businesses may both use “junk removal,” but one may accept appliances, mattresses, and yard debris while another may have different restrictions. So I’d keep something like the canonical service ID, original label, category, location, and service-specific inclusions/exclusions as separate fields rather than relying on the normalized name alone.
I also like the idea of logging queries that don’t map cleanly. Those real queries can become a much better source for expanding the alias list than trying to predict every synonym in advance.
One thing I’d add to your pipeline is a service-scope check after normalization:
user query → canonical service → location/category filter → service scope check → vector retrieval → business-specific RAG
That extra check can prevent a semantically close match from being treated as an actual service the business provides. This seems particularly useful for local businesses where related services can have very different pricing, equipment, disposal requirements, or availability.
I’ve found it useful to keep a separate service scope reference for this reason — essentially documenting what falls under a service and what doesn’t. That gives the retrieval/RAG layer something more reliable than just the service name to work with.
Your point about tree trimming vs. tree removal is a good example of why this matters. They may be semantically close, but they shouldn’t necessarily resolve to the same service ID.
Overall, I’d also favor the hybrid model: taxonomy for identity, metadata for filtering, embeddings for recall, and RAG for the business-specific details. The query logs can then continuously improve the taxonomy and alias list.