A heightmap is just a mapping from 2D horizontal positions to heights. It's often implemented as a regular grid of height samples we could encode in say a texture's grid of pixels, but that's by no means a requirement.
Instead, we could implement it as a 2D quadtree/bounding volume hierarchy (or other spatial partition), storing our vertices or triangles by their horizontal positions/extents. This lets us quickly isolate which triangles of the terrain lie within the footprint of a prospective collider. If each node of the tree also tracks its max height, we can use this as an early-out to skip checks for objects elevated above the terrain.
Once you have the potential triangles involved, then it's just a series of triangle-primitive collision checks, like a regular mesh collision. We just use the nature of the terrain to help us isolate the relevant triangles more easily.
"My terrain has around 2000 faces, wouldn't that be too much?"
That's a question for your profiler, but there's no particular reason to expect it would be. A good spatial partition should let us get to the relevant triangles in a number of steps logarithmic in the number of faces, so you're looking at a tree depth of a dozen or less. After that, your time spent on collisions will be determined by how many triangles you need to check, so as long as your colliding objects aren't spanning hundreds of triangles worth of terrain, you should be fine. (And if they are, it might be a good idea to let them collide with a simplified terrain with fewer triangles - if I'm trying to simulate a cruise liner, it's not going to care about every little bump and divot.)