In case it helps others, I had to answer this one myself. If looks like the following regular expression will work:
(^|[[:blank:]])(#[a-zA-Z0-9/_-]*[a-zA-Z/_-][a-zA-Z0-9/_-]*)+
This also obeys the restriction that tags cannot be only numbers. The astute reader will notice that this allows for an all-number sub-tag, or empty tag. Yes, it does, because Obsidian recognizes them as tags, so Obsidian will actually accept and tag crazy things like:
#///777/
So I’ve gotta conform, if I expect external results to match internal (Obsidian) results.
The only hiccup is that the regex will capture leading space, which isn’t really a part of the tag, but that’s easily “strippable.” I wanted to do a Positive Lookbehind but that’s not reliable in a cross-platform or cross-tool context, so it has to be avoided. Also, the sequence
(^|[[:blank:]])
should be simplified to
[[:space:]]
except that it’s not picking up tags at beginning of line, even though it should.
Linux man page citation: grep → re_format → wctype → isspace
Edit: Whoops, I forgot to handle the case with the pipe. Rather than delete the post I’ll update it later.
Edit2: Regex not rendering properly due to display interpretation; fixed. I’m addressing the pipe issue in a followup post.
Edit3: Oof. Yet another display rendering issue. Hopefully the last one. This getting kinda meta.