Build a Local Hybrid RAG API

Build a free local API that fuses semantic and keyword search with RAG.

Introduction

30 Second Summary

When a question mixes broad ideas with exact commands, one search method can miss the clue that matters. Cloud tools can also turn an experiment into an ongoing bill.

In this project, you will build a completely free local hybrid RAG API that answers questions from a shared knowledge base. It uses Pinecone for semantic retrieval, OpenSearch for keyword retrieval, Reciprocal Rank Fusion to merge rankings, plus a local Llama 3.2 1B model for grounded generation.

What You'll Build

You will post a question to a local /ask HTTP endpoint. Its JSON response shows the generated answer, its sources, plus every retrieval ranking produced through locally emulated AWS Lambda and API Gateway.

By the end of this project, you'll have:

  • One searchable knowledge base that returns meaning-based results from Pinecone plus exact-term results from OpenSearch.
  • Visible retrieval diagnostics that show the semantic, keyword, plus fused record rankings for each question.
  • A local serverless API that returns a source-grounded answer without deploying billable AWS resources.
  • Secret Mission: Evaluate conceptual, identifier-heavy, plus paraphrased questions to recommend one evidence-based retrieval improvement.

Are there any prerequisites?

You need a supported 64-bit Windows computer with virtualization enabled, at least 8 GB of RAM, plus at least 4 GB free for Ollama.

You also need extra space for models and container images, plus internet access for installers and model downloads.

Before We Start

Before any setup begins, this checkpoint locks in your goal: a zero-cost local hybrid RAG API that answers questions from five documents. The workflow turns two retrieval rankings into grounded local answers.

Prepare the Windows Toolchain

Your local hybrid RAG API eventually coordinates two search systems with a local language model.

Visual Studio Code becomes your editor. Python becomes your runtime.

Docker Desktop provides local containers. AWS SAM provides the AWS-compatible local test harness.

In this step, get ready to:
  • Install Visual Studio Code 1.141.1 plus Python 3.13.16.
  • Confirm the Windows requirements before installing Docker Desktop in per-user WSL 2 mode.
  • Install AWS SAM CLI 1.169.0 before verifying the complete toolchain.
Install Visual Studio Code and Python

Both tools add commands to the Windows search path. A fresh PowerShell window confirms whether each installation is available.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search box.
  • Press Enter to open PowerShell.
  • Check for Visual Studio Code by running this command:
code --version

What does this check?

The command asks the Visual Studio Code command-line interface for its installed version. It also proves that Windows can find code through the search path.

✔️ I see the required version

Visual Studio Code 1.141.1 is ready. Your editor is available from PowerShell.

ⓧ I see an older version

Your existing installation needs the project version before you continue.

  • Open the Visual Studio Code download page in your browser.
  • Select the x64 option under User Installer for Windows.
  • Run the downloaded Visual Studio Code installer from your browser downloads.
  • Complete the installer with its default options.
  • Close the current PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Confirm the installed version by running:
code --version

What should I see?

The first output line should show 1.141.1. Reopening PowerShell loads the search path updated by the installer.

ⓧ Command not found

Windows cannot find the editor command because Visual Studio Code still needs its user installation.

  • Open the Visual Studio Code download page in your browser.
  • Select the x64 option under User Installer for Windows.
  • Run the downloaded Visual Studio Code installer from your browser downloads.
  • Complete the installer with its default options.
  • Close the current PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Verify the installation by running:
code --version

What should I see?

The first output line should show 1.141.1. The user installer adds Visual Studio Code to the Windows search path.

  • Check for Python by running this command in the fresh PowerShell window:
python --version

What does this check?

The command reports the Python runtime selected by Windows. The project needs Python 3.13.16 for its local scripts.

✔️ I see the required version

Python 3.13.16 is ready. Your runtime now matches the project.

ⓧ I see an older version

Your current Python command points to an older runtime. Installing the project version updates that command for the upcoming virtual environment.

  • Open the official Python Windows releases page in your browser.
  • Find Python 3.13.16 on the releases page.
  • Select Download Windows installer (64-bit) under that release.
  • Run python-3.13.16.exe from your browser downloads.
  • Select Add Python to PATH on the first installer screen.
  • Select Install Now to begin the installation.
  • Close the installer after it reports completion.
  • Open a fresh PowerShell window through Windows search.
  • Confirm the selected runtime by running:
python --version

What should I see?

PowerShell should report Python 3.13.16. The PATH option makes the runtime available through the python command.

ⓧ Command not found

Python still needs its official Windows installation.

  • Open the official Python Windows releases page in your browser.
  • Find Python 3.13.16 on the releases page.
  • Select Download Windows installer (64-bit) under that release.
  • Run python-3.13.16.exe from your browser downloads.
  • Select Add Python to PATH on the first installer screen.
  • Select Install Now to begin the installation.
  • Close the installer after it reports completion.
  • Open a fresh PowerShell window through Windows search.
  • Verify the installation by running:
python --version

What should I see?

PowerShell should report Python 3.13.16. This confirms that Windows can locate the runtime.

Confirm Windows and install Docker Desktop

Docker Desktop uses the Windows Subsystem for Linux to run Linux containers locally. The WSL 2 backend requires a supported Windows release plus hardware virtualization.

This project also needs at least 8 GB of system RAM. Keep at least 4 GB free for Ollama plus extra space for model files and container images.

  • Open a fresh PowerShell window through Windows search.
  • Display your Windows release information by running:
winver

What does this check?

The command opens the Windows version dialog. Your machine needs Windows 10 release 22H2 or newer.

A supported Windows 11 release also meets the operating system requirement.

  • Confirm the dialog shows Windows 10 release 22H2 or a supported Windows 11 release.
  • Close the Windows version dialog.
  • Inspect the hardware details by running:
systeminfo

What does this check?

The report shows your installed physical memory. It also reports whether firmware virtualization is enabled.

These details determine whether the WSL 2 container engine can run reliably.

  • Confirm the report shows at least 8 GB of installed physical memory.
  • Confirm the report shows that firmware virtualization is enabled.
  • Follow Microsoft's virtualization guidance before continuing if virtualization is disabled.
  • Press the Windows key to open Windows search.
  • Type Storage settings into the search box.
  • Press Enter to open the storage summary.
  • Confirm your main drive has at least 4 GB free for Ollama plus extra free space for model files and container images.
  • Confirm your internet connection by loading the Docker Desktop installation page.
  • Check the installed WSL version by running:
wsl --version

What does this check?

The command confirms that WSL is installed. Docker Desktop requires WSL version 2.1.5 or newer for its documented Windows configuration.

✔️ I see the required WSL version

WSL version 2.1.5 or newer is ready. Your machine can support Docker Desktop's WSL 2 backend.

ⓧ I see an older WSL version

  • Update WSL from PowerShell by running:
wsl --update

What does this command do?

The command updates the installed WSL application. This brings its runtime components up to date for Docker Desktop.

  • Restart Windows if the update asks you to reboot.
  • Open a fresh PowerShell window through Windows search.
  • Confirm the updated version by running:
wsl --version

What should I see?

The reported WSL version should be 2.1.5 or newer.

ⓧ Command not found

WSL needs a one-time Windows installation before Docker Desktop can use its Linux engine.

  • Close the current PowerShell window.
  • Search for PowerShell from the Windows search box.
  • Select Run as administrator for PowerShell.
  • Approve the Windows permission prompt.
  • Install WSL by running:
wsl --install

What does this command do?

The command enables the Windows features required by WSL. It also installs the current WSL components.

  • Restart Windows after the installation completes.
  • Open a fresh PowerShell window through Windows search.
  • Confirm WSL is available by running:
wsl --version

What should I see?

The command should print WSL version information. Confirm the reported version is 2.1.5 or newer.

  • Check whether Docker Desktop is already available by running:
docker version

What does this check?

The command asks the local Docker installation for command-line information plus engine information. A working setup prints both Client and Server sections.

✔️ I see Client and Server sections

Docker Desktop is running through its local engine. Your container runtime is ready for OpenSearch in the next step.

ⓧ I only see the Client section

The Docker command is installed. Its local engine still needs to finish starting.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop into the search box.
  • Press Enter to start Docker Desktop.
  • Keep Docker Desktop open until it reports that the engine is running.
  • Return to PowerShell.
  • Check the engine again by running:
docker version

What should I see?

The output should now contain both Client and Server sections. The Server section proves that the local engine is responding.

ⓧ Command not found

Docker Desktop needs its per-user installation. This mode uses WSL 2 without requiring an all-users installation.

  • Select the Windows download from the official Docker Desktop installation page.
  • Run Docker Desktop Installer.exe from your browser downloads.
  • Choose the per-user installation mode when prompted.
  • Choose WSL 2 as the backend on the configuration screen.
  • Complete the installation wizard.
  • Select Close after the installation succeeds.
  • Press the Windows key to open Windows search.
  • Type Docker Desktop into the search box.
  • Press Enter to start Docker Desktop.

The first engine start may spend a few minutes preparing its WSL 2 components. Keep Docker Desktop open while that work finishes.

  • Complete the displayed first-run agreement if Docker Desktop asks for acceptance.
  • Wait for Docker Desktop to report that its engine is running.
  • Open a fresh PowerShell window through Windows search.
  • Verify the local engine by running:
docker version

What should I see?

The output should contain both Client and Server sections. That result confirms Docker Desktop can run local containers.

Install AWS SAM CLI and verify the toolchain

AWS SAM CLI runs the future Lambda-style handler behind a local HTTP endpoint. The project uses version 1.169.0 through the official Windows installer.

  • Check whether AWS SAM CLI is already available by running:
sam --version

What does this check?

The command reports the installed AWS SAM CLI version. It also confirms that Windows can locate the sam command.

✔️ I see the required version

AWS SAM CLI 1.169.0 is ready. Your local serverless test harness is installed.

ⓧ I see an older version

The official installer can replace the older release with the project version.

  • Open the AWS SAM CLI 1.169.0 release page in your browser.
  • Expand the release assets if they are collapsed.
  • Download AWS_SAM_CLI_64_PY3.msi.
  • Run the downloaded MSI from your browser downloads.
  • Complete the installer with its default options.
  • Close the current PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Confirm the updated version by running:
sam --version

What should I see?

The output should report AWS SAM CLI 1.169.0.

ⓧ Command not found

AWS SAM CLI needs the official Windows MSI before PowerShell can invoke it.

  • Open the AWS SAM CLI 1.169.0 release page in your browser.
  • Expand the release assets if they are collapsed.
  • Download AWS_SAM_CLI_64_PY3.msi.
  • Run the downloaded MSI from your browser downloads.
  • Complete the installer with its default options.
  • Close the current PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Verify the installation by running:
sam --version

What should I see?

The output should report AWS SAM CLI 1.169.0. Reopening PowerShell loads the command installed by the MSI.

Before the final check, which tool do you expect to report both a command-line component and a running engine?

  • Close every open PowerShell window.
  • Open one fresh PowerShell window through Windows search.
  • Verify the complete toolchain by running these commands:
code --version
python --version
docker version
sam --version

What does the final check prove?

  • The first command proves that Visual Studio Code is available through the Windows search path.
  • The second command proves that Python 3.13.16 is the selected runtime.
  • The third command proves that the Docker command plus local engine are responding.
  • The fourth command proves that AWS SAM CLI 1.169.0 is installed.

You should see Visual Studio Code 1.141.1 plus Python 3.13.16. Docker should print Client and Server sections.

AWS SAM CLI should report 1.169.0. That is the full Windows toolchain working from one fresh shell.

Is one command still failing?

Close PowerShell after each installer finishes. A fresh window reloads the updated Windows search path.

Keep Docker Desktop running if the Docker output lacks a Server section.

Ask for help: Help me diagnose which Windows toolchain command is failing and guide me through checking its installation or PATH entry.

Your Windows toolchain is ready. Next up, you can start the local model plus search services that power the hybrid retrieval workflow.

Start Local AI and Search Services

Managed model endpoints can introduce usage charges. Managed search services create the same problem for a strict $0 project. This step replaces them with Ollama for local generation plus OpenSearch for local keyword retrieval.

You will also prepare a free Pinecone Starter account for semantic retrieval. These services form the foundation of your hybrid RAG workflow.

In this step, get ready to:
  • Install Ollama 0.40.4 with the Llama 3.2 1B model.
  • Run an OpenSearch 3.9.0 container through Docker Desktop.
  • Prepare the Pinecone account plus the local Python project environment.
Install Ollama and download Llama 3.2 1B

Ollama runs the language model on your computer. Its local service gives the application a model endpoint at http://localhost:11434.

  • Check whether Ollama is already available by running this command in the PowerShell window from earlier:
ollama --version

✔️ I see the required version

Your Ollama installation is ready if the output shows version 0.40.4.

ⓧ I see an older version

Your installed Ollama version needs an update before you continue.

  • Visit the official Ollama Windows page.
  • Download the Windows installer.
  • Run the installer to update Ollama.
  • Close the installer after it finishes.
  • Return to a fresh PowerShell window.
  • Check the installed version again by running:
ollama --version

You should now see version 0.40.4.

ⓧ Command not found

Ollama still needs to be installed on this computer.

  • Visit the official Ollama Windows page.
  • Download the Windows installer.
  • Run the installer.
  • Close the installer after it finishes.
  • Return to a fresh PowerShell window.
  • Verify the installation by running:
ollama --version

You should see version 0.40.4.

Ollama Still Unavailable?

Close every PowerShell window after the installer finishes. Start a fresh PowerShell window so Windows reloads the command path.

Ask for help: Help me verify my Ollama installation and Windows command path.

The first model run downloads Llama 3.2 1B before opening an interactive prompt. This can take several minutes because Ollama stores the model on your computer.

  • Download the model plus start its interactive prompt by running:
ollama run llama3.2:1b

What Does This Command Do?

Ollama downloads llama3.2:1b when the model is missing. It then starts an interactive session backed by the local model.

  • Enter a short question about hybrid retrieval at the model prompt.
  • Press Enter to submit the question.
  • Wait for the model to return a response.
  • Close this PowerShell window after you receive the response.

Before you check, which model do you expect Ollama to list after that download?

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Inspect the downloaded models by running:
ollama ls

You should see llama3.2:1b in the model list. That confirms your local generator is ready.

Model Missing From the List?

Run the model command again if the download was interrupted. Keep the PowerShell window open until the interactive prompt accepts a question.

Ask for help: Help me diagnose an incomplete Ollama model download.

Start OpenSearch in Docker

OpenSearch gives the project a separate ranking for exact commands plus identifiers. This development container uses one node with its security plugin disabled.

Keep This Service Local

Disabling the security plugin is suitable for local development only. Keep port 9200 off the public internet.

The first container start can take several minutes while Docker downloads the OpenSearch image. A quiet terminal during this download does not mean the process has failed.

  • Create the local OpenSearch container by running:
docker run -d -p 9200:9200 -p 9600:9600 -e "discovery.type=single-node" -e "DISABLE_SECURITY_PLUGIN=true" --name opensearch opensearchproject/opensearch:3.9.0

What Does This Command Do?

  • The -d flag keeps the container running in the background.
  • Port 9200 exposes the OpenSearch API to your computer.
  • Port 9600 publishes the performance analyzer endpoint.
  • The name opensearch gives later commands a stable container target.

Docker prints the new container ID when creation succeeds. OpenSearch can take another minute to accept requests.

Before you query the endpoint, what details do you expect a running search cluster to return?

  • Query the local OpenSearch endpoint by running:
Invoke-RestMethod http://localhost:9200

You should see cluster information with the distribution set to opensearch. This proves that the container is reachable through port 9200.

OpenSearch Not Responding?

Confirm the Docker engine from earlier is still running. A stopped engine prevents the OpenSearch container from starting.

A different local service may already use port 9200. Stop that service before retrying the container command.

Ask for help: Help me diagnose why my local OpenSearch endpoint is unavailable.

Prepare Pinecone and the project environment

Pinecone provides the semantic side of the retriever. Its integrated embeddings keep this project focused on retrieval without adding another local embedding library.

Keep the Account Free

Plan selection can feel risky when paid options appear nearby. Remain on Pinecone Starter because this project fits within its published free quotas.

Do not activate Builder, Standard, or Enterprise for this project.

  • Visit the Pinecone website.
  • Create a Pinecone account.
  • Select the Starter plan during plan selection.
  • Create an API key through the Pinecone console.
  • Store the new key in a secure password manager.

The Pinecone SDK reads the secret from PINECONE_API_KEY. A session environment variable keeps the live key out of the project files.

  • Replace your-api-key-here with the Pinecone key stored in your password manager.
  • Set the secret for the active PowerShell session by running:
$env:PINECONE_API_KEY="your-api-key-here"

Why Use the Current Session?

PowerShell passes this environment variable to Python processes started from the same window. Closing the window clears the session value.

The env.json file keeps its placeholder until the local AWS SAM test later in the project.

The supplied project files provide the shared knowledge base plus the staged application structure. A Python virtual environment keeps this project's packages separate from other projects.

  • Move PowerShell to your Desktop by running:
Set-Location -Path ~/Desktop
  • Create the hybrid-rag-api project folder by running:
New-Item -Path .\hybrid-rag-api -ItemType Directory

PowerShell should print a directory entry named hybrid-rag-api.

  • Move into the new project folder by running:
Set-Location -Path .\hybrid-rag-api
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Use the editor's folder picker to select the hybrid-rag-api folder on your Desktop.
  • Copy the supplied project files into the selected folder.
  • Keep the PINECONE_API_KEY value in env.json set to your-api-key-here.

You should see the app folder plus the data folder. You should also see env.json, seed.py, and template.yaml at the top level.

✔️ Awesome, I've got everything!

Great. Save every project file before creating the virtual environment.

ⓧ I'd like to double check the full code

Compare your copied project against this artifact map.

  • The app/app.py file is prepared for staged implementation of Pinecone semantic retrieval, OpenSearch keyword retrieval, Reciprocal Rank Fusion, ChatOllama generation, and lambda_handler.
  • The data/documents.json file contains five records from doc-1 through doc-5.
  • The five knowledge-base topics are Semantic retrieval, Keyword retrieval, Reciprocal Rank Fusion, Local AWS SAM API, and Grounded generation.
  • The seed.py file is prepared to create the free-hybrid-rag Pinecone index.
  • The same ingestion script is prepared to recreate the hybrid-rag OpenSearch index.
  • The template.yaml file defines HybridRagFunction with app/ as its CodeUri.
  • The template uses app.lambda_handler as its Handler.
  • The template exposes a POST event at /ask.

The complete app/requirements.txt file should contain:

pinecone==10.0.0
langchain-ollama==1.1.0

The env.json file should contain the local environment configuration for HybridRagFunction.

  • Keep PINECONE_API_KEY set to your-api-key-here.
  • Keep PINECONE_INDEX_NAME set to free-hybrid-rag.
  • Keep PINECONE_NAMESPACE set to docs.
  • Keep OPENSEARCH_URL set to http://host.docker.internal:9200.
  • Keep OPENSEARCH_INDEX set to hybrid-rag.
  • Keep OLLAMA_BASE_URL set to http://host.docker.internal:11434.
  • Keep OLLAMA_MODEL set to llama3.2:1b.
  • Create the .venv virtual environment by running this command in the same PowerShell window:
python -m venv .venv

You should see a .venv folder appear in the Visual Studio Code file list.

  • Activate the virtual environment by running:
.\.venv\Scripts\Activate.ps1

Your PowerShell prompt should gain a virtual environment indicator. Python commands in this window now use the isolated environment.

Package installation can take a minute while Python downloads the two dependencies. Keep this PowerShell window open because it also holds your Pinecone API key.

  • Install the project dependencies by running:
python -m pip install -r app\requirements.txt

The installation output should name pinecone==10.0.0 plus langchain-ollama==1.1.0. PowerShell returns to the activated environment prompt when installation finishes.

Environment Setup Failing?

Confirm PowerShell is inside the hybrid-rag-api folder. The installation command must find app/requirements.txt from that location.

If PowerShell blocks activation, use the error details to review the execution policy configured for your Windows account.

Ask for help: Help me diagnose my virtual environment or requirements-file path.

Before the final check, which model plus search distribution do you expect the two commands to report?

  • Confirm the local model remains installed by running:
ollama ls

You should see llama3.2:1b in the model list.

  • Confirm the search service remains reachable by running:
Invoke-RestMethod http://localhost:9200

You should see OpenSearch cluster information with the distribution set to opensearch.

Your local model plus keyword search service are running. Your semantic search account plus Python environment are also ready for the shared knowledge base.

Next, you will seed the same five documents into both retrieval systems. You will then inspect your first semantic ranking.

Build the Knowledge Base

Your local answer engine is ready. Your search container is running.

The API still needs a shared knowledge collection. You’ll load five records into Pinecone.

The same IDs will also go into OpenSearch. Semantic retrieval can then produce its first visible ranking before exact-term behavior is tested.

In this step, get ready to:
  • Review the five-document knowledge collection.
  • Seed matching record IDs into both retrieval systems.
  • Query the top three Pinecone semantic results.
Review the document collection

A fixed JSON collection keeps this project focused on retrieval behavior. Matching IDs make each document traceable across both search systems.

  • Return to Visual Studio Code from earlier.
  • Select data/documents.json in the Explorer sidebar.
  • Confirm the array contains objects numbered doc-1 through doc-5.

You should see five documents covering retrieval, fusion, local API testing, and grounded generation.

How are the documents structured?

  • The id field gives each document a stable identifier.
  • The title field names the document topic.
  • The source field records the source filename returned with an answer.
  • The content field contains the evidence that each retriever searches.
Seed both retrieval systems

The ingestion script uses each shared ID to represent one logical document across two retrieval systems. This gives later ranking comparisons a stable reference point.

Resetting an index can feel risky. The script limits the reset to the project’s local hybrid-rag data.

  • Switch back to the active PowerShell session from earlier.

Before you run this, form a prediction about whether the semantic validation list stays empty.

  • Seed both retrieval systems by running this command:
python seed.py

What does the seed command do?

  • The seed_opensearch() function recreates the local hybrid-rag index. Its mapping makes title searchable as text.
  • The same mapping makes content searchable as text. It stores source as a keyword.
  • The seed_pinecone() function creates free-hybrid-rag when needed. It maps chunk_text into embeddings with llama-text-embed-v2.
  • The validation loop waits for Pinecone’s asynchronous upsert to become searchable. It then prints the visible hit IDs.

You’ll see Seeded 5 records into both retrieval systems. near the end of the output. You’ll also see Semantic validation hits: followed by one or more document IDs.

That is the storage backbone working: both retrievers now hold the same five IDs.

Missing records or semantic hits?

  • Confirm the opensearch container is still running in Docker Desktop.
  • Confirm PINECONE_API_KEY is still set in the active PowerShell session.
  • Allow the script’s built-in retry loop to finish before treating an empty first search as a failure.

Ask for help: Help me diagnose why seed.py did not populate both retrieval systems.

Add semantic retrieval

The application needs one place for service configuration. A shared JSON helper also prepares the file for HTTP requests to local services.

  • Select app/app.py in the Explorer sidebar.
  • Replace the staged contents with the following configuration code:
import json
import os
from urllib.request import Request, urlopen

from pinecone import Pinecone

PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "free-hybrid-rag")
PINECONE_NAMESPACE = os.getenv("PINECONE_NAMESPACE", "docs")
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
OPENSEARCH_INDEX = os.getenv("OPENSEARCH_INDEX", "hybrid-rag")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")


def post_json(url, payload):
    request = Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))

What does this code do?

  • The constants keep service names in one place. Environment values can override each default.
  • Pinecone reads PINECONE_API_KEY from the active session. The credential stays outside this source file.
  • The post_json() helper prepares a JSON request. It parses the JSON response into Python data.
  • Save app/app.py.
  • Verify the saved configuration by running this command:
python -c "print(__import__('app.app', fromlist=['PINECONE_INDEX_NAME']).PINECONE_INDEX_NAME)"

What does this check prove?

Python imports your saved application module. It then prints the configured Pinecone index name.

You should see free-hybrid-rag in PowerShell. This confirms the module loads with the expected configuration.

Module import failing?

  • Confirm PowerShell is still inside the project folder that contains the app directory.
  • Check app/app.py for missing parentheses or quotation marks.
  • Confirm the .venv virtual environment remains active.

Ask for help: Help me fix the import error in my semantic retrieval configuration.

Pinecone’s integrated embeddings convert each question into a semantic search automatically. The application can request ranked records without running a separate embedding model.

  • Add the following function below post_json() in app/app.py.
def semantic_search(question):
    index = Pinecone().index(PINECONE_INDEX_NAME)
    results = index.search(
        namespace=PINECONE_NAMESPACE,
        top_k=3,
        inputs={"text": question},
        fields=["title", "chunk_text", "source"],
    )
    return [
        {
            "id": hit.id,
            "score": hit.score,
            "title": hit.fields["title"],
            "content": hit.fields["chunk_text"],
            "source": hit.fields["source"],
        }
        for hit in results.result.hits
    ]

How does semantic search work here?

  • The Pinecone().index() call connects to the existing free-hybrid-rag index.
  • The search uses the docs namespace. It requests the top three matches.
  • The inputs value sends the question to Pinecone’s integrated embedding model.
  • The returned list normalizes every hit into id, score, title, content, and source fields.
  • Save app/app.py.

Before you run the check, form a prediction about which document IDs rank highest for the validation question.

  • Inspect the top semantic rankings by running this command:
python -c "print(__import__('json').dumps(__import__('app.app', fromlist=['semantic_search']).semantic_search('Why combine semantic and keyword search?'), indent=2))"

What should I see?

The one-line check imports your saved semantic_search() function. It sends the validation question to Pinecone.

The output is a JSON list containing up to three records. Each record includes its retrieval fields.

You’ve got the first retrieval slice working: a natural-language question now returns visible Pinecone rankings from your knowledge base.

No semantic rankings returned?

  • Confirm the earlier seed output included one or more semantic validation hit IDs.
  • Check that PINECONE_INDEX_NAME still resolves to free-hybrid-rag.
  • Check that PINECONE_NAMESPACE still resolves to docs.

Ask for help: Help me debug why semantic_search returns no Pinecone records.

✔️ Awesome, I've got everything!

Your semantic retrieval stage is saved. Double check that PowerShell returned up to three ranked records.

ⓧ I'd like to double check the full code

Compare your semantic retrieval stage with this complete version of app/app.py.

import json
import os
from urllib.request import Request, urlopen

from pinecone import Pinecone

PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "free-hybrid-rag")
PINECONE_NAMESPACE = os.getenv("PINECONE_NAMESPACE", "docs")
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
OPENSEARCH_INDEX = os.getenv("OPENSEARCH_INDEX", "hybrid-rag")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")


def post_json(url, payload):
    request = Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))


def semantic_search(question):
    index = Pinecone().index(PINECONE_INDEX_NAME)
    results = index.search(
        namespace=PINECONE_NAMESPACE,
        top_k=3,
        inputs={"text": question},
        fields=["title", "chunk_text", "source"],
    )
    return [
        {
            "id": hit.id,
            "score": hit.score,
            "title": hit.fields["title"],
            "content": hit.fields["chunk_text"],
            "source": hit.fields["source"],
        }
        for hit in results.result.hits
    ]

How does the complete stage fit together?

  • The configuration identifies every service used by the application.
  • The JSON helper prepares HTTP requests for local search services.
  • The semantic function returns three normalized Pinecone results.

Your knowledge base now returns meaning-based rankings. Next, literal matching exposes the gap between two separate result lists.

Add Keyword Retrieval

Your Pinecone search already uses semantic retrieval to rank passages by meaning. The project now has one useful view of the knowledge base.

Exact commands or identifiers also benefit from keyword retrieval through OpenSearch. This step exposes both rankings so you can see the unresolved gap between two independent result lists.

In this step, get ready to:
  • Query OpenSearch with a boosted multi-field keyword search.
  • Return separate semantic and keyword record IDs.
  • Observe why two independent rankings cannot provide one coherent context.
Add OpenSearch keyword search

A multi_match query searches several text fields with one question. Boosting title^2 gives title matches more influence than content matches.

  • Switch back to Visual Studio Code from earlier.
  • Select app/app.py in the Explorer sidebar.
  • Find the closing bracket of semantic_search(question).
  • Add the keyword retrieval function directly below it by copying this code:
def keyword_search(question):
    payload = {
        "size": 3,
        "query": {
            "multi_match": {
                "query": question,
                "fields": ["title^2", "content"],
            }
        },
    }
    results = post_json(
        f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}/_search",
        payload,
    )
    return [
        {
            "id": hit["_id"],
            "score": hit["_score"],
            "title": hit["_source"]["title"],
            "content": hit["_source"]["content"],
            "source": hit["_source"]["source"],
        }
        for hit in results["hits"]["hits"]
    ]

What does this code do?

  • The payload requests the three highest-ranked keyword matches.
  • The multi_match query searches each document's title and content.
  • The post_json() helper sends the request to the existing hybrid-rag index.
  • The returned list keeps each hit's record ID, score, title, content, and source.
  • Save app/app.py.
  • Switch back to the PowerShell window from earlier.
  • Confirm the script still runs by executing this command:
python app\app.py

What does this command do?

This starts the local Python application from the active virtual environment. The current entry point asks for a question before displaying its semantic ranking.

  • Enter What command starts the local API? at the question prompt.

You'll see the existing semantic ranking. This confirms Python loaded the new keyword_search() function without a syntax error.

Seeing a Python error?

  • Check that keyword_search() begins at the far left of app/app.py.
  • Compare the closing braces in payload with the code above.
  • Confirm every field lookup keeps its matching square brackets and quotation marks.

Ask for help: Help me debug the new keyword_search function in app/app.py.

Return both retrieval rankings

The two retrieval functions become comparable when one question passes through both paths. A small diagnostic entry point can display each ordered ID list in the same result.

  • Return to app/app.py in Visual Studio Code.
  • Locate the block that begins with if __name__ == "__main__":.
  • Select that entire entry-point block.
  • Replace the selected block with this diagnostic function and entry point:
def answer_question(question):
    semantic = semantic_search(question)
    keyword = keyword_search(question)

    return {
        "semantic_ids": [document["id"] for document in semantic],
        "keyword_ids": [document["id"] for document in keyword],
    }


if __name__ == "__main__":
    user_question = input("Question: ").strip()
    print(json.dumps(answer_question(user_question), indent=2))

What does this code do?

  • The answer_question() function sends the same question to both retrieval functions.
  • The semantic_ids field preserves Pinecone's top-three order.
  • The keyword_ids field preserves OpenSearch's top-three order.
  • The entry point prints both lists as an indented JSON object for comparison.
  • Save app/app.py.
  • Switch back to the PowerShell window from earlier.

Before you run the script, do you expect both retrieval methods to place every document in the same position?

  • Start the diagnostic run by executing this command:
python app\app.py

What does this command do?

This starts the updated entry point. The script waits for one question before running both retrieval methods.

  • Enter What command starts the local API? at the question prompt.

There it is. Your application now exposes semantic_ids and keyword_ids for the same question.

The lists may order the same documents differently. The output still contains no fused_ids list for one combined context.

The ranking gap is intentional

Both retrievers found useful evidence. Their separate rank positions leave the application without one deduplicated order to send into generation.

Missing one of the ID lists?

  • Confirm the opensearch container from earlier is still running.
  • Check that answer_question() calls both semantic_search(question) and keyword_search(question).
  • Check that the return object contains both diagnostic field names exactly as shown.

Ask for help: Help me find why one retrieval diagnostic is missing.

✔️ Awesome, I've got everything!

Your file now contains semantic retrieval, keyword retrieval, and a shared diagnostic entry point.

ⓧ I'd like to double check the full code

This reference shows the complete app/app.py file at the end of this step.

import json
import os
from urllib.request import Request, urlopen

from pinecone import Pinecone

PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "free-hybrid-rag")
PINECONE_NAMESPACE = os.getenv("PINECONE_NAMESPACE", "docs")
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
OPENSEARCH_INDEX = os.getenv("OPENSEARCH_INDEX", "hybrid-rag")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")


def post_json(url, payload):
    request = Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))


def semantic_search(question):
    index = Pinecone().index(PINECONE_INDEX_NAME)
    results = index.search(
        namespace=PINECONE_NAMESPACE,
        top_k=3,
        inputs={"text": question},
        fields=["title", "chunk_text", "source"],
    )
    return [
        {
            "id": hit.id,
            "score": hit.score,
            "title": hit.fields["title"],
            "content": hit.fields["chunk_text"],
            "source": hit.fields["source"],
        }
        for hit in results.result.hits
    ]


def keyword_search(question):
    payload = {
        "size": 3,
        "query": {
            "multi_match": {
                "query": question,
                "fields": ["title^2", "content"],
            }
        },
    }
    results = post_json(
        f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}/_search",
        payload,
    )
    return [
        {
            "id": hit["_id"],
            "score": hit["_score"],
            "title": hit["_source"]["title"],
            "content": hit["_source"]["content"],
            "source": hit["_source"]["source"],
        }
        for hit in results["hits"]["hits"]
    ]


def answer_question(question):
    semantic = semantic_search(question)
    keyword = keyword_search(question)

    return {
        "semantic_ids": [document["id"] for document in semantic],
        "keyword_ids": [document["id"] for document in keyword],
    }


if __name__ == "__main__":
    user_question = input("Question: ").strip()
    print(json.dumps(answer_question(user_question), indent=2))

How does the full file fit together?

The shared configuration feeds both retrieval functions. The entry point runs them with one question before printing their ordered record IDs.

You now have useful evidence from both retrieval systems. Next, you'll turn those independent rankings into one context before asking the local model to answer.

Fuse and Serve Answers

Your Pinecone and OpenSearch retrievers now surface useful evidence. However, their separate rankings cannot provide one coherent context for an answer.

This step uses Reciprocal Rank Fusion to combine those rankings. You will generate a grounded answer with LangChain before exposing the workflow through AWS SAM, Lambda, and local API Gateway emulation.

In this step, get ready to:
  • Fuse semantic and keyword rankings into one deduplicated result.
  • Generate an answer from the four highest-ranked documents.
  • Serve the grounded answer through a local HTTP endpoint.
Fuse rankings and generate a grounded answer

Semantic scores and keyword scores use different scales. Reciprocal Rank Fusion compares rank positions so each retriever can contribute without mixing incompatible raw scores.

  • Add the ChatOllama import above the existing Pinecone import in app/app.py by pasting this line:
from langchain_ollama import ChatOllama

Why add this import?

ChatOllama connects the application to the Ollama model running on your computer. It gives the retrieved context to the local model through LangChain.

  • Add reciprocal_rank_fusion() below keyword_search() in app/app.py by pasting this function:
def reciprocal_rank_fusion(rankings, rank_constant=60):
    documents = {}
    scores = {}

    for ranking in rankings:
        for position, document in enumerate(ranking, start=1):
            document_id = document["id"]
            documents.setdefault(document_id, document)
            scores[document_id] = scores.get(document_id, 0.0) + (
                1.0 / (rank_constant + position)
            )

    fused = []
    for document_id, score in sorted(
        scores.items(), key=lambda item: item[1], reverse=True
    ):
        document = dict(documents[document_id])
        document["rrf_score"] = round(score, 6)
        fused.append(document)
    return fused

How does the fusion work?

  • Each document receives a contribution based on its position in each ranking.
  • The documents dictionary keeps one copy of every record ID.
  • The scores dictionary adds the reciprocal contributions from both retrievers.
  • The final sort places documents with the highest combined support first.
  • Save app/app.py.
  • Check the open file for red syntax markers. You should see none around reciprocal_rank_fusion().

Seeing syntax markers?

Check that every line inside reciprocal_rank_fusion() remains indented by four spaces. Confirm that both closing parentheses remain below the score calculation.

Ask for help: Help me check the indentation and parentheses in my reciprocal_rank_fusion function.

The fused documents become evidence for a local LLM. Numbering that evidence gives the model a stable citation format for its answer.

  • Add the beginning of answer_question() below reciprocal_rank_fusion() by pasting this code:
def answer_question(question):
    semantic = semantic_search(question)
    keyword = keyword_search(question)
    fused = reciprocal_rank_fusion([semantic, keyword])
    selected = fused[:4]

    context = "\n\n".join(
        f"[{position}] {document['title']}\n"
        f"{document['content']}\n"
        f"Source: {document['source']}"
        for position, document in enumerate(selected, start=1)
    )

How is the context prepared?

  • The function runs both retrievers with the same question.
  • The fusion function produces one deduplicated ranking.
  • The slice fused[:4] limits the model context to the four highest-ranked documents.
  • Each selected document receives a bracketed position that the model can cite.
  • Complete answer_question() by pasting this code directly below the context construction:
    model = ChatOllama(
        model=OLLAMA_MODEL,
        base_url=OLLAMA_BASE_URL,
        temperature=0,
    )
    response = model.invoke(
        [
            (
                "system",
                "Answer only from the supplied context. Cite supporting passages "
                "with bracketed numbers such as [1]. If the context is insufficient, "
                "say that clearly.",
            ),
            (
                "human",
                f"Question: {question}\n\nContext:\n{context}",
            ),
        ]
    )

    return {
        "answer": response.content,
        "sources": [document["source"] for document in selected],
        "semantic_ids": [document["id"] for document in semantic],
        "keyword_ids": [document["id"] for document in keyword],
        "fused_ids": [document["id"] for document in fused],
    }

How is the answer generated?

  • The model uses llama3.2:1b through the configured local Ollama URL.
  • A temperature of 0 keeps the response focused on the supplied evidence.
  • The system instruction requires bracketed citations from the numbered context.
  • The returned diagnostics preserve the semantic ranking, keyword ranking, and fused ranking.
  • Save app/app.py.
  • Check the open file for red syntax markers. You should see none inside answer_question().
  • Replace the temporary diagnostic entry point at the bottom of app/app.py with this final interactive entry point:
if __name__ == "__main__":
    user_question = input("Question: ").strip()
    print(json.dumps(answer_question(user_question), indent=2))

What does the entry point do?

Running the file now asks for one question. The application prints the generated answer together with the source and ranking diagnostics.

Before you run the app, predict which document the fused ranking places first.

  • Save app/app.py.
  • Enter What command starts the local API? when the Question: prompt appears.
  • Run the completed local application with this command:
python app\app.py

What should you see?

You should see a JSON object containing answer, sources, semantic_ids, keyword_ids, and fused_ids.

The answer should describe the local API command from the retrieved evidence. Its bracketed citation connects the response to the numbered context.

Missing the generated answer?

  • Confirm Ollama from earlier is still serving llama3.2:1b.
  • Confirm the opensearch container from earlier is still running.
  • Check that your active PowerShell session still contains the Pinecone API key.

Ask for help: Help me debug why answer_question is not returning a generated answer.

Add the Lambda interface and local environment

The local script proves that retrieval and generation work. A Lambda proxy handler now needs to translate HTTP event bodies into questions before returning stringified response bodies.

  • Add lambda_handler() below answer_question() in app/app.py by pasting this function:
def lambda_handler(event, context):
    try:
        body = event.get("body", "{}")
        if isinstance(body, str):
            body = json.loads(body)
        question = str(body.get("question", "")).strip()
        if not question:
            return {
                "statusCode": 400,
                "headers": {"Content-Type": "application/json"},
                "body": json.dumps({"error": "question is required"}),
            }

        result = answer_question(question)
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(result),
        }
    except Exception as error:
        return {
            "statusCode": 500,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(
                {"error": type(error).__name__, "detail": str(error)}
            ),
        }

How does the handler respond?

  • A request without a question receives status code 400 with a JSON error body.
  • A successful request receives status code 200 with the grounded answer and retrieval diagnostics.
  • An unexpected exception receives status code 500 with the exception type and detail.
  • Every response uses a string body that matches the Lambda proxy response shape.
  • Save app/app.py.
  • Check the open file for red syntax markers. You should see none inside lambda_handler().

Handling an API key deserves care. The key stays in your local env.json file so the request command never displays it.

  • Select env.json from the project file list in Visual Studio Code.
  • Find the your-api-key-here placeholder under PINECONE_API_KEY.
  • Replace the placeholder with your real Pinecone API key.
  • Save env.json.

Keep the key private

Do not paste your key into a screenshot or share the contents of env.json. The final validation captures only the API response.

✔️ Awesome, I've got everything!

Your fusion, generation, and Lambda handler code is ready. Double check that you saved app/app.py.

ⓧ I'd like to double check the full code

Compare your complete app/app.py file with this reference.

import json
import os
from urllib.request import Request, urlopen

from langchain_ollama import ChatOllama
from pinecone import Pinecone

PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "free-hybrid-rag")
PINECONE_NAMESPACE = os.getenv("PINECONE_NAMESPACE", "docs")
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
OPENSEARCH_INDEX = os.getenv("OPENSEARCH_INDEX", "hybrid-rag")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:1b")


def post_json(url, payload):
    request = Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urlopen(request, timeout=60) as response:
        return json.loads(response.read().decode("utf-8"))


def semantic_search(question):
    index = Pinecone().index(PINECONE_INDEX_NAME)
    results = index.search(
        namespace=PINECONE_NAMESPACE,
        top_k=3,
        inputs={"text": question},
        fields=["title", "chunk_text", "source"],
    )
    return [
        {
            "id": hit.id,
            "score": hit.score,
            "title": hit.fields["title"],
            "content": hit.fields["chunk_text"],
            "source": hit.fields["source"],
        }
        for hit in results.result.hits
    ]


def keyword_search(question):
    payload = {
        "size": 3,
        "query": {
            "multi_match": {
                "query": question,
                "fields": ["title^2", "content"],
            }
        },
    }
    results = post_json(
        f"{OPENSEARCH_URL}/{OPENSEARCH_INDEX}/_search",
        payload,
    )
    return [
        {
            "id": hit["_id"],
            "score": hit["_score"],
            "title": hit["_source"]["title"],
            "content": hit["_source"]["content"],
            "source": hit["_source"]["source"],
        }
        for hit in results["hits"]["hits"]
    ]


def reciprocal_rank_fusion(rankings, rank_constant=60):
    documents = {}
    scores = {}

    for ranking in rankings:
        for position, document in enumerate(ranking, start=1):
            document_id = document["id"]
            documents.setdefault(document_id, document)
            scores[document_id] = scores.get(document_id, 0.0) + (
                1.0 / (rank_constant + position)
            )

    fused = []
    for document_id, score in sorted(
        scores.items(), key=lambda item: item[1], reverse=True
    ):
        document = dict(documents[document_id])
        document["rrf_score"] = round(score, 6)
        fused.append(document)
    return fused


def answer_question(question):
    semantic = semantic_search(question)
    keyword = keyword_search(question)
    fused = reciprocal_rank_fusion([semantic, keyword])
    selected = fused[:4]

    context = "\n\n".join(
        f"[{position}] {document['title']}\n"
        f"{document['content']}\n"
        f"Source: {document['source']}"
        for position, document in enumerate(selected, start=1)
    )

    model = ChatOllama(
        model=OLLAMA_MODEL,
        base_url=OLLAMA_BASE_URL,
        temperature=0,
    )
    response = model.invoke(
        [
            (
                "system",
                "Answer only from the supplied context. Cite supporting passages "
                "with bracketed numbers such as [1]. If the context is insufficient, "
                "say that clearly.",
            ),
            (
                "human",
                f"Question: {question}\n\nContext:\n{context}",
            ),
        ]
    )

    return {
        "answer": response.content,
        "sources": [document["source"] for document in selected],
        "semantic_ids": [document["id"] for document in semantic],
        "keyword_ids": [document["id"] for document in keyword],
        "fused_ids": [document["id"] for document in fused],
    }


def lambda_handler(event, context):
    try:
        body = event.get("body", "{}")
        if isinstance(body, str):
            body = json.loads(body)
        question = str(body.get("question", "")).strip()
        if not question:
            return {
                "statusCode": 400,
                "headers": {"Content-Type": "application/json"},
                "body": json.dumps({"error": "question is required"}),
            }

        result = answer_question(question)
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(result),
        }
    except Exception as error:
        return {
            "statusCode": 500,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(
                {"error": type(error).__name__, "detail": str(error)}
            ),
        }


if __name__ == "__main__":
    user_question = input("Question: ").strip()
    print(json.dumps(answer_question(user_question), indent=2))
Build and test the local API

Your existing template.yaml maps POST /ask to app.lambda_handler. AWS SAM now packages the application into the local build format used by its Lambda emulator.

  • Return to the PowerShell session with .venv active.
  • Build the local Lambda application by running this command:
sam build

What does the build create?

AWS SAM reads template.yaml before packaging the function code and dependencies. The generated files are stored under .aws-sam for local execution.

  • Confirm the build completed in the terminal output.
  • Confirm the .aws-sam folder now appears in your project files.

Build did not complete?

  • Confirm Docker Desktop from earlier is still running.
  • Confirm app/requirements.txt still contains both project dependencies.
  • Confirm the CodeUri value in template.yaml points to app/.

Ask for help: Help me diagnose why my AWS SAM build is failing.

The first local start can take a moment while AWS SAM prepares the function container. A quiet terminal during this setup is normal.

  • Start the local API with the environment values from env.json by running this command:
sam local start-api --env-vars env.json

What does the local server do?

This command starts local API Gateway and Lambda emulation at http://127.0.0.1:3000. Requests to /ask invoke lambda_handler() inside a local function container.

The command keeps control of this PowerShell window while the server runs. Leave the window open for the endpoint test.

  • Press the Windows key to open the Windows search bar.
  • Type PowerShell into the search bar.
  • Press Enter to open a second PowerShell window.

Before you send the request, predict whether the response contains one ranking or all three ranking stages.

  • Send a question to the local /ask endpoint by running this command:
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3000/ask -ContentType 'application/json' -Body '{"question":"Why combine semantic and keyword retrieval?"}'

What should you see?

You should see a response containing answer, sources, semantic_ids, keyword_ids, and fused_ids.

The answer should explain the benefit of combining both retrieval methods. Its source list and rankings show which evidence supported the response.

You made the full path work. A local HTTP request now triggers semantic retrieval, keyword retrieval, fusion, and grounded generation through an AWS-compatible interface.

Request not returning JSON?

  • Confirm the first PowerShell window still shows the running AWS SAM process.
  • Confirm env.json contains your real Pinecone API key.
  • Confirm the OpenSearch container and Ollama service from earlier are still running.

Ask for help: Help me debug why my local /ask endpoint is not returning the hybrid RAG response.

Secret mission

Evaluate the Hybrid Retriever

A single successful request provides only one view of retrieval quality. Compare three query styles. Turn the rankings into an evidence-based tuning recommendation.

Clean Up Your Resources

Clean Up Your Resources

This project uses free local services plus Pinecone Starter. Keeping the Pinecone index within Starter quotas creates no ongoing charge.

Resources you used:

  • Pinecone Starter index (free-hybrid-rag).
  • Local OpenSearch container (opensearch) managed by Docker.
  • Downloaded Ollama model (llama3.2:1b).
  • Local project folder containing app, data, seed.py, template.yaml, env.json, evaluation.md, .venv, and .aws-sam from AWS SAM.

Keep everything running

No action is needed. Choose this if you are still testing the hybrid retriever or want to improve your evaluation.

  • Leave the local API available at http://127.0.0.1:3000/ask while you continue testing questions.
  • Keep the opensearch container running while you compare keyword rankings.
  • Keep llama3.2:1b downloaded so Ollama can generate answers without downloading the model again.
  • Leave free-hybrid-rag on Pinecone Starter while its usage remains within the free quotas.
  • Keep env.json private because it contains your Pinecone API key.
  • Retain evaluation.md as evidence of how the retrievers behaved across your three query styles.

Pause - I'll come back to this later

Shut down the running processes to free local memory while preserving your model, index, evaluation, and source files.

  • Stop the local API by pressing Ctrl+C in the PowerShell window serving http://127.0.0.1:3000/ask.
  • Stop the OpenSearch container by running this command:
docker stop opensearch

What does this command do?

This stops the local search process while preserving the hybrid-rag index inside the container. You can restart the same container later.

  • Close Ollama from its Windows taskbar icon to stop the local model service.
  • Leave the free-hybrid-rag index on Pinecone Starter.
  • Restart OpenSearch when you return by running this command:
docker start opensearch

What happens when OpenSearch restarts?

Docker starts the existing opensearch container. Your seeded hybrid-rag index remains available.

  • Press the Windows key to open Windows search.
  • Type Ollama.
  • Press Enter to start Ollama.
  • Confirm that the model remains downloaded by running this command:
ollama ls

What should you see?

The downloaded model list should contain llama3.2:1b. This confirms that the local generator is ready.

  • Return to the project folder from earlier in PowerShell.
  • Restart the local API by running this command:
sam local start-api --env-vars env.json

What does this restart?

AWS SAM starts the local API Gateway and Lambda workflow using the settings in env.json. Keep this PowerShell window open while you send requests to the endpoint.

Delete - I don't want to use this again

Remove all project resources when you want to reclaim local storage and erase the hosted retrieval index.

This is the irreversible option. Keep a separate copy of evaluation.md before continuing if you want to retain your findings.

  • Stop the local API by pressing Ctrl+C in the PowerShell window serving the endpoint.
  • Remove the OpenSearch container by running this command:
docker rm -f opensearch

What does this remove?

This force-removes the opensearch container. The hybrid-rag index stored inside that container is permanently deleted.

  • Remove the downloaded Ollama model by running this command:
ollama rm llama3.2:1b

What does this remove?

This deletes the downloaded llama3.2:1b model from your computer. Ollama remains installed for future projects.

  • Switch back to the PowerShell session from earlier where .venv and your Pinecone key remain active.
  • Start a short Python session by running this command:
python

Why use the active environment?

The active .venv contains the Pinecone SDK. The Pinecone client uses the API key already available in this PowerShell session.

  • Delete the Pinecone index by entering this code in the Python session:
from pinecone import Pinecone

# Connect with the key from the current PowerShell session
pc = Pinecone()

# Remove the hosted records and integrated index
pc.indexes.delete("free-hybrid-rag")

What does this code remove?

The Pinecone client permanently deletes the free-hybrid-rag index. Its docs namespace and five records are deleted with it.

The command blocks until the deletion finishes. A return without an exception confirms that Pinecone completed the operation.

  • Press Ctrl+Z to leave the Python session.
  • Press Enter to return to PowerShell.

The final deletion removes your source code plus env.json. That also removes the local file containing your Pinecone API key.

  • Press the Windows key to open Windows search.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Navigate to the parent directory containing the project folder from earlier.
  • Select the project folder.
  • Press Shift+Delete to permanently delete it.
  • Confirm the permanent deletion in the Windows prompt.
  • Verify that the project folder is no longer listed in File Explorer.

Having trouble deleting a resource?

If the container removal fails, start Docker Desktop through Windows search and run the removal command again.

If the model removal fails, start Ollama through Windows search and retry the command.

If Pinecone rejects the deletion, return to the PowerShell session that contains your API key and active .venv.

help me troubleshoot this cleanup.

Nice Work!

Nice Work!

Mission complete! Your local hybrid RAG API now returns source-grounded answers through an AWS SAM endpoint without deploying paid AWS resources.

What you learned:

  • Built a hybrid knowledge base that uses Pinecone for semantic retrieval. Added OpenSearch keyword retrieval over the same five documents.
  • Compared two independent rankings to reveal inconsistent ordering. Applied Reciprocal Rank Fusion to score rank positions. Produced one deduplicated context for generation.
  • Connected LangChain to a local Ollama model for grounded generation. Served answers at http://127.0.0.1:3000/ask through local API Gateway emulation. Returned source names in every Lambda-style response. Included all three ranking lists for inspection.
  • Secret Mission: Created an evaluation report for conceptual questions. Recorded exact identifier-heavy questions. Tested paraphrased questions. Used the rankings to recommend one targeted retrieval improvement.

Ready to quiz yourself?