An Azure-hosted API that turns plain-English queries into ranked educational dataset recommendations from the DfE's Explore Educational Statistics (EES) platform. A user asks "pupil attendance for London secondary schools 2023"; the system returns relevant datasets, each with the specific filters and indicators worth selecting.
It combines Azure Cognitive Search (hybrid BM25 + vector) with a multi-stage Azure OpenAI pipeline (GPT-4.1-mini by default). Results stream back to the client as Server-Sent Events (SSE) so partial results appear progressively.
Stack: Python - Azure Functions - FastAPI (mounted as ASGI) - Azure Cognitive Search - Azure OpenAI
ees-natural-language-search/
├── function_app.py # Azure Functions entry point - mounts FastAPI via an ASGI proxy
├── host.json # Functions runtime config
├── requirements.txt
├── local.settings.example.json # Template for local env vars
├── azure-pipelines.yml # CI/CD
│
├── core/
│ ├── app.py # Builds the FastAPI app, registers the main routes
| └── config.py # Loads local.settings.json into os.environ (local only)
|
├── common/ # All business logic
│ ├── workflow.py # Pipeline orchestrator
│ ├── retrieve_datasets.py # Thin wrapper over multi_index_search
│ ├── search_client.py # Azure Search clients + embeddings + hybrid search
│ ├── openai_client.py # generate_answer() - Azure OpenAI chat wrapper
│ ├── reranker.py # LLM agent: rerank + extract query requirements
│ ├── filter_selection.py # LLM agent: pick relevant filter values per dataset
│ ├── indicator_selection.py # LLM agent: pick relevant indicators per dataset
│ ├── data_utils.py # Filter retrieval, response merge, score conversion
│ └── location_utils.py # Fuzzy location matching + geographic level grouping
│
└── routes/
├── natural_language_search_function.py # POST /api/natural_language_search_function (SSE)
├── healthcheck.py # GET /health_check
└── vectorizer_middleware.py # POST /api/vectorizer_middleware (embeddings for indexers)
function_app.pyproxies the main API routes into the FastAPI ASGI app, while the health check is registered directly as an Azure Functions route. Streaming responses are forwarded through anasyncio.Queue.- The route handler in
natural_language_search_function.pycallsrun_workflow(...), which is an async generator which yields plain dicts; the route serialises each one asdata: <json>\n\nand is also where exceptions become a{"error": ...}SSE event. common/workflow.pyruns the pipeline stage by stage, yielding after each.
run_workflow(user_query, publication)
│
├── 1. retrieve_relevant_datasets -> multi_index_search (search_client.py -> Azure Search)
│ Hybrid BM25 + vector search over the Filter index; dataset docs fetched by id.
│ Also returns grouped filter labels per dataset for downstream narrowing.
│ yields {stage:"retrieved datasets", data:{datasets:[...]}}
│
├── 2. run_reranking_agent (reranker.py -> Azure OpenAI)
│ LLM shortlists datasets and extracts queryRequirements (filters, geography, timePeriod).
│ Workflow augments the shortlisted datasets dataset metadata and relevanceScore obtained at the previous step.
│ yields {stage:"reranker complete", data:{confidence, datasets:[...], query_requirements, token_usage, cost}}
│
├── 3. Build reranked dataset map with subject metadata (workflow.py + EES Data API subject meta)
│ Build reranked_datasets_by_id from reranker datasets + subject meta
│
├── 4. get_location_matches (location_utils.py)
│ Fuzzy-match mentioned locations, group by allowed geographic levels per dataset
│
├── 5. retrieve_and_transform_filter_data (data_utils.py -> Azure Search filter index)
│ Fetch full filter values for shortlisted datasets, flattened for the LLM
│
├── 6. filter + indicator + time period agents in parallel (asyncio.gather -> Azure OpenAI)
│ Per-dataset relevance decisions for each filter value/indicator
│
└── 7. parse_selection_responses + build_final_dataset_response (data_utils.py)
Parse the agent responses keyed by file id, then merge filters, indicators, time period, locations,
and a relevance reason, per dataset.
yields {stage:"pipeline complete", data:{datasets:[...], token_usage:<int>}}
| Stage | data payload |
|---|---|
starting pipeline |
(none) |
retrieved datasets |
{datasets:[...]} |
reranker complete |
{confidence, datasets:[...], query_requirements, token_usage, cost} |
pipeline complete |
{datasets:[{fileId, filters:[{id, label}], indicators:[{id, label}], timePeriod, geographicLevels, relevanceReason, autoSelectedFilters:{<filterLabel>:{filterItemLabel, filterItemId}}, unfilteredFilters:[<filterLabel>]}], token_usage, cost} |
error (from route, on exception) |
{error: <message>} |
The most important file. Accumulates token_usage across all LLM calls and yields after each stage. It builds a reranked_datasets_by_id map with dataset metadata plus subject meta and passes that through geography/filter/indicator/time period stages. If the reranker shortlists nothing, downstream stages simply produce empty results.
- Module-level
filter_clientanddataset_clientare created at import. Credential isAzureKeyCredentialifAZURE_SEARCH_KEYis set, elseDefaultAzureCredential(). get_embeddings(input_text, model_name, dimensions=1536)->**returns (embeddings, total_tokens)**. Lists are batched in groups of 15 with up to 2 attempts and exponential backoff on transient errors.hybrid_search(...)runs BM25 + vector search against the filter index (top=10, vectorweight=0.5), optionally filtered bypublicationTitleandlatestData.multi_index_search(...)groups filter hits byfileId, keeps themax @search.scoreper dataset, then fetches each dataset doc viadataset_client.get_document(...). Returns(query, datasets, max_scores, grouped_filters).
Sends the query plus trimmed dataset metadata (fileId, title, content, filters, timePeriodRange) to the LLM. Returns a typed RerankingAgentResult used downstream:
grouped_filters, grouped_indicators, reranker_response, and total_tokens_used. The LLM output schema remains:
queryRequirements{filters[], geography[], timePeriod}, shortlistedDatasets[{fileId, title, relevanceReason, relevantFilters[]}], confidence.
One LLM call per reranked dataset, all gathered concurrently. Each returns a list of raw JSON strings plus a token total.
- Filter output:
{"<fileId>": { "filterItems": { "<filter label>|||<filter item group ID>|||<filter item label>": {relevant(Yes/No), reasoning} } } } - Indicator output:
{"<fileId>" { "<indicators>": {relevant(Yes/No), reasoning} } }
retrieve_and_transform_filter_data(...)pulls full filter values from the filter index and flattens them per dataset.parse_selection_responses(...)parses the filter/indicator/time period agent responses and merges them into dicts keyed by file id.build_final_dataset_response(...)takes the parsed filter/indicator/time period results, keeps only values markedrelevant: true, resolves ids from subject meta, attachesgeographicLevelsand arelevanceReason, and returns aFinalDatasetResponse. Every filter always ends up with a selection. A filter without any relevant filter items uses the filter item with itsautoSelectFilterItemIdas a fallback if set. If there's noautoSelectFilterItemIdevery filter item of that filter is selected.rrf_to_percentage(score)scales an RRF score to 0-100
hybrid_scoreronly accepts a perfecttoken_set_ratio(100) when >= 2 tokens overlap and the candidate isn't much shorter than they query; otherwise falls back toWRatio
generate_answer(...) calls Azure OpenAI chat completions with temperature=0, top_p=1, seed=42 (deterministic-ish) and returns the full response object
Returns { message: "API working" }.
Generates embeddings for Azure Search skillset/indexer integration. Uses AZURE_OPENAI_EMBEDDING_DEPLOYMENT and EMBEDDING_DIMENSIONS (default 1536).
//request
{ "values": [ { "recordId": "1", "data": { "text": "text to embed" } } ] }
//response
{ "values": [ { "recordId": "1", "data": { "embedding": [0.12, ...] } } ] }Replace publicationId with the ID of the publication you want to search.
{
"userQuery": "pupil attendance in London secondary schools in 2023",
"publicationId": "00000000-0000-0000-0000-000000000000"
}The response is returned as text/event-stream.
Prerequisites: Python (CI pipeline targets 3.14), Azure Functions Core Tools, and access to the Azure Search / OpneAI / Storage resources.
pip install -r requirements.txt
Copy-Item local.settings.example.json local.settings.json # then fill in values
func start # serves on http://localhost:7071Test:
Replace publicationId with the ID of the publication you want to search.
curl -X POST https://localhost:7071/api/natural_language_search_function `
-H "Content-Type: application/json" `
-d '{"userQuery": "Show me the percentage of pupils reported as on holiday in the last 4 weeks", "publicationId": "00000000-0000-0000-0000-000000000000"}'core/config.py loads local.settings.json into environment locally if you plan on running it with uvicorn