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:
- NLQ translation: use an LLM to turn plain English into parameters for existing endpoints.
- Semantic retrieval: collect text from NPS content endpoints, chunk it, embed it, and search it by meaning.
- Semantic-to-structured bridging: connect semantically matched content back to actual trail records.
- 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=falserepresents this phase, asresolve_trails=falsemeans 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=truerepresents this phase, asresolve_trails=truemeans 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:
- Interpret the user query with the LLM.
- Identify that a semantic component exists.
- Run semantic matching to find relevant trail-linked content.
- Apply structured filters to the resulting trail candidates.
- 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=3represents 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.