I have had this one in the drafts for months ever since I started
working on a microservices project, Eventa.
Eventa is a distributed event ticketing platform where organizers
publish events and sell tickets, while attendees discover events and buy them. It runs across eight NestJS and Go services, which use gRPC when one service needs an immediate response. For asynchronous work, the services write messages to transactional outboxes, and separate Debezium Server instances relay committed business facts to Kafka and worker jobs to RabbitMQ.
I started around early July or thereabout, and it has been one exciting yet challenging project. Although I should have been done by now, working on it alongside my daily work and other side projects made it take longer than I expected. I planned for Eventa to be a local Docker Compose project but eventually had to move it to a VPS because my PC could no longer handle all those Docker containers alongside everything else I was doing.
The Eventa Docker containers running on the VPS.
I have already shared quite a bit about Eventa on X and LinkedIn whilst I was building it, so this article majorly focuses on how I built its event recommendation system using Ahnlich, a vector database for text embeddings and semantic search, and PostGIS, a PostgreSQL extension for geographic queries.
Table of Contents
- Eventa's Architecture
- From Event Publication to Discovery
- Using Ahnlich for Semantic Search
- Building Attendee Preferences from Real Activity
- Turning Search Results into Recommendations
- Using PostGIS for Location
1. Eventa's Architecture
Eventa runs across eight backend services, four written in NestJS and four in Go, with the API Gateway handling client requests and the other services responsible for identity, events, commerce, tickets, discovery, analytics, and notifications. They communicate over gRPC for operations that require a response from another service, such as Commerce reserving ticket capacity through Event Service before a purchase proceeds, while the Gateway uses HTTP to communicate with Ticket Service.
Eventa uses Kafka to distribute business events between services and RabbitMQ to handle background jobs, with separate Debezium Server instances relaying records from transactional outboxes to the respective brokers. When Commerce completes a purchase, for example, it publishes an order-paid event that Ticket Service consumes to issue the tickets. Discovery consumes event publications, purchases, and check-ins to maintain its own data for search and recommendations.
Each service has its own PostgreSQL database, so Discovery maintains its own event data and attendee activity rather than querying another service's database directly. Its recommendation engine uses Ahnlich to find semantic similarities between events and attendee preferences, while PostGIS handles geographic searches and distance calculations. That's the part of Eventa I'll focus on in this article.
2. From Event Publication to Discovery
Before Eventa can recommend an event to an attendee, Discovery Service needs to index published events so it can find relevant ones based on each attendee's interests and activity. When an organizer publishes an event, Event Service saves it to its database and writes a record to its transactional outbox, which Debezium picks up and sends to Kafka.
Discovery consumes the message and fetches the event details from Event Service, including the title, description, categories, schedule, and venue information. It then indexes the event in Ahnlich, where its text can be compared with an attendee's interests and activity to find semantically similar events.
3. Turning Event Text into Vectors with Ahnlich
Ahnlich has two components that work together: Ahnlich AI generates embeddings from text, while Ahnlich DB stores the resulting vectors and searches them by similarity.
For each event, Discovery Service combines the title, description, categories, venue name, and city into a single text representation. Including these fields gives the embedding model more context about the event than its title alone.
text := semantic.Truncate(semantic.BuildText(
content.Title,
content.Description,
content.Categories,
content.VenueName,
content.VenueCity,
))
Discovery Service sends this text to Ahnlich AI to generate the event's embedding, which Ahnlich DB stores against the event ID. When an attendee requests recommendations, Discovery Service builds a text query from their interests and activity and sends it to Ahnlich for a similarity search:
response, err := c.client.GetSimN(ctx, &aiquery.GetSimN{
Store: c.store,
SearchInput: &keyval.StoreInput{
Value: &keyval.StoreInput_RawString{RawString: query},
},
ClosestN: uint64(limit),
Algorithm: algorithms.Algorithm_CosineSimilarity,
})
Ahnlich embeds the query and compares it with the stored event vectors using cosine similarityreturning the closest event IDs and their similarity scores to Discovery Service which uses it in building recommendations.
4. Building Attendee Preferences from Real Activity
Discovery Service builds attendee preferences from three sources: interests selected by the attendee, events they have purchased, and events they have checked in to. The interests are read when a recommendation query is built, while purchase and check-in events arrive asynchronously from Commerce Service and Ticket Service through Kafka and are stored by Discovery Service.
When the attendee requests recommendations, Discovery Service reads their interests and activity history and combines them into a single query for Ahnlich.
query := semantic.BuildPreferences(
interests,
attendedEvents,
purchasedEvents,
)
The query has an 1,800-byte budget, split across the three sources: up to 900 bytes for attended events, 600 for purchased events, and 300 for selected interests. If one source uses less than its allocation, the unused space passes to the next group.
If the attendee has no selected interests, purchases, or check-ins, Discovery Service returns an empty recommendation list because it has no preference information to search with.
5. Turning Search Results into Recommendations
When an attendee opens the Discover page, Discovery Service builds the preference query from their interests and activity, then searches Ahnlich for relevant events. It sends the returned event IDs to Event Service, which filters out events that are no longer published or have no tickets available.
Discovery Service then combines the remaining events with their similarity scores to produce the recommendations. If the attendee has provided a location, distance also contributes to the ranking.
query := semantic.BuildPreferences(interests, attended, purchased)
candidates, err := store.Search(ctx, query, ranking.CandidateLimit(limit))
ids := ranking.UniqueIDs(candidates)
resolved, err := events.ListRecommendableEvents(ctx, ids)
ranked := rankCandidates(ids, resolved, similarityByID(candidates), origin)
6. Using PostGIS for Location
An event can match an attendee's interests but still be too far away to attend, so I added venue coordinates to Eventa's event form. Organizers can set the venue on a map or enter its latitude and longitude. Event Service owns these coordinates, and Discovery Service receives them with the event details.
For the Near me search, Discovery Service uses PostGIS to find published events within the requested radius and sort them by distance. The query uses ST_DWithin to filter by radius and ST_Distance to calculate how far away each event is.
SELECT event_id,
ST_Distance(venue_geo, :origin) / 1000 AS distance_km
FROM discovery_event_index
WHERE status = 'published'
AND ST_DWithin(venue_geo, :origin, :radius_metres)
ORDER BY distance_km, starts_at, event_id
LIMIT :page_size;
I wanted to see how the radius search performed against the data in Eventa, so I ran EXPLAIN (ANALYZE, BUFFERS) on the query.
The search returned 1,225 published events within 50 km of the Lagos coordinates I used for the test. Looking at the query plan, PostgreSQL was using the GiST index on venue_geo to find candidate locations before checking the matching rows. The full count query took about 141 ms to execute, while the index scan itself took less than a millisecond.
The query plan also gives me a breakdown of how PostgreSQL executed the search, along with the actual timings and buffer usage.
Recommendations work differently as Discovery Service first finds events that match the attendee's interests through Ahnlich, then uses distance to adjust their ranking when the attendee's location is available:
proximity = max(0, 1 - distance_km / 50)
score = 0.8 × cosine_similarity + 0.2 × proximity
This gives semantic similarity 80% of the weight and proximity 20%. The proximity score decreases as the event gets farther away, reaching zero at 50 km. Without an attendee location, Discovery Service keeps Ahnlich's ranking and events without venue coordinates can still be recommended based on similarity alone.
Together, these two approaches let Discovery Service account for both what an attendee is interested in and how far they would have to travel to attend it.





Top comments (1)
The outbox plus Debezium split is doing quiet but important work here: business facts land in Kafka and worker jobs in RabbitMQ, both sourced from committed transactions, so a consumer crash never loses state the database already accepted. Keeping gRPC only for calls that need an immediate answer keeps the async paths free of request-response coupling.