Normalizing a mess of documents into usable data: AI embeddings, RAG, and semantic search

This side project started with a friend describing their small business and how their data is stored: “each customer is a folder with a bunch of random documents (pdf, csv, docx, images)”. Their tour-guide business was becoming too successful and they needed help using their data to template new leads that came in from their website.

I created a process to:

  • Extract document text and distill it down to useful data, using AI and JSON schema contracts.
  • Create text embeddings (vector storage) for semantic search.
  • Create a “generate” function that uses RAG (retrieval augmented generation) to quickly build smart working prototypes from normal language.

Now they could type or paste natural language queries from customers like:

My name is Jasper Showers and we are a family of 4, looking for a tour that goes along the coast of Spain. We’ll be there November 1st and plan want the tour to last 5 days.

Here is how I built it, and the technologies used.

Step 1 — Turning random files into structured data

The messy folders were full of PDFs, Word docs, and Excel spreadsheets. I wrote an ingestion pipeline that extracts text from each format (pypdfpython-docxopenpyxl) and feeds it to an LLM (DeepSeek).

The clever part is the JSON schema contract. Instead of asking for a freeform summary, I define a strict schema and the model must return exactly that shape. Every tour document gets normalized into a structured record:

{
  "client_group_name": "...",
  "document_name": "...",
  "tours": [
    {
      "title_en": "...",
      "total_days": 7,
      "activities": [
        {"day_number": 1, "title_en": "...", "description_en": "..."}
      ]
    }
  ],
  "price_sources": [
    {"service_name": "...", "unit_price": 120.0, "total_price_eur": 240.0}
  ]
}

LLM output is unpredictable, so the ingestion layer is defensive: malformed or missing fields are warned and skipped, never fatal to a run. This is how you turn “years of PDFs” into a queryable database without a human reading a single one.

Step 2 — Semantic search with vector embeddings

Structured data alone isn’t enough — keywords fail when customers describe things loosely (“a coastal route in early winter”). So each tour summary is embedded into a 1024-dimensional vector using jina-embeddings-v3 (Jina’s OpenAI-compatible API), stored in Postgres with the pgvector extension (hosted on Supabase).

Because the source docs are bilingual, each record gets two vectors — English and Spanish — and queries match against whichever is closest using L2 (Euclidean) distance. A query in either language retrieves relevant historical tours by meaning, not exact words.

Step 3 — RAG: retrieval-augmented generation

The “generate” endpoint is the product: paste a lead’s request, get a working draft itinerary.

  1. Parse the free text into structured inquiry details (travelers, dates, destinations, preferences) via DeepSeek and another JSON schema contract.
  2. Retrieve the top-k most similar historical templates via the embeddings from step 2.
  3. Generate a proposal grounded in that retrieved context, with prices cited back to the source documents.

A key detail: retrieval runs before any LLM call. If there isn’t enough historical data to ground a response, the API returns a 503 rather than letting the model hallucinate. Every generated line item carries a citation to the historical price source it came from, so nothing is made up.

Step 4 — Editable, shareable output

The generated draft isn’t a dead document — it’s persisted as an editable itinerary via Django REST Framework. The team can tweak it in a UI, and export branded deliverables: a client-copy PDF (built with reportlab) and a Word .docx (built with python-docx).

The stack

  • Backend: Python, Django 5, Django REST Framework
  • Storage: Postgres + pgvector (Supabase), with embeddings kept in the same DB as the data they describe
  • AI: DeepSeek (chat/JSON extraction), Jina AI embeddings via the OpenAI SDK
  • Docs: pypdf, python-docx, openpyxl
  • Output: reportlab (PDF), python-docx
  • Deploy: GitHub Actions → Namecheap cPanel (Passenger), auto-deploy on push to main

What I’d do differently

The extraction pipeline relies on the LLM following a JSON contract, and while retries + defensive parsing catch most drift, schema validation and a human review step would harden it. Also, embeddings age — re-embedding after schema or model changes is a batch job worth automating.

Overall it turned a pile of unread folders into a system that answers “do we have something like this?” in seconds — and drafts the answer too.

Leave a Reply

Your email address will not be published. Required fields are marked *