This is the actual article content.
diff --git a/.env.example b/.env.example
index 79c2c2be0..aad7b0267 100644
--- a/.env.example
+++ b/.env.example
@@ -54,6 +54,12 @@ INDEXERUI_PORT=8060 # Port to expose the Indexer UI
INDEXERUI_URL='http://X.X.X.X:INDEXERUI_PORT'
API_BASE_URL='http://X.X.X.X:APP_PORT' # Base URL of your FastAPI backend.
+# Web Search
+# WEBSEARCH_API_TOKEN= # Web search provider API token. If unset, web search is silently disabled.
+# WEBSEARCH_BASE_URL=https://api.staan.ai/search/web # Web search provider endpoint
+# WEBSEARCH_TOP_K=5 # Number of web results to include (default: 5)
+# WEBSEARCH_LANG=fr-FR # Search language/market (default: fr-FR)
+
# LOGGING
LOG_LEVEL=DEBUG # See possible values https://loguru.readthedocs.io/en/stable/api/logger.html
diff --git a/.gitignore b/.gitignore
index 3bf1ad2ee..41d956636 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,9 @@ services/*
#helm
charts/openrag-stack/charts/*.tgz
+# Planning
+.planning/
+
# Astro / Starlight
.astro/
dist/
diff --git a/.hydra_config/config.yaml b/.hydra_config/config.yaml
index 1cb693e5e..45f370b60 100644
--- a/.hydra_config/config.yaml
+++ b/.hydra_config/config.yaml
@@ -3,6 +3,7 @@ defaults:
- chunker: ${oc.env:CHUNKER, recursive_splitter} # recursive_splitter
- retriever: ${oc.env:RETRIEVER_TYPE, single} # single # multiQuery # hyde
- rag: ChatBotRag
+ - websearch: ${oc.env:WEBSEARCH_PROVIDER, staan}
llm_params: &llm_params
temperature: 0.1
@@ -67,6 +68,7 @@ map_reduce:
# Enable debug logging for map & reduce
debug: ${oc.decode:${oc.env:MAP_REDUCE_DEBUG, false}}
+
verbose:
level: ${oc.env:LOG_LEVEL, DEBUG}
diff --git a/.hydra_config/websearch/base.yaml b/.hydra_config/websearch/base.yaml
new file mode 100644
index 000000000..83381dd81
--- /dev/null
+++ b/.hydra_config/websearch/base.yaml
@@ -0,0 +1,11 @@
+provider: ''
+api_token: ${oc.env:WEBSEARCH_API_TOKEN, ""}
+base_url: ''
+top_k: ${oc.decode:${oc.env:WEBSEARCH_TOP_K, 5}}
+lang: ${oc.env:WEBSEARCH_LANG, fr-FR}
+max_tokens: ${oc.decode:${oc.env:WEBSEARCH_MAX_TOKENS, 2000}}
+fetch_content: ${oc.decode:${oc.env:WEBSEARCH_FETCH_CONTENT, true}}
+fetch_max_results: ${oc.decode:${oc.env:WEBSEARCH_FETCH_MAX_RESULTS, 3}}
+fetch_timeout: ${oc.decode:${oc.env:WEBSEARCH_FETCH_TIMEOUT, 1.0}}
+fetch_max_tokens: ${oc.decode:${oc.env:WEBSEARCH_FETCH_MAX_TOKENS, 500}}
+fetch_verify_ssl: ${oc.decode:${oc.env:WEBSEARCH_FETCH_VERIFY_SSL, false}}
diff --git a/.hydra_config/websearch/staan.yaml b/.hydra_config/websearch/staan.yaml
new file mode 100644
index 000000000..de14bc331
--- /dev/null
+++ b/.hydra_config/websearch/staan.yaml
@@ -0,0 +1,5 @@
+defaults:
+ - base
+
+provider: staan
+base_url: ${oc.env:WEBSEARCH_BASE_URL, "https://api.staan.ai/search/web"}
diff --git a/CLAUDE.md b/CLAUDE.md
index 2bda129f7..96dad6ced 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -189,6 +189,28 @@ await vectordb.list_partition_members.remote(partition)
- For admins with `SUPER_ADMIN_MODE=true`, `all` resolves to all system partitions
- Model prefix is `openrag-` (legacy: `ragondin-`)
+### Web Search Integration
+
+Optional web search augmentation via the Staan API, allowing the LLM to combine RAG document context with live web results.
+
+**Configuration** (`.hydra_config/config.yaml` → `websearch:` block, env vars):
+- `WEBSEARCH_API_TOKEN` — provider API token; if unset, web search is silently disabled
+- `WEBSEARCH_BASE_URL` — provider endpoint (default: Staan API)
+- `WEBSEARCH_TOP_K` — number of web results (default: 5)
+- `WEBSEARCH_LANG` — search language/market (default: `fr-FR`)
+
+**How it works:**
+- Client sends `metadata: {"websearch": true}` in the chat completion request
+- **Combined mode** (partition + websearch): RAG retrieval and web search run concurrently via `asyncio.gather()`; web results are appended after document sources with continuous `[Source N]` numbering
+- **Web-only mode** (no partition + websearch): skips RAG retrieval entirely, uses web results as sole context; if no results (token unset / search fails), falls back to plain direct LLM mode
+- Source entries include `source_type: "document"` or `source_type: "web"` in the `extra.sources` response
+
+**Key files:**
+- `openrag/components/websearch/` — `WebSearchService`, `BaseWebSearchProvider`, `StaanProvider`
+- `openrag/components/utils.py` — `format_web_context()` formats web results as numbered source blocks
+- `openrag/components/pipeline.py` — `_prepare_for_web_only()`, web search logic in `_prepare_for_chat_completion()`
+- `openrag/routers/openai.py` — `__prepare_sources()` merges document and web sources
+
### File Quota System
Per-user file quota enforcement tracked via the `file_count` and `file_quota` columns on `users`, and `created_by` on `files`.
@@ -236,7 +258,7 @@ Environment variables override config values (see `.env.example`).
act -j api-tests -W .github/workflows/api_tests.yml --bind
```
-**Mock VLLM for CI:** `.github/workflows/api_tests/mock_vllm.py` provides fake embeddings and completions endpoints (streaming and non-streaming) for testing without a real LLM. Pydantic request models use `ConfigDict(extra="allow")` to accept vendor-specific fields like `extra_body`.
+**Mock VLLM for CI:** `tests/api_tests/api_run/mock_vllm.py` provides fake embeddings and completions endpoints (streaming and non-streaming) for testing without a real LLM. Pydantic request models use `ConfigDict(extra="allow")` to accept vendor-specific fields like `extra_body`.
## Key Patterns
diff --git a/docs/content/docs/documentation/API.mdx b/docs/content/docs/documentation/API.mdx
index 7174560a2..c2fc43466 100644
--- a/docs/content/docs/documentation/API.mdx
+++ b/docs/content/docs/documentation/API.mdx
@@ -400,17 +400,16 @@ OpenAI-compatible text completion endpoint.
#### Extra arguments
-* When using the openai endpoint /v1/chat/completions, one can provide extra arguments in the request body to customize the RAG behavior:
-- `spoken_style_answer`: boolean (default: false) - If true, the model will generate a succint spoken style conversational answer based on the retrieved documents.
-- `use_map_reduce`: boolean (default: false) - If true, the model will use a map-reduce strategy to aggregate information from multiple documents. For more information see the [map-reduce documentation](/openrag/documentation/env_vars/#map--reduce-configuration).
-- `llm_override`: object (optional) - Route the request to a different LLM endpoint while still using OpenRAG's RAG pipeline (retrieval, reranking, prompt construction). Accepts the following fields:
- - `base_url`: string - Base URL of the target LLM API (e.g. `https://api.openai.com/v1`)
- - `api_key`: string - API key for the target LLM
- - `model`: string - Model name to use on the target endpoint
+* When using the openai endpoint /v1/chat/completions, you can pass extra arguments **via the `metadata` field** of the request body to customize the RAG behavior:
- Any field not provided falls back to the default OpenRAG LLM configuration.
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `websearch` | `bool` | `false` | Augments the RAG context with live web search results. When used with a partition (`openrag-{partition}`), document and web results are combined. When used without a partition (direct LLM mode), web results are the sole context. Requires `WEBSEARCH_API_TOKEN` to be configured. See [web search configuration](/openrag/documentation/env_vars/#web-search-configuration). |
+| `spoken_style_answer` | `bool` | `false` | Generates a succinct spoken-style conversational answer based on the retrieved documents. |
+| `use_map_reduce` | `bool` | `false` | Uses a map-reduce strategy to aggregate information from multiple documents. See [map-reduce configuration](/openrag/documentation/env_vars/#map--reduce-configuration). |
+| `llm_override` | `object` | `null` | Routes the request to a different LLM endpoint while still using OpenRAG's RAG pipeline (retrieval, reranking, prompt construction). Accepts: `base_url` (string), `api_key` (string), `model` (string). Any field not provided falls back to the default OpenRAG LLM configuration. |
-These arguments are supplied via the metadata field of the OpenAI request body. Example:
+Examples:
```bash title="Enabling conversational answer with openai chat completions endpoint"
curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \
@@ -433,6 +432,46 @@ curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \
}'
```
+```bash title="Enabling web search with RAG documents"
+curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \
+ -H 'accept: application/json' \
+ -H 'Authorization: Bearer YOUR_AUTH_TOKEN' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "openrag-{partition_name}",
+ "messages": [
+ {
+ "role": "user",
+ "content": "your_query"
+ }
+ ],
+ "stream": false,
+ "metadata": {
+ "websearch": true
+ }
+}'
+```
+
+```bash title="Web search only (no RAG partition)"
+curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \
+ -H 'accept: application/json' \
+ -H 'Authorization: Bearer YOUR_AUTH_TOKEN' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "",
+ "messages": [
+ {
+ "role": "user",
+ "content": "your_query"
+ }
+ ],
+ "stream": false,
+ "metadata": {
+ "websearch": true
+ }
+}'
+```
+
```bash title="Using a custom LLM endpoint with OpenRAG's RAG pipeline"
curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \
-H 'accept: application/json' \
diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md
index 8d012a034..32534c3b1 100644
--- a/docs/content/docs/documentation/env_vars.md
+++ b/docs/content/docs/documentation/env_vars.md
@@ -383,6 +383,28 @@ Ray Serve enables deployment of the FastAPI as a scalable service. For simple de
| `CHAINLIT_PORT` | int | 8090 | Port for the Chainlit UI interface if ray serve is enable `ENABLE_RAY_SERVE`. If not chainlit UI is simply a subroute (`/chainlit` [see this](/openrag/getting_started/usage/#default-ports)) of the FastAPI **`base_url`**|
+### Web Search Configuration
+
+Web search allows the LLM to augment RAG document context with live web results. It is disabled by default — set `WEBSEARCH_API_TOKEN` to enable it.
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `WEBSEARCH_PROVIDER` | `str` | `staan` | Web search provider to use. Currently supported: `staan`. |
+| `WEBSEARCH_API_TOKEN` | `str` | `""` | API token for the web search provider. If empty, web search is disabled. |
+| `WEBSEARCH_BASE_URL` | `str` | (provider default) | Base URL of the web search provider API. |
+| `WEBSEARCH_TOP_K` | `int` | `5` | Number of web search results to return. |
+| `WEBSEARCH_LANG` | `str` | `fr-FR` | Language/market code for web search queries. |
+| `WEBSEARCH_MAX_TOKENS` | `int` | `2000` | Maximum token budget for all web sources combined in the LLM context. This budget is reserved from the global context window when web results are present. |
+| `WEBSEARCH_FETCH_CONTENT` | `bool` | `true` | When enabled, fetches actual page content from the top URLs instead of relying on short search snippets. |
+| `WEBSEARCH_FETCH_MAX_RESULTS` | `int` | `3` | Number of top URLs to fetch content from (the remaining results use their search snippet). |
+| `WEBSEARCH_FETCH_TIMEOUT` | `float` | `1.0` | Per-URL timeout in seconds for content fetching. URLs that don't respond within this time fall back to their snippet. |
+| `WEBSEARCH_FETCH_MAX_TOKENS` | `int` | `500` | Maximum approximate tokens of content to extract per page. Content is truncated at word boundaries. |
+| `WEBSEARCH_FETCH_VERIFY_SSL` | `bool` | `false` | Whether to verify SSL certificates when fetching page content. |
+
+:::tip[How to Enable Web Search?]
+When chatting, you can enable web search through the OpenAI-compatible API by setting `"websearch": true` in the `metadata` field of the request body. See the [API documentation](/openrag/documentation/api/#extra-arguments) for examples.
+:::
+
### Map & Reduce Configuration
The map & reduce mechanism processes documents by fetching chunks (map phase), filtering out irrelevant ones and summarizing relevant content (reduce phase) with respect to the user's query. The algorithm works as follows:
diff --git a/docs/content/docs/documentation/features_in_details.md b/docs/content/docs/documentation/features_in_details.md
index 5cce7a411..bc65fc886 100644
--- a/docs/content/docs/documentation/features_in_details.md
+++ b/docs/content/docs/documentation/features_in_details.md
@@ -84,6 +84,22 @@ See the section on [distributed deployment in a ray cluster](#5-distributed-depl
+### 🌐 Web Search Augmentation
+Enhance RAG responses with live web search results. When enabled, the LLM can combine document context with up-to-date information from the web.
+
+Web Search Features
+
+* **Combined mode** — RAG retrieval and web search run concurrently; web results are appended as additional sources alongside document sources
+* **Web-only mode** — Skip RAG entirely by omitting the partition; uses web results as the sole context
+* **Content fetching** — The top 3 URLs are fetched in parallel (1s timeout) and their main content is extracted, providing richer context than search snippets alone
+* **Boilerplate filtering** — Navigation, footers, headers, and other non-content HTML elements are stripped before extraction
+* **Graceful fallback** — If web search fails or returns no results, the pipeline continues with document context only (or falls back to direct LLM mode)
+* **Source attribution** — Web sources are tagged with `source_type: "web"` in the response, distinct from `source_type: "document"`
+
+
World paragraph content here.
" + + async def mock_handler(request): + return httpx.Response(200, text=html) + + transport = httpx.MockTransport(mock_handler) + async with httpx.AsyncClient(transport=transport) as client: + text = await fetcher._fetch_single(client, "https://example.com") + + assert text is not None + assert "Hello" in text + assert "World paragraph content" in text + + @pytest.mark.asyncio + async def test_returns_none_on_timeout(self, fetcher): + async def slow_handler(request): + await asyncio.sleep(5) + return httpx.Response(200, text="too late") + + transport = httpx.MockTransport(slow_handler) + async with httpx.AsyncClient(transport=transport) as client: + text = await fetcher._fetch_single(client, "https://slow.example.com") + + assert text is None + + @pytest.mark.asyncio + async def test_returns_none_on_http_error(self, fetcher): + async def error_handler(request): + return httpx.Response(500, text="error") + + transport = httpx.MockTransport(error_handler) + async with httpx.AsyncClient(transport=transport) as client: + text = await fetcher._fetch_single(client, "https://error.example.com") + + assert text is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "url", + [ + "http://localhost/secret", + "http://127.0.0.1/admin", + "http://127.0.0.42/x", + "http://[::1]/admin", + "http://10.0.0.1/internal", + "http://192.168.1.1/router", + "http://169.254.169.254/metadata", + "http://0.0.0.0/x", + ], + ) + async def test_skips_loopback_urls(self, fetcher, url): + async def mock_handler(request): + return httpx.Response(200, text="secret") + + transport = httpx.MockTransport(mock_handler) + async with httpx.AsyncClient(transport=transport) as client: + text = await fetcher._fetch_single(client, url) + + assert text is None + + @pytest.mark.asyncio + async def test_strips_boilerplate_html(self, fetcher): + html = """ + +This is the actual article content.
Page content for testing.
" + + async def mock_handler(request): + return httpx.Response(200, text=html) + + transport = httpx.MockTransport(mock_handler) + results = [_make_result(f"https://example.com/{i}") for i in range(5)] + + async with httpx.AsyncClient(transport=transport) as client: + fetcher._client_override = client + enriched = await fetcher.enrich(results) + + for r in enriched[:3]: + assert r.content is not None + for r in enriched[3:]: + assert r.content is None + + @pytest.mark.asyncio + async def test_failed_fetch_keeps_none_content(self, fetcher): + async def error_handler(request): + return httpx.Response(500, text="error") + + transport = httpx.MockTransport(error_handler) + results = [_make_result()] + + async with httpx.AsyncClient(transport=transport) as client: + fetcher._client_override = client + enriched = await fetcher.enrich(results) + + assert enriched[0].content is None diff --git a/openrag/models/openai.py b/openrag/models/openai.py index 7f233bdec..323e44d64 100644 --- a/openrag/models/openai.py +++ b/openrag/models/openai.py @@ -29,6 +29,8 @@ class OpenAIChatCompletionRequest(BaseModel): { "use_map_reduce": False, "spoken_style_answer": False, + "websearch": False, + "llm_override": None, }, description="Extra custom parameters. Supports 'llm_override' object with optional 'base_url', 'api_key', and 'model' to override the downstream LLM endpoint.", ) diff --git a/openrag/routers/openai.py b/openrag/routers/openai.py index 263dbdadf..9eb686fb7 100644 --- a/openrag/routers/openai.py +++ b/openrag/routers/openai.py @@ -1,9 +1,10 @@ import asyncio import json from pathlib import Path -from urllib.parse import quote +from urllib.parse import quote, urlparse import consts +from components.indexer.utils.text_sanitizer import sanitize_text from components.pipeline import RagPipeline from components.utils import ( extract_and_strip_sources_block, @@ -110,7 +111,7 @@ async def list_models( return JSONResponse(content={"object": "list", "data": models}) -def __prepare_sources(request: Request, docs: list[Document]): +def __prepare_sources(request: Request, docs: list[Document], web_results: list | None = None): links = [] for doc in docs: doc_metadata = dict(doc.metadata) @@ -119,11 +120,24 @@ def __prepare_sources(request: Request, docs: list[Document]): encoded_url = quote(file_url, safe=":/") links.append( { + "source_type": "document", "file_url": encoded_url, "chunk_url": str(request.url_for("get_extract", extract_id=doc_metadata["_id"])), **doc_metadata, } ) + for result in web_results or []: + url = sanitize_text(result.url or "") + if not url or urlparse(url).scheme not in ("http", "https"): + continue + links.append( + { + "source_type": "web", + "url": url, + "title": sanitize_text(result.title), + "snippet": sanitize_text(result.snippet), + } + ) return links @@ -319,10 +333,10 @@ async def openai_chat_completion( partitions = await get_partition_name(model_name, user_partitions, is_admin=user["is_admin"]) log.debug(f"Using partitions: {partitions}") - llm_output, docs = await ragpipe.chat_completion(partition=partitions, payload=request.model_dump()) + llm_output, docs, web_results = await ragpipe.chat_completion(partition=partitions, payload=request.model_dump()) log.debug("RAG chat completion pipeline executed.") - sources = __prepare_sources(request2, docs) + sources = __prepare_sources(request2, docs, web_results=web_results) if request.stream: diff --git a/pyproject.toml b/pyproject.toml index 7990c9870..648e51878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "pytest-env>=1.1.5", "markitdown[docx]>=0.1.3", "html-to-markdown>=2.4.0", + "lxml>=5.0.0", "alembic>=1.17.0", "fast-langdetect>=1.0.0", "ruff>=0.14.1", diff --git a/tests/api_tests/test_openai_compat.py b/tests/api_tests/test_openai_compat.py index 8c1050203..b0446da3a 100644 --- a/tests/api_tests/test_openai_compat.py +++ b/tests/api_tests/test_openai_compat.py @@ -234,6 +234,52 @@ def test_streaming_has_finish_reason(self, api_client, indexed_partition): assert isinstance(extra["sources"], list) +class TestWebOnlyMode: + """Test web-only mode: metadata.websearch=true with no partition.""" + + def test_web_only_mode_no_partition(self, api_client): + """Web-only mode returns 200 with valid response when websearch=true and no partition.""" + response = api_client.post( + "/v1/chat/completions", + json={ + "model": "", # empty string → is_direct_llm_model()=True → partition=None + "messages": [{"role": "user", "content": "What is the capital of France?"}], + "metadata": {"websearch": True}, + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "choices" in data + assert len(data["choices"]) > 0 + assert data["choices"][0]["message"]["content"] # non-empty string + + # If sources present, all must be web type (no document sources in web-only mode) + extra = json.loads(data["extra"]) if data.get("extra") else {} + sources = extra.get("sources", []) + for source in sources: + assert source.get("source_type") == "web" + + def test_web_only_mode_graceful_degradation(self, api_client): + """Web-only mode with no web results still returns 200 (plain LLM answer).""" + # In CI with mock VLLM and no Staan, web results will be empty. + # The LLM should still answer — graceful degradation, not an error. + response = api_client.post( + "/v1/chat/completions", + json={ + "model": "", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": {"websearch": True}, + "stream": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "choices" in data + assert len(data["choices"]) > 0 + assert data["choices"][0]["message"]["content"] # non-empty + + class TestChatCompletionsMultiPartition: """Test chat completions with multi-partition access."""