Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/viet2811/uk-travel-recommendation/llms.txt

Use this file to discover all available pages before exploring further.

UK Travel Recommendation is designed around a single architectural principle: keep the heavy lifting — vector storage, similarity search, and profile arithmetic — inside the database layer, and keep the mobile client as thin as possible. The result is a three-tier system where a React Native / Expo frontend communicates exclusively through a stateless REST API, which in turn delegates all vector operations to a PostgreSQL 15 instance running the pgvector extension. There are no external ML inference services at request time; embeddings are pre-computed offline and stored alongside the attraction records, so every recommendation query is resolved in a single SQL call enriched by in-process MMR re-ranking.

Component Overview

Backend API

Django 6 + Django REST FrameworkTwo apps — users and recommendations — expose RESTful endpoints under /api/user/ and /api/recommendations/. Business logic (profile updates, MMR re-ranking) runs in Python; authentication is handled by djangorestframework-simplejwt.

Vector Database

PostgreSQL 15 + pgvectorEvery attraction and every user profile stores a 777-dimensional finalVector column. The pgvector extension provides an IVFFlat index and the CosineDistance operator, turning kNN queries into standard SQL that PostgreSQL can execute efficiently.

Mobile Frontend

React Native + Expo SDK 54NativeWind provides Tailwind-style utility classes. TanStack Query v5 manages server state and background refetching. axios sends authenticated requests with JWTs stored in Expo Secure Store. The primary UI is a swipe card feed.

Data Flow

The lifetime of a user session follows a clear cycle from registration through continuous profile refinement.
1

Registration and login

The user creates an account via POST /api/user/register/. The backend creates a User record and an associated UserProfile with a zero-initialised finalVector. On login (POST /api/user/token/), the server returns a short-lived access token (15 min) and a long-lived refresh token (30 days). Both are persisted in Expo Secure Store on the device.
2

Initial preference setup

Before the swipe feed is shown, the onboarding flow asks the user to select preferred attraction categories. These selections are encoded using the same Multi-Hot Encoding scheme used for attractions and used to seed the user’s finalVector, giving the recommendation engine a meaningful starting point before any swipes have occurred.
3

Recommendation fetch

The frontend calls GET /api/recommendations/ (optionally with ?county=, ?region=, or ?country= geo-filter parameters). Django retrieves the caller’s UserProfile.finalVector, runs a pgvector cosine-distance kNN query against the attractions table, and then applies MMR re-ranking in Python before returning a ranked list of attraction objects.
4

Swipe interaction

Each swipe gesture triggers either a POST /api/recommendations/like/<id> or POST /api/recommendations/dislike/<id> request carrying the attraction ID. The backend fetches the swiped attraction’s finalVector and updates the user’s profile vector — blending it toward (like) or orthogonally rejecting it from (dislike) the attraction’s embedding — then re-normalises and persists the updated profile.
5

Profile update and next batch

With the profile updated, TanStack Query invalidates the recommendations cache, and the next fetch returns a freshly computed batch that reflects the user’s latest expressed preferences. This cycle repeats for every swipe, creating a continuously tightening feedback loop.

Vector Representation

Every attraction in the database is characterised by a single 777-dimensional finalVector that captures three complementary signals at different levels of abstraction. User profiles are stored in exactly the same format, which means the dot-product geometry that underlies cosine similarity works identically for attraction-to-attraction and user-to-attraction comparisons. The vector is assembled by concatenating three normalised, weighted sub-vectors:
Sub-vectorDimensionsSourceWeight
labelMHE9Multi-Hot Encoding of attraction category labels1.5×
labelEmbed384Sentence-transformer embedding of the attraction type string1.0×
summaryEmbed384Sentence-transformer embedding of the attraction summary text0.5×
The higher weight given to labelMHE reflects the observation that broad category (e.g. castle, beach, museum) is the strongest predictor of personal taste, while the semantic summary embedding captures finer-grained distinctions at a reduced influence. The assembly is performed by the normalize() function, called once during data ingestion and once each time a user profile is updated:
WEIGHT_MHE = 1.5
WEIGHT_TYPE = 1.0
WEIGHT_SUMMARY = 0.5

def safeL2Normalize(vec):
    arr = np.array(vec, dtype=float)
    l2 = np.linalg.norm(arr)
    if l2 > 0: arr /= l2
    return arr

def normalize(labelMHE, labelEmbed, summaryEmbed):
    return np.concatenate([
        safeL2Normalize(labelMHE) * WEIGHT_MHE,
        safeL2Normalize(labelEmbed) * WEIGHT_TYPE,
        safeL2Normalize(summaryEmbed) * WEIGHT_SUMMARY,
    ])
safeL2Normalize divides a vector by its L2 norm (guarding against zero-norm edge cases) before the weight is applied. The resulting 777-dim vector is not re-normalised after concatenation — the differential weighting is preserved in the final magnitude.

Recommendation Pipeline

The pipeline begins with a pgvector cosine-distance query. The value of k (the number of candidates retrieved) scales with the specificity of the geo-filter in order to keep the candidate pool large enough for MMR to operate effectively:
Geo filterk
No filter (all UK)100
Country (England / Scotland / Wales / NI)75
Region50
County25
The query uses the <=> pgvector cosine-distance operator and orders results ascending (smaller distance = more similar), excluding attractions the user has already swiped on.
Raw kNN results often cluster around a single corner of the embedding space — for example, returning 20 variations of “Victorian seaside pier” for a user who swiped right on one pier. Maximal Marginal Relevance (MMR) addresses this by iteratively selecting the next recommendation to maximise a trade-off between relevance to the user profile and dissimilarity to the already-selected items.The trade-off is controlled by lambda_mult = 0.5, which weights relevance and diversity equally:
MMR score = λ · sim(attraction, user_profile)
          − (1 − λ) · max_sim(attraction, already_selected)
At lambda_mult = 0.5 the algorithm is equally willing to sacrifice a small amount of relevance to gain meaningful diversity. The final ranked list (the top 10 items from the MMR pass) is what the frontend receives.
The re-ranked attractions are serialised by Django REST Framework into JSON objects containing the attraction’s metadata fields (name, county, region, summary, image URL, coordinates) but not the raw vector. The response is paginated so the frontend can request additional batches as the user works through the swipe stack.

Authentication Flow

UK Travel Recommendation uses JSON Web Tokens (JWT) via djangorestframework-simplejwt. The token lifecycle is designed to balance security (short-lived access tokens) with a smooth user experience (automatic silent refresh).
POST /api/user/token/
→ 200 OK
{
  "access":  "<JWT, expires in 15 minutes>",
  "refresh": "<JWT, expires in 30 days>"
}
Both tokens are stored immediately in Expo Secure Store, which uses the device’s hardware-backed secure enclave (Keychain on iOS, Keystore on Android) to protect them at rest.
Access tokens are intentionally short-lived (15 minutes) to limit the blast radius of token theft. Because the refresh flow is fully automatic, users are never prompted to log in again during an active session, even if the session spans multiple days.

Infrastructure

The entire backend stack is defined in a single docker-compose.yaml at the repository root:
services:
  backend:
    build: ./backend
    container_name: backend_csproj
    command: python manage.py runserver 0.0.0.0:8000
    ports:
      - "8000:8000"
    depends_on:
      - db
    env_file:
      - ./backend/.env

  db:
    image: pgvector/pgvector:pg15-bookworm
    container_name: postgres_vector
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
The backend service reads its configuration from ./backend/.env (Django secret key and database credentials). The db service receives its PostgreSQL credentials via inline environment variables sourced from the same .env file at the shell level. The pgvector/pgvector:pg15-bookworm image ships with the pgvector extension pre-compiled, so no additional installation step is needed — the extension is enabled by the Django migration that creates the vector columns.

Build docs developers (and LLMs) love