Building an AI-powered RAG search with NLWeb
Building a complete vector search stack using Docker, Ollama for LLM capabilities, and Qdrant for vector storage.
Search for content and press Enter to submit.
Building a complete vector search stack using Docker, Ollama for LLM capabilities, and Qdrant for vector storage.
In this tutorial we are building a smart semantic search system that can ingest content from multiple sources (RSS feeds, JSON files, text documents), generate semantic embeddings, and return intelligent search results through a clean web interface using Docker, Ollama for LLM capabilities, and Qdrant for vector storage. Think of it as your own private Google, trained on your content, that actually understands context and meaning, not just keywords.
Before diving into configuration files, let's understand what we're building:
NLWeb is an open-source RAG (Retrieval-Augmented Generation) framework that combines vector search with large language models (LLMs). It acts as the orchestration layer, coordinating between:
The workflow is elegant: content gets embedded into vectors, stored in Qdrant, and when users search, NLWeb retrieves relevant chunks and uses the LLM to generate contextual answers.
Before we begin, ensure you have installed the below:
Download and install Docker Desktop from the official documentation. There's a checkbox to avoid during the installation on Windows: "Use Windows containers instead of Linux containers." Leave this unchecked. We need Linux containers for this stack.

After installation, verify Docker is running:
docker --version
You should get something like: Docker version 24.0.x, build xxxxx

We're using a remote Ollama instance to offload computational work to a more powerful machine (still a local machine). This architecture allows your development machine to remain lightweight while the GPU-intensive work happens elsewhere. This is why in the screenshots and the code we share in this article you will see the ollama url accessed through IP address as http://192.168.2.11:11434. If you install ollama on your local development machine you should access it with the default url: https://localhost:11434.
Pull the models you want to use for the embedding and for the retrieval. Before pulling the models make sure you check the system requirement for each model and make sure your local machine meets the requirements.
For this setup, we'll use:
nomic-embed-text (alternatively bge-m3)qwen3:4b (or qwen3:14b for better quality)qwen3:4bThe "high" and "low" distinction allows NLWeb to use different models for different complexity tasks, optimizing both quality and performance.
To make sure that Ollama is running navigate to its default port https://localhost:11434
On our side, as mentioned above, we have installed Ollama on a remote powerful local machine and we access it through an IP address.
You should see:
Ollama is running. This confirms the Ollama service is accessible.

To check the LLM models installed access: http://localhost:11434/api/tags
You'll see JSON output listing available models - something like:
{
"models": [
{
"name": "qwen3:14b",
"modified_at": "2025-08-14T10:27:11.6735263+02:00",
"size": 9276198565,
"parameter_size": "14.8B"
},
{
"name": "bge-m3:latest",
"modified_at": "2025-08-14T10:20:46.1659263+02:00",
"size": 1157672605,
"parameter_size": "566.70M"
}
]
}https://github.com/nlweb-ai/NLWeb/tree/main
This repository contains everything: the Python backend, configuration templates, Docker setup, and even a frontend interface.
.env fileCopy the env.template file and rename it to .env
This is where we will define and store our environment variables.
In the .env file make sure that you have the Ollama url set correctly - e.g.:
OLLAMA_URL=http://localhost:11434
This environment variable tells NLWeb where to find and connect to your Ollama instance.
config_embedding.yaml fileThe config_embedding.yaml file controls how text gets converted into vector representations, defining which embedding provider (OpenAI, Ollama, Gemini, etc.) to use and which specific model generates the numerical embeddings that enable semantic search. This configuration is critical because the embedding model's output dimensions must match your vector database settings, and different models offer trade-offs between speed, accuracy, and multilingual support.
In the config/config_embedding.yaml file set the preferred_provider to ollama.
Set the model to nomic-embed-text. This model produces 768-dimensional vectors and works well for general-purpose semantic search. Alternatively, you can use bge-m3 (1024 dimensions) - slightly better multilingual performance, A bit more accuracy for complex queries, but slower embedding generation and more storage.
preferred_provider: ollama ollama: api_endpoint_env: OLLAMA_URL model: nomic-embed-text

config_llm.yaml fileThe config_llm.yaml file configures which large language model (LLM) generates the intelligent responses and summaries in your search results, allowing you to specify different models for 'high' complexity tasks (detailed analysis, complex queries) versus 'low' complexity tasks (simple lookups, quick answers) to optimize both quality and performance. This dual-model approach lets you balance resource usage by reserving more powerful models for queries that truly need them while using faster, lighter models for straightforward searches
In the config/config_llm.yaml file by default the LLM model is set to
azure_openai. Set it to ollama and update the model to qwen3:4b
preferred_endpoint: ollama
ollama:
api_endpoint_env: OLLAMA_URL
llm_type: ollama
models:
high: qwen3:4b
low: qwen3:4b

qwen3:4b is a solid choice for this setup for several reasons:
Additionally:
qwen3:4b also works well in combination with the nomic-embed-text
requirements.txt fileTo finish up with the settings for ollama, open the \code\python\requirements.txt file and uncomment the line
ollama>=0.5.1

This Python package is essential for communicating with Ollama. Without it, you'll see "No module named 'ollama'" errors later.
NLWeb supports two Qdrant storage mechanisms:
data/db/ (doesn't appear in Qdrant's web interface)We're using the server approach because it provides better visibility, debugging capabilities, and production-readiness.
requirements.txt fileEdit code/python/requirements.txt and ensure this line is uncommented:
qdrant-client>=1.14.0The qdrant-client is the Python library that allows NLWeb to communicate with your Qdrant vector database. Without it, your application cannot talk to Qdrant.
NLWeb supports multiple vector database backends, so the requirements.txt file has different clients commented out by default. You only install the ones you're actually using to keep the Docker image lean.
If you build the Docker image without uncommenting this line, you'll get runtime errors like:
ModuleNotFoundError: No module named 'qdrant_client'
ValueError: No valid endpoints available for searchThe application will start, appear to be running, but fail immediately when it tries to search or store vectors because it can't import the Qdrant client library.
config/config_retrieval.yml fileThe config/config_retrieval.yml file defines where and how NLWeb stores and retrieves vector embeddings, specifying the write_endpoint (which vector database to use for storing new data) and configuring connection details for each available storage backend like Qdrant or local file storage. This is where you tell NLWeb 'use the Qdrant server at this URL with this collection name' making it the routing configuration that connects your embedding pipeline to your chosen vector database.
In the config/config_retrieval.yml file set the write_endpoint to qdrant_url, make sure it is enabled: true. You can also set the index name (the collection name). We will leave the default value nlweb_collection
Disable file-based storage.
write_endpoint: qdrant_url qdrant_url: enabled: true api_endpoint_env: QDRANT_URL index_name: nlweb_collection db_type: qdrant qdrant_local: enabled: false # Disable file-based storage
config/config_conv_store.yaml fileThe config/config_conv_store.yaml file manages conversation storage and vector database settings for the NLWeb application, defining the default_storage endpoint and critically specifying the vector_size parameter that must exactly match your embedding model's output dimensions (768 for nomic-embed-text, 1024 for bge-m3, default value 1536 etc.). This configuration also controls whether to use local file-based storage versus a remote Qdrant server - making it essential for both correct vector storage and optimal search performance.
Open config/config_conv_store.yaml and configure:
default_storage: qdrant_remote qdrant_remote: enabled: true # enable server based storageapi_endpoint_env: QDRANT_URL index_name: nlweb_collection vector_size: 768 # Critical - must match embedding model db_type: qdrant qdrant_local: enabled: false # Disable file-based storage
This is the most common source of frustration: your embedding model's vector size MUST match your Qdrant collection's vector_size configuration. Mismatches cause cryptic errors like:
ValueError: All endpoint searches failed
qdrant_client - ERROR: Unexpected Response: 500 (Internal Server Error)
OutputTooSmallRun this PowerShell command where you add the name of the embedding model you use (e.g. nomic-embed-tex):
$response = Invoke-RestMethod -Uri http://192.168.2.11:11434/api/embeddings -Method Post -Body '{"model":"nomic-embed-text","prompt":"test"}' -ContentType "application/json"
$response.embedding.CountFor nomic-embed-text, this should return 768

By default, NLWeb's configuration assumes 1536 dimensions (OpenAI's ada-002 size), but Ollama models typically use 768 or 1024. Always verify and match these numbers.
Go back to config/config_conv_store.yaml and ensure:
qdrant_remote:
vector_size: 768 # Must match the embedding model output
Now we'll set up both NLWeb and Qdrant as Docker services. Your docker-compose.yaml should look like this:
services: nlweb: build: context: . dockerfile: Dockerfile container_name: nlweb ports: - "8000:8000" env_file: - .env depends_on: - qdrant environment: - PYTHONPATH=/app - PORT=8000 - NLWEB_CONFIG_DIR=/app/config - QDRANT_URL=http://qdrant:6333 volumes: - ./data:/app/data - ./code/python:/app/python - ./config:/app/config:ro healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000')\""] interval: 30s timeout: 10s retries: 3 start_period: 10s restart: unless-stopped user: nlweb qdrant: image: qdrant/qdrant:latest container_name: qdrant ports: - "6333:6333" volumes: - ./data/qdrant:/qdrant/storage restart: unless-stopped
You can see the changes below;

The docker-compose.yaml file defines how NLWeb and Qdrant services work together - including port mappings (8000 for NLWeb's web interface, 6333 for Qdrant's API), volume mounts for persistent data storage, environment variables, and critically the depends_on directive that ensures Qdrant starts before NLWeb attempts to connect to it. This single configuration file replaces dozens of individual docker run commands with a simple docker-compose up that launches your complete AI search infrastructure with proper networking, health checks, and automatic restart policies - making it the blueprint for your entire application architecture.
docker-compose.yaml configurationNLWeb Service:
.envdata, code/python, and config directoriesnlweb user for securityQdrant Service:
./data/qdrantThe depends_on directive ensures Qdrant is available before NLWeb tries to connect, preventing initialization race conditions.
While no changes are needed in the Dockerfile, it will be useful to explain its role and what it does.
Your Dockerfile uses a multi-stage build for efficiency and security:
FROM python:3.13-slim AS builder
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
pip install --no-cache-dir --upgrade pip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy and install requirements
COPY code/python/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
This stage installs compilation tools (gcc, python3-dev) needed for packages like numpy or other compiled dependencies, then installs all Python packages.
FROM python:3.13-slim AS builder
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
pip install --no-cache-dir --upgrade pip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy and install requirements
COPY code/python/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
The multi-stage approach means your final image doesn't contain build tools (reducing size and attack surface), only the runtime dependencies. This is a Docker best practice for production deployments.
Time to build the Docker images:
docker-compose build nlwebThis processes the Dockerfile's multi-stage build, installs all dependencies, and creates your NLWeb image. The first build takes a few minutes.

Start both services in detached mode:
docker-compose up -dThe -d flag runs containers in the background, freeing your terminal. Without it, logs stream directly to your terminal and Ctrl+C stops everything.

Check container status:
docker-compose psYou should see both nlweb and qdrant with status "Up".
You can also open Docker Desktop to confirm both nlweb and qdrant containers are running successfully. You should see:
In the Containers view:
Quick health check: Look at the CPU and memory usage - both should be low at idle (typically <1% CPU, under 200MB memory for each container). This confirms the services are running efficiently without consuming excessive resources.

Accessing the Qdrant Web UI:
Click on the 6333:6333 port link next to the qdrant container. This opens Qdrant's built-in web interface at http://localhost:6333/dashboard, where you can:
nlweb_collection after loading data)This visual confirmation in Docker Desktop is crucial - it shows your entire stack is orchestrated correctly and both services are communicating through Docker's internal network.

View logs if needed:
docker-compose logs -f nlweb
docker-compose logs -f qdrantThe -f flag follows logs in real-time. Press Ctrl+C to exit (containers keep running).
When you modify configuration files or update the code:
docker-compose build nlweb
docker-compose up -dThis rebuilds the image and restarts the container with the new changes.
To stop everything:
docker-compose downThis stops and removes containers but preserves your data volumes.
Now that your infrastructure is running, let's feed it some data. NLWeb supports multiple formats and sources.
Your data loading script is located at code/python/data_loading/db_load.py on your host machine. The Dockerfile copies this to /app/python/data_loading/ inside the container.
To run it correctly, you must:
/app/python (where Python can find the module)-m flag to run it as a module: python -m data_loading.db_loadPerfect for blogs, podcasts, or any RSS-enabled content:
docker-compose exec -w /app/python nlweb python -m data_loading.db_load https://feeds.libsyn.com/121695/rss Behind-the-TechBreaking down this command:
docker-compose exec: Execute a command in a running container-w /app/python: Set working directory to /app/pythonnlweb: The service name from docker-compose.yamlpython -m data_loading.db_load: Run the loading modulePlace your JSON file in the data/json/ directory, then load it:
docker-compose exec -w /app/python nlweb python -m data_loading.db_load ../data/json/faq-section-1.jsonl edxThe relative path ../data/json/ works because we're executing from /app/python, and the data directory is mounted at /app/data.
Example JSON structure for FAQs:
{ "@type": "Question", "name": "How does RFP optimization fit into broader digital strategy?", "acceptedAnswer": { "@type": "Answer", "text": "RFP optimization ensures vendor selection decisions support comprehensive digital transformation objectives..." } }
For plain text content:
docker-compose exec -w /app/python nlweb python -m data_loading.db_load ../data/json/news.txt newsLarge datasets can overwhelm the system. Use --batch-size to control how many items are processed at once:
docker-compose exec -w /app/python nlweb python -m data_loading.db_load ../data/json/news.txt news --batch-size 20The default batch size is 100. Reduce it if you see memory issues or timeout errors.
Go back to http://localhost:6333/dashboard to see the collections, inspect points, and etc.



Visit http://localhost:8000 in your browser. You should see the NLWeb search interface. Try a query related to your loaded content.
The interface provides multiple display modes:

The API returns streaming data with different message types:
data: {"message_type": "retrieval_count", "query": "cms", "count": 4}
data: {"message_type": "tool_selection", "selected_tool": "search", "score": 100}
data: {"message_type": "result_batch", "results": [...]}
data: {"message_type": "complete"}This streaming approach allows the frontend to show results progressively as they're found.

Now that your NLWeb search engine is running, integration with Sitefinity is straightforward. We've created a custom Sitefinity NLWeb widget that connects directly to your local or remote NLWeb instance using the /ask API endpoint, allowing content editors to add intelligent search functionality to any page through Sitefinity's familiar widget interface. This means your Sitefinity-powered website can now offer semantic search across your entire content repository—FAQs, news articles, documentation, and more - without complex development work, simply by dropping the widget onto a page and configuring the NLWeb endpoint URL.





You've successfully built a production-ready AI search engine from scratch. This isn't just a tutorial project - it's a foundation you can build on, customize, and deploy to solve real problems.
The stack you've assembled (NLWeb + Ollama + Qdrant + Docker) represents modern best practices for AI-powered search systems:
Most importantly, you now understand how these pieces fit together. When you need to troubleshoot, optimize, or extend the system, you have the knowledge to do so confidently.
Whether you're building internal tools, customer-facing applications, or research prototypes, this foundation scales with your needs. Start small, validate the approach, then expand as you learn what works for your specific use case.
The AI search landscape continues evolving rapidly, but the fundamentals you've learned here - embeddings, vector databases, LLM orchestration - will remain relevant. You're well-positioned to adapt as new models, techniques, and tools emerge.
Now go build something amazing. Your AI-powered search engine awaits its first real users.
Below are some of the issues we experienced during the implementation, their root causes and the solution. The solutions are included in the above tutorial, however we thought it would be useful if we have the issues explained separately in case you missed configuring something and face issues.
http://localhost:8000 returns a 404Root cause: There's no root route configured. The backend API is running, but there's no homepage mapped to.
Solution: Add Static File Serving
Edit code/python/webserver/aiohttp_server.py and modify the create_app method to include static file serving. Add these imports at the top:
from aiohttp import web
import osThen update the create_app method (around line 111):
async def create_app(self) -> web.Application:
"""Create and configure the aiohttp application"""
# Create application with proper settings
app = web.Application(
client_max_size=1024**2 * 10, # 10MB max request size
)
# Store config in app for access in handlers
app['config'] = self.config
# --- ADD THIS BLOCK TO SERVE STATIC FILES ---
static_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', 'static')
)
app.router.add_static('/static/', static_path, name='static')
# ------------------------------------------
# This way http://localhost:8000/ will serve index.html directly
async def root_handler(request):
return web.FileResponse(os.path.join(static_path, 'index.html'))
app.router.add_get('/', root_handler)
# ------------------------------------------
# Setup middleware
from .middleware import setup_middleware
setup_middleware(app)
# Setup routes
from .routes import setup_routes
setup_routes(app)
# Setup startup and cleanup handlers
app.on_startup.append(self._on_startup)
app.on_cleanup.append(self._on_cleanup)
app.on_shutdown.append(self._on_shutdown)
# Setup client session for outgoing requests
app['client_session'] = None
return appThis configuration:
static/ directory at the /static/ URL path/ to serve index.html directlyhttp://localhost:8000Symptom:
ERROR - Failed to load provider for ollama: No module named 'ollama'
ValueError: No valid endpoints available for searchRoot Cause: The Ollama Python package wasn't installed during the Docker build.
Solution:
code/python/requirements.txtollama>=0.5.1 is uncommenteddocker-compose build nlweb
docker-compose up -dVerification: Check the logs - you should see "LLM API connectivity check successful for ollama" during startup.
Symptom:
qdrant_client - ERROR: Unexpected Response: 500 (Internal Server Error)
OutputTooSmall
ValueError: All endpoint searches failedRoot Cause: Your embedding model produces vectors of size X, but Qdrant collection expects size Y.
Solution:
$response = Invoke-RestMethod -Uri http://192.168.2.11:11434/api/embeddings -Method Post -Body '{"model":"nomic-embed-text","prompt":"test"}' -ContentType "application/json"
$response.embedding.Countconfig/config_conv_store.yaml:qdrant_remote:
vector_size: 768 # Match the number from step 1curl -X DELETE http://localhost:6333/collections/nlweb_collectiondocker-compose restart nlweb
# Re-run your data loading commandsSymptom:
TimeoutError: Operation timed out after 8 secondsRoot Cause: LLM operations, especially with larger models, can take longer than the default 8-second timeout.
Solution:
Edit code/python/core/llm.py around line 162:
Timeout: int = 60 # Increased from 8Rebuild and restart:
docker-compose build nlweb
docker-compose up -dSymptom:
ConnectionError: Cannot connect to Qdrant at http://qdrant:6333Diagnosis: Test connectivity from inside the container:
docker-compose exec nlweb python -c "import requests; print(requests.get('http://qdrant:6333').json())"You should see:
{'title': 'qdrant - vector search engine', 'version': '1.15.2', ...}Solutions:
depends_on: - qdrant is in your docker-compose.yamldocker-compose psQDRANT_URL=http://qdrant:6333docker-compose restartSymptom:
python: can't open file '/app/data_loading/db_load.py': [Errno 2] No such file or directoryRoot Cause: Incorrect working directory or module path.
Solution: Always use the correct command format:
docker-compose exec -w /app/python nlweb python -m data_loading.db_load [args]The -w /app/python flag is crucial because:
/app/python/data_loading/db_load.py/app/python to find the data_loading module-m flag runs it as a module (not a direct file path)