From NLQ Translation to Hybrid RAG

Turn a structured natural language query endpoint into a semantic and hybrid retrieval system.
python
RAG
LLMs
NLQ
Published

July 17, 2026

For my NPS Hikes project, I recently developed a RAG pipeline over a series of progressive phases in order to enable the query/ endpoint to handle semantic (in addition to structural) questions.

The main steps were:

  1. NLQ translation: use an LLM to turn plain English into parameters for existing endpoints.
  2. Semantic retrieval: collect text from NPS content endpoints, chunk it, embed it, and search it by meaning.
  3. Semantic-to-structured bridging: connect semantically matched content back to actual trail records.
  4. Hybrid retrieval: support queries that mix semantic intent and structured constraints in one flow.

NLQ over structured data

For this project’s first foray into LLM workflows, I introduced a natural language query endpoint over structured data. It used a local LLM to translate a query like “short hikes in Yosemite I’ve completed” into the existing API filters. Given this query, the LLM could extract table filters like park_code, hiked and max_length:

{
  "original_query": "short hikes in Yosemite I've completed",
  "interpreted_as": {
    "park_code": "yose",
    "hiked": true,
    "max_length": 3
  },
  "function_called": "search_trails",
  "results": {
    "trail_count": 12,
    "total_miles": 13.59,
    "trails": [...]
  }...
}    

Raw semantic retrieval

While an interesting demo, in reality, this endpoint was helpful only for such a narrow class of queries that it wasn’t much better than reading the documentation to learn how to filter the API.

This initial endpoint couldn’t answer any question not already represented in a column. For example, the data has no column waterfalls, slot_canyon, good_for_birdwatching, etc. The NPS API however offers endpoints thingstodo and places with exactly this kind of rich descriptive data, often at the trail level.

Given the availability of this data, a true RAG pipeline made more sense compared to a limited NLQ endpoint. I started by collecting, cleaning, and normalizing data from these two endpoints. For example, here’s a row from my nps_places table:

id park_code title short_description
6DF32715… yose Bridalveil Fall Trailhead A short walk will lead you to the base of Bridalveil Fall, allowing you to get an up close view of this often-windswept waterfall….

Chunking the text from these sources was simple. Most were already short chunks. From those chunks, the next step was generating embeddings. Here’s that same row with a computed embedding in the content_embeddings table:

id park_code source_type source_id title chunk_text embedding
10095 yose places 6DF32715… Bridalveil Fall Trailhead Bridalveil Fall Trailhead A short walk will lead you to the base of Bridalveil Fall… [0.005398781,…]

With these pre-computed embeddings, now I could embed a user’s query at runtime, and then retrieve the nearest chunks. A query for “waterfalls hikes” would return chunks mentioning “Bridalveil Fall”.

{
  "query": "waterfalls hikes",
  "result_count": 10,
  "results": [
        {
      "chunk_text": "Bridalveil Fall Trailhead\n\nA short walk will lead you to the base of Bridalveil Fall, allowing you to get an up close view of this often-windswept waterfall...",
      "title": "Bridalveil Fall Trailhead",
      "park_code": "yose",
      "park_name": "Yosemite National Park",
      "source_type": "places",
      "source_id": "6DF32715...",
      "similarity_score": 0.7228,
      "metadata": null
    }, ...
}    

Tip: A search query like /search?q=waterfalls&resolve_trails=false represents this phase, as resolve_trails=false means it returns raw semantic content chunks.

Semantic chunks to trail results

On its own, finding the most relevant chunks wasn’t particularly helpful without the next step: relating those chunks back to structured entities (trails). While phase 2 could take a query like “waterfalls hikes” and return a chunk about the content item “Bridalveil Fall Trailhead”, phase 3 needed to make the connection between that chunk and the actual trail record in Yosemite. In other words, instead of document retrieval, I needed trail retrieval.

To do so, during indexing, I precomputed a fuzzy match between content items (content_embedding_id) and structured trail records (trail_id). I stored the most confident results in content_trail_mapping. Here’s one such row:

id content_embedding_id park_code trail_name trail_id content_title name_similarity_score match_confidence
3535 10095 yose Bridalveil Fall Trail A9B9FCCB… Bridalveil Fall Trailhead 1 1

At this stage, a semantic search query could now return a proper trail set:

{
  "query": "waterfall hikes",
  "response_type": "trails",
  ...
  "trails": [
    ...
    {
      "trail_id": "{A9B9FCCB-1135-429D-BE9C-DD03B482AA15}",
      "trail_name": "Bridalveil Fall Trail",
      "park_code": "yose",
      "park_name": "Yosemite",
      "states": "CA",
      "source": "TNM",
      "length_miles": 0.636,
      ...
    }
  ],
  "topic_context": [
        {
      "trail_id": "{A9B9FCCB-1135-429D-BE9C-DD03B482AA15}",
      "trail_name": "Bridalveil Fall Trail",
      "content_title": "Bridalveil Fall Trailhead",
      "park_code": "yose",
      "park_name": "Yosemite",
      "chunk_text": "Bridalveil Fall Trailhead\n\nA short walk will lead you to the base of Bridalveil Fall, allowing you to get an up close view of this often-windswept waterfall..."
    },
  ]
  ...
}    

This maintained the same user experience for the API or the Streamlit app.

Tip: A search query like /search?q=waterfalls&resolve_trails=true represents this phase, as resolve_trails=true means it returns trail objects when finding a match.

Hybrid retrieval

The limitation of phase 3 was that the search endpoint could handle two separate classes of queries: purely structural or purely semantic. In reality though, these queries are often mixed. Given a query like “waterfall hikes in Yosemite”, the “in Yosemite” part is a structured query filter; “waterfalls” is semantic. Phase 4 combined these capabilities.

Now in the same flow, the system could:

  1. Interpret the user query with the LLM.
  2. Identify that a semantic component exists.
  3. Run semantic matching to find relevant trail-linked content.
  4. Apply structured filters to the resulting trail candidates.
  5. Return a normal trail result set with preserved semantic intent.

You can imagine the same JSON results above, but with structured filters.

Tip: A search query like /search?q=waterfalls&resolve_trails=true&park_code=yose&hiked=true&max_length=3 represents this phase, as it returns filtered trail objects.

Full circle

Having added semantic retrieval, content-to-trail linking, and hybrid search, the query/ endpoint, which originally performed only structured filtering, could become something much more capable. Instead of only parsing for structured filters, it became a unified natural-language front door to all the retrieval modes behind the application:

  • Structured trail and park search
  • Semantic topic search
  • Hybrid semantic-plus-structured search
  • Grounded answer generation from retrieved context

The last one is new. In addition to the trail response, a query like “short waterfall hikes I’ve completed in Yosemite” includes a generated_answer like this:

There are multiple waterfalls mentioned in the context:

- **Yosemite Falls**, which is made up of the upper fall, middle cascades, and lower fall, and is one of the tallest waterfalls in North America (Context [4], [6], and [9]).
- **Bridalveil Fall**, which is a 620-foot (189 meters) waterfall that flows year-round and is known for its characteristic light, swaying flow (Context [2], [3], and [5]).
- **Lower Yosemite Fall**, which is the final 320-foot (98-meter) drop of Yosemite Falls and is often dry by late summer (Context [1] and [9]).

Information comes from various Yosemite National Park contexts.

The key part is still parsing the query and returning filtered trail results, but the generated answer provides an approximate narrative add-on.