Result: #{{ counter|add:offset }}
- Relevance: {{ document.relevance|floatformat:3 }}
+ FTS rank: {{ document.relevance|floatformat:3 }}
+ Cosine dist: {{ document.cosine_distance|floatformat:3|default:"—" }}
+ RRF score: {{ document.rrf_score|floatformat:4 }}
diff --git a/radis/search/tests/test_query_parser.py b/radis/search/tests/test_query_parser.py
index 28d05dd29..3ee06138e 100644
--- a/radis/search/tests/test_query_parser.py
+++ b/radis/search/tests/test_query_parser.py
@@ -136,6 +136,23 @@ def test_fixed_queries():
assert is_fixed_query("foo \\) bar", "foo bar", 2)
+def test_strips_field_filter_syntax():
+ # Field-filter syntax (`field:value`) has no place in the query grammar —
+ # structured field filtering lives in `SearchFilters`. Stripping the
+ # whole token keeps the colon from being silently dropped into a
+ # corrupted single word (`bodypneumonia`).
+ assert is_fixed_query("pneumonia body:pneumonia", "pneumonia", 1)
+ # Two stripped filters still count as one "step ran", matching how
+ # `_replace_invalid_characters` reports its own pass.
+ assert is_empty_query("body:pneumonia patient_sex:F", 1)
+ assert is_empty_query("body:pneumonia", 1)
+ # Colons inside phrases are preserved verbatim (operator syntax doesn't
+ # apply inside quoted strings).
+ assert is_valid_query('"body:pneumonia"')
+ # Time-like tokens with embedded colons are also stripped.
+ assert is_fixed_query("time:14:30 finding", "finding", 1)
+
+
def test_empty_queries():
assert is_empty_query("", 0)
assert is_empty_query(" ", 0)
diff --git a/radis/search/tests/test_query_parser_unparse_for_embedding.py b/radis/search/tests/test_query_parser_unparse_for_embedding.py
new file mode 100644
index 000000000..833db95cc
--- /dev/null
+++ b/radis/search/tests/test_query_parser_unparse_for_embedding.py
@@ -0,0 +1,41 @@
+import pytest
+
+from radis.search.utils.query_parser import QueryParser
+
+
+@pytest.mark.parametrize(
+ "query,expected",
+ [
+ # Simple positive term — unchanged.
+ ("pneumothorax", "pneumothorax"),
+ # Phrase: quotes dropped, value preserved (embedding tokenizers handle
+ # multi-word spans natively; the quote chars are noise).
+ ('"chest x-ray"', "chest x-ray"),
+ # Implicit AND (no operator) — both sides survive, joined by a space.
+ ("cardiac arrest", "cardiac arrest"),
+ # Explicit AND — operator token dropped; bag of terms.
+ ("A AND B", "A B"),
+ # Explicit OR — operator token dropped; bag of terms.
+ ("A OR B", "A B"),
+ # NOT alone — empty (polarity-blind for negation).
+ ("NOT pneumothorax", ""),
+ # AND NOT — left survives, NOT branch dropped, AND collapses.
+ ("A AND NOT B", "A"),
+ # NOT AND — right survives, NOT branch dropped, AND collapses.
+ ("NOT A AND B", "B"),
+ # NOT OR NOT — both branches dropped, empty.
+ ("NOT A OR NOT B", ""),
+ # Mixed AND OR with a NOT branch — grouping parens dropped,
+ # operators dropped, surviving terms joined.
+ ("(A AND NOT B) OR C", "A C"),
+ # Nested NOT inside parens — empty parens collapsed.
+ ("A AND (NOT B)", "A"),
+ # Double-nested OR with one NOT — parens + operators dropped,
+ # surviving disjunction terms joined.
+ ("(A OR B) AND NOT C", "A B"),
+ ],
+)
+def test_unparse_for_embedding(query, expected):
+ node, _fixes = QueryParser().parse(query)
+ assert node is not None, f"parser produced empty node for {query!r}"
+ assert QueryParser.unparse_for_embedding(node) == expected
diff --git a/radis/search/tests/test_views.py b/radis/search/tests/test_views.py
index 3a0f672f6..8e0290da9 100644
--- a/radis/search/tests/test_views.py
+++ b/radis/search/tests/test_views.py
@@ -324,3 +324,18 @@ def test_search_view_form_validation_errors(client: Client):
response = client.get("/search/", search_params)
assert response.status_code == 200
assert "form" in response.context
+
+
+@pytest.mark.django_db
+def test_search_view_returns_200_when_embedding_provider_unset(client: Client, settings):
+ """SearchView returns 200 via FTS-only fallback when EMBEDDING_PROVIDER_URL is unset."""
+ from django.conf import settings as django_settings
+
+ settings.EMBEDDING_PROVIDER_URL = ""
+ settings.MIDDLEWARE = [
+ m for m in django_settings.MIDDLEWARE if "debug_toolbar" not in m.lower()
+ ]
+ user = create_test_user_with_active_group()
+ client.force_login(user)
+ response = client.get("/search/?query=pneumothorax")
+ assert response.status_code == 200
diff --git a/radis/search/utils/query_parser.py b/radis/search/utils/query_parser.py
index 4782a39a1..f26ac92d0 100644
--- a/radis/search/utils/query_parser.py
+++ b/radis/search/utils/query_parser.py
@@ -141,6 +141,25 @@ def _modify_unquoted_segments(
return "".join(results)
+ def _strip_field_filters(self, input_string: str) -> str:
+ """Drop `field:value` tokens (e.g., ``body:pneumonia``,
+ ``patient_sex:F``, ``time:14:30``).
+
+ The parser grammar has no field-filter syntax — structured field
+ filtering lives on the provider side via ``SearchFilters``. Without
+ this step the colon would be silently stripped by
+ ``_replace_invalid_characters`` and ``body:pneumonia`` would collapse
+ to ``bodypneumonia``, a meaningless token that pollutes both the FTS
+ tsquery and the dense-embedding text. Drop the whole token instead.
+
+ Operates only on unquoted segments so ``"body:pneumonia"`` inside a
+ phrase is preserved verbatim.
+ """
+ pattern = re.compile(r"\b\w+:\S+")
+ return self._modify_unquoted_segments(
+ input_string, lambda s: pattern.sub("", s)
+ )
+
def _replace_invalid_characters(self, input_string: str) -> str:
def handle_segment(segment: str) -> str:
return "".join(char for char in segment if is_search_query_char(char))
@@ -244,6 +263,11 @@ def parse(self, query: str) -> tuple[QueryNode | None, list[str]]:
if query_before != query_after:
fixes.append("Fixed unbalanced parentheses")
+ query_before = query_after
+ query_after = self._strip_field_filters(query_before)
+ if query_before != query_after:
+ fixes.append("Stripped field-filter syntax (use the filter widgets instead)")
+
query_before = query_after
query_after = self._replace_invalid_characters(query_before)
if query_before != query_after:
@@ -312,3 +336,41 @@ def unparse(node: QueryNode) -> str:
)
else:
raise ValueError(f"Unknown node type: {type(node)}")
+
+ @staticmethod
+ def unparse_for_embedding(node: QueryNode) -> str:
+ """Render the query as a plain bag of terms suitable for a dense
+ embedding model.
+
+ - Drops every ``UnaryNode("NOT", X)`` (embeddings are polarity-blind
+ for negation; see spec §7.8).
+ - Drops boolean operator tokens (``AND``/``OR``): they're query syntax,
+ not content. The embedding model would otherwise see them as
+ stopword-ish tokens cluttering the input.
+ - Drops grouping parentheses for the same reason.
+ - Drops quotes around phrases — embedding tokenizers handle multi-word
+ spans natively; the literal quote chars only add noise.
+
+ Returns the empty string if the whole query reduces to NOT clauses.
+ Used by the hybrid-search vector half via ``providers.search``.
+ """
+ if isinstance(node, TermNode):
+ # Emit the raw value for both WORD and PHRASE — no surrounding
+ # quotes, since the embedding model doesn't care about them.
+ return node.value
+ if isinstance(node, ParensNode):
+ return QueryParser.unparse_for_embedding(node.expression)
+ if isinstance(node, UnaryNode):
+ return ""
+ if isinstance(node, BinaryNode):
+ left = QueryParser.unparse_for_embedding(node.left)
+ right = QueryParser.unparse_for_embedding(node.right)
+ if not left and not right:
+ return ""
+ if not left:
+ return right
+ if not right:
+ return left
+ # Always join with a single space — operator tokens are dropped.
+ return f"{left} {right}"
+ raise ValueError(f"Unknown node type: {type(node)}")
diff --git a/radis/settings/base.py b/radis/settings/base.py
index 319f24853..bd3a8565e 100644
--- a/radis/settings/base.py
+++ b/radis/settings/base.py
@@ -319,9 +319,7 @@
},
"dbbackup": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
- "OPTIONS": {
- "location": env.str("DBBACKUP_STORAGE_LOCATION", default="/tmp/backups-radis")
- },
+ "OPTIONS": {"location": env.str("DBBACKUP_STORAGE_LOCATION", default="/tmp/backups-radis")},
},
}
DBBACKUP_CLEANUP_KEEP = 30
@@ -338,6 +336,27 @@
LLM_SERVICE_DEV_PORT = env.int("LLM_SERVICE_DEV_PORT", default=8080)
LLM_SERVICE_URL = env.str("LLM_SERVICE_URL", default=f"http://localhost:{LLM_SERVICE_DEV_PORT}/v1")
+# Embedding service (per-deployment)
+EMBEDDING_BACKEND = env.str("EMBEDDING_BACKEND", default="openai")
+EMBEDDING_PROVIDER_URL = env.str("EMBEDDING_PROVIDER_URL", default="")
+EMBEDDING_PROVIDER_PATH = env.str("EMBEDDING_PROVIDER_PATH", default="")
+EMBEDDING_PROVIDER_API_KEY = env.str("EMBEDDING_PROVIDER_API_KEY", default="")
+EMBEDDING_MODEL_NAME = env.str("EMBEDDING_MODEL_NAME", default="Qwen/Qwen3-Embedding-4B")
+EMBEDDING_DIM = env.int("EMBEDDING_DIM", default=1024)
+
+# Embedding tuning constants
+EMBEDDING_REQUEST_TIMEOUT = 30
+EMBEDDING_QUERY_INSTRUCTION = (
+ "Instruct: Given a radiology search query, retrieve relevant radiology reports.\nQuery: "
+)
+EMBEDDING_BATCH_SIZE = 32
+EMBEDDING_SUBJOB_SIZE = 1000
+
+# Hybrid search tuning
+HYBRID_VECTOR_TOP_K = 100
+HYBRID_FTS_MAX_RESULTS = 10_000
+HYBRID_RRF_K = 60
+
# Chat
CHAT_GENERATE_TITLE_SYSTEM_PROMPT = """
Summarize the following conversation in $num_words words or less and in the same language as
diff --git a/radis/settings/test.py b/radis/settings/test.py
index 1c084d467..697d21e5c 100644
--- a/radis/settings/test.py
+++ b/radis/settings/test.py
@@ -10,3 +10,10 @@
DATABASES["default"]["TEST"] = {"NAME": test_database} # noqa: F405
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: False}
+
+# Tests must not hit a live embedding service. Embedding work is deferred via
+# a Procrastinate task; tests do not run a worker by default. Blanking the URL
+# means any incidental construction of EmbeddingClient/AsyncEmbeddingClient
+# fast-fails into EmbeddingClientError rather than touching the network. Tests
+# that exercise the embedding path explicitly patch the client.
+EMBEDDING_PROVIDER_URL = ""
diff --git a/uv.lock b/uv.lock
index 53c0b5467..8eec83838 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2020,6 +2020,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
]
+[[package]]
+name = "pgvector"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.5.1"
@@ -2768,6 +2780,7 @@ dependencies = [
{ name = "openai" },
{ name = "openpyxl" },
{ name = "pandas" },
+ { name = "pgvector" },
{ name = "procrastinate", extra = ["django"] },
{ name = "psycopg", extra = ["binary"] },
{ name = "pycountry" },
@@ -2853,6 +2866,7 @@ requires-dist = [
{ name = "openai", specifier = ">=1.64.0" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=2.2.3" },
+ { name = "pgvector", specifier = ">=0.3" },
{ name = "procrastinate", extras = ["django"], specifier = ">=3.0.2" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.5" },
{ name = "pycountry", specifier = ">=24.6.1" },