Skip to content

Commit 644b118

Browse files
authored
Merge pull request #4 from webstackdev/feature/wp4-embeddings-and-qdrant-integration
Feature/wp4 embeddings and qdrant integration
2 parents 662be4b + 0b36523 commit 644b118

16 files changed

Lines changed: 601 additions & 12 deletions

‎.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ REDIS_URL=redis://localhost:6379/0
88
QDRANT_URL=http://localhost:6333
99

1010
OPENROUTER_API_KEY=
11+
OPENROUTER_API_BASE=https://openrouter.ai/api/v1
12+
OPENROUTER_APP_URL=
13+
OPENROUTER_APP_NAME=newsletter-maker
14+
EMBEDDING_PROVIDER=sentence-transformers
1115
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
16+
EMBEDDING_TRUST_REMOTE_CODE=false
17+
OLLAMA_URL=http://localhost:11434
1218
REDDIT_CLIENT_ID=
1319
REDDIT_CLIENT_SECRET=
1420
REDDIT_USER_AGENT=newsletter-maker/0.1

‎.vscode/settings.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"mday",
1111
"noinput",
1212
"nomic",
13+
"OLLAMA",
1314
"PRAW",
1415
"PYTHONDONTWRITEBYTECODE",
1516
"PYTHONUNBUFFERED",

‎IMPLEMENTATION.md‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ Set up the Django project, Docker infrastructure, and development environment.
3232
- `.env.example` with all required environment variables:
3333
- `DATABASE_URL`, `REDIS_URL`, `QDRANT_URL`
3434
- `OPENROUTER_API_KEY`
35-
- `EMBEDDING_MODEL` (default: `nomic-embed-text` or `all-MiniLM-L6-v2`)
35+
- `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`
36+
- `OLLAMA_URL` for local Ollama embeddings
37+
- `OPENROUTER_API_BASE` for hosted OpenAI-compatible embeddings APIs
3638
- `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`
3739
- Health check endpoints: `GET /healthz/` (system health), `GET /readyz/` (DB + Qdrant reachable)
3840
- `justfile` with commands: `dev` (docker compose up), `test`, `migrate`, `seed`, `shell`
@@ -218,7 +220,18 @@ What is not built yet is the full done state from the plan, especially live feed
218220

219221
Compute embeddings for all ingested content and store them in Qdrant for similarity search.
220222

221-
**Embedding model:** Local, free — `sentence-transformers/all-MiniLM-L6-v2` (384 dimensions) or `nomic-embed-text` via Ollama (768 dimensions). Configurable via `EMBEDDING_MODEL` env var.
223+
**Embedding backend:** Configurable via `EMBEDDING_PROVIDER` plus `EMBEDDING_MODEL`.
224+
225+
- `sentence-transformers`: local Hugging Face / SentenceTransformers model loading
226+
- `ollama`: local model served over Ollama's embedding API
227+
- `openrouter`: hosted embeddings through the OpenRouter `/embeddings` API
228+
229+
Examples:
230+
231+
- `EMBEDDING_PROVIDER=sentence-transformers`, `EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2`
232+
- `EMBEDDING_PROVIDER=ollama`, `EMBEDDING_MODEL=nomic-embed-text`
233+
- `EMBEDDING_PROVIDER=ollama`, `EMBEDDING_MODEL=qwen3-embedding-8b`
234+
- `EMBEDDING_PROVIDER=openrouter`, `EMBEDDING_MODEL=openai/text-embedding-3-small`
222235

223236
**Qdrant setup:**
224237

@@ -230,8 +243,9 @@ Compute embeddings for all ingested content and store them in Qdrant for similar
230243

231244
1. Plugin fetches article → `Content` record created in Postgres
232245
2. Post-save signal (or inline in ingestion task) computes embedding
233-
3. Embedding + metadata upserted into tenant's Qdrant collection
234-
4. `Content.embedding_id` stores the Qdrant point ID for later retrieval
246+
3. The configured embedding provider returns a vector for the content text
247+
4. Embedding + metadata upserted into tenant's Qdrant collection
248+
5. `Content.embedding_id` stores the Qdrant point ID for later retrieval
235249

236250
**Reference corpus seeding:**
237251

@@ -246,8 +260,17 @@ Compute embeddings for all ingested content and store them in Qdrant for similar
246260
- `search_similar(tenant_id: int, query_vector: list[float], limit: int) -> list[ScoredPoint]` — find similar content
247261
- `get_reference_similarity(tenant_id: int, vector: list[float]) -> float` — average similarity against reference corpus
248262

263+
**Operational usage:**
264+
265+
- `just embed-all` — backfill embeddings for all content rows
266+
- `just embed-tenant <tenant_id>` — backfill one tenant's content
267+
- `python3 manage.py sync_embeddings --content-id <id>` — re-embed one record
268+
249269
**Definition of done:** Every ingested content item has an embedding in Qdrant. `search_similar` returns semantically related articles. Reference corpus is seeded for test tenant.
250270

271+
- Run `just embed-smoke` to confirm Django can talk to Ollama.
272+
- If that works, run `just embed-all` or `just embed-tenant <tenant_id>` to backfill real content.
273+
251274
### WP5: AI Skills + LangGraph Pipeline
252275

253276
Implement the three Phase 1 skills and wire them into a LangGraph orchestrator.

‎README.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,47 @@ python3 -m pip install -r requirements.txt
9292

9393
For host-based development without Docker, install `requirements.txt`, then use `python3 manage.py migrate` and `python3 manage.py runserver`. The default `.env.example` is host-safe; Docker Compose overrides the service URLs inside containers.
9494

95+
### Embedding Backends
96+
97+
The embedding layer is provider-based. Configure it with `EMBEDDING_PROVIDER` and `EMBEDDING_MODEL`:
98+
99+
- `sentence-transformers`: loads a Hugging Face / SentenceTransformers model inside the Django process
100+
- `ollama`: calls a local Ollama server for embeddings
101+
- `openrouter`: calls OpenRouter's embeddings API using the configured model id
102+
103+
Common examples:
104+
105+
```dotenv
106+
EMBEDDING_PROVIDER=sentence-transformers
107+
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
108+
```
109+
110+
```dotenv
111+
EMBEDDING_PROVIDER=ollama
112+
EMBEDDING_MODEL=nomic-embed-text
113+
OLLAMA_URL=http://localhost:11434
114+
```
115+
116+
```dotenv
117+
EMBEDDING_PROVIDER=openrouter
118+
EMBEDDING_MODEL=openai/text-embedding-3-small
119+
OPENROUTER_API_KEY=...
120+
OPENROUTER_API_BASE=https://openrouter.ai/api/v1
121+
```
122+
123+
For SentenceTransformers models that require custom remote code, set `EMBEDDING_TRUST_REMOTE_CODE=true`.
124+
125+
### Embedding Commands
126+
127+
Use these commands to backfill or refresh embeddings for existing content:
128+
129+
```bash
130+
just embed-all
131+
just embed-tenant 1
132+
python3 manage.py sync_embeddings --content-id 42
133+
python3 manage.py sync_embeddings --references-only
134+
```
135+
95136
When `just dev` is running, Django admin uses the Postgres database inside Docker, not the host SQLite database. That means host commands like `python manage.py createsuperuser` create users in SQLite and will not let you log into the Docker-backed admin site.
96137

97138
Create or update an admin user for the running Docker stack with:

‎core/admin.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ class EntityAdmin(admin.ModelAdmin):
2323

2424
@admin.register(Content)
2525
class ContentAdmin(admin.ModelAdmin):
26-
list_display = ("title", "tenant", "source_plugin", "published_date", "relevance_score", "is_active")
27-
list_filter = ("tenant", "source_plugin", "is_active")
26+
list_display = ("title", "tenant", "source_plugin", "published_date", "relevance_score", "is_reference", "is_active")
27+
list_filter = ("tenant", "source_plugin", "is_reference", "is_active")
2828
search_fields = ("title", "author", "url")
2929

3030

‎core/embeddings.py‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from functools import lru_cache
5+
from uuid import uuid4
6+
7+
from django.conf import settings
8+
from django.utils.dateparse import parse_datetime
9+
import httpx
10+
from qdrant_client import QdrantClient
11+
from qdrant_client.models import Distance, FieldCondition, Filter, MatchValue, PointStruct, VectorParams
12+
from sentence_transformers import SentenceTransformer
13+
14+
from core.models import Content
15+
16+
17+
class EmbeddingProvider(ABC):
18+
@abstractmethod
19+
def embed_text(self, text: str) -> list[float]:
20+
raise NotImplementedError
21+
22+
def get_embedding_dimension(self) -> int:
23+
return len(self.embed_text("dimension probe"))
24+
25+
26+
class SentenceTransformerEmbeddingProvider(EmbeddingProvider):
27+
def __init__(self):
28+
self.model = SentenceTransformer(
29+
settings.EMBEDDING_MODEL,
30+
trust_remote_code=settings.EMBEDDING_TRUST_REMOTE_CODE,
31+
)
32+
33+
def embed_text(self, text: str) -> list[float]:
34+
return self.model.encode(text, normalize_embeddings=True).tolist()
35+
36+
def get_embedding_dimension(self) -> int:
37+
return int(self.model.get_sentence_embedding_dimension())
38+
39+
40+
class OllamaEmbeddingProvider(EmbeddingProvider):
41+
def embed_text(self, text: str) -> list[float]:
42+
normalized_text = normalize_text(text)
43+
response = httpx.post(
44+
f"{settings.OLLAMA_URL.rstrip('/')}/api/embed",
45+
json={"model": settings.EMBEDDING_MODEL, "input": [normalized_text]},
46+
timeout=30.0,
47+
)
48+
if response.status_code == 404:
49+
legacy_response = httpx.post(
50+
f"{settings.OLLAMA_URL.rstrip('/')}/api/embeddings",
51+
json={"model": settings.EMBEDDING_MODEL, "prompt": normalized_text},
52+
timeout=30.0,
53+
)
54+
legacy_response.raise_for_status()
55+
return legacy_response.json()["embedding"]
56+
response.raise_for_status()
57+
return response.json()["embeddings"][0]
58+
59+
60+
class OpenRouterEmbeddingProvider(EmbeddingProvider):
61+
def embed_text(self, text: str) -> list[float]:
62+
if not settings.OPENROUTER_API_KEY:
63+
raise RuntimeError("OPENROUTER_API_KEY must be set when using the openrouter embedding provider.")
64+
headers = {
65+
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
66+
"Content-Type": "application/json",
67+
}
68+
if settings.OPENROUTER_APP_URL:
69+
headers["HTTP-Referer"] = settings.OPENROUTER_APP_URL
70+
if settings.OPENROUTER_APP_NAME:
71+
headers["X-OpenRouter-Title"] = settings.OPENROUTER_APP_NAME
72+
response = httpx.post(
73+
f"{settings.OPENROUTER_API_BASE.rstrip('/')}/embeddings",
74+
headers=headers,
75+
json={
76+
"model": settings.EMBEDDING_MODEL,
77+
"input": normalize_text(text),
78+
"encoding_format": "float",
79+
},
80+
timeout=30.0,
81+
)
82+
response.raise_for_status()
83+
return response.json()["data"][0]["embedding"]
84+
85+
86+
def collection_name_for_tenant(tenant_id: int) -> str:
87+
return f"tenant_{tenant_id}_content"
88+
89+
90+
@lru_cache(maxsize=1)
91+
def get_qdrant_client() -> QdrantClient:
92+
return QdrantClient(url=settings.QDRANT_URL, timeout=10.0)
93+
94+
95+
@lru_cache(maxsize=1)
96+
def get_embedding_provider() -> EmbeddingProvider:
97+
provider_name = settings.EMBEDDING_PROVIDER
98+
if provider_name == "sentence-transformers":
99+
return SentenceTransformerEmbeddingProvider()
100+
if provider_name == "ollama":
101+
return OllamaEmbeddingProvider()
102+
if provider_name == "openrouter":
103+
return OpenRouterEmbeddingProvider()
104+
raise ValueError(f"Unsupported embedding provider: {provider_name}")
105+
106+
107+
def get_embedding_dimension() -> int:
108+
return get_embedding_provider().get_embedding_dimension()
109+
110+
111+
def embed_text(text: str) -> list[float]:
112+
return get_embedding_provider().embed_text(normalize_text(text))
113+
114+
115+
def upsert_content_embedding(content: Content) -> str:
116+
client = get_qdrant_client()
117+
ensure_tenant_collection(content.tenant_id)
118+
embedding_id = content.embedding_id or str(uuid4())
119+
vector = embed_text(build_content_embedding_text(content))
120+
client.upsert(
121+
collection_name=collection_name_for_tenant(content.tenant_id),
122+
points=[
123+
PointStruct(
124+
id=embedding_id,
125+
vector=vector,
126+
payload={
127+
"content_id": content.id,
128+
"tenant_id": content.tenant_id,
129+
"url": content.url,
130+
"title": content.title,
131+
"published_date": serialize_published_date(content.published_date),
132+
"source_plugin": content.source_plugin,
133+
"is_reference": content.is_reference,
134+
},
135+
)
136+
],
137+
wait=True,
138+
)
139+
if content.embedding_id != embedding_id:
140+
content.embedding_id = embedding_id
141+
content.save(update_fields=["embedding_id"])
142+
return embedding_id
143+
144+
145+
def search_similar(
146+
tenant_id: int,
147+
query_vector: list[float],
148+
limit: int = 10,
149+
*,
150+
is_reference: bool | None = None,
151+
exclude_content_id: int | None = None,
152+
):
153+
if not tenant_collection_exists(tenant_id):
154+
return []
155+
query_filter = build_search_filter(is_reference=is_reference, exclude_content_id=exclude_content_id)
156+
return get_qdrant_client().search(
157+
collection_name=collection_name_for_tenant(tenant_id),
158+
query_vector=query_vector,
159+
limit=limit,
160+
query_filter=query_filter,
161+
with_payload=True,
162+
)
163+
164+
165+
def get_reference_similarity(tenant_id: int, vector: list[float], limit: int = 5) -> float:
166+
scored_points = search_similar(tenant_id, vector, limit=limit, is_reference=True)
167+
if not scored_points:
168+
return 0.0
169+
return sum(point.score for point in scored_points) / len(scored_points)
170+
171+
172+
def ensure_tenant_collection(tenant_id: int) -> None:
173+
client = get_qdrant_client()
174+
collection_name = collection_name_for_tenant(tenant_id)
175+
if tenant_collection_exists(tenant_id):
176+
return
177+
client.create_collection(
178+
collection_name=collection_name,
179+
vectors_config=VectorParams(size=get_embedding_dimension(), distance=Distance.COSINE),
180+
)
181+
182+
183+
def tenant_collection_exists(tenant_id: int) -> bool:
184+
try:
185+
get_qdrant_client().get_collection(collection_name_for_tenant(tenant_id))
186+
except Exception:
187+
return False
188+
return True
189+
190+
191+
def build_content_embedding_text(content: Content) -> str:
192+
return "\n\n".join(part for part in [content.title, content.content_text] if part)
193+
194+
195+
def normalize_text(text: str) -> str:
196+
normalized_text = text.strip()
197+
if not normalized_text:
198+
return "empty content"
199+
return normalized_text
200+
201+
202+
def serialize_published_date(value) -> str:
203+
if hasattr(value, "isoformat"):
204+
return value.isoformat()
205+
if isinstance(value, str):
206+
parsed_value = parse_datetime(value)
207+
if parsed_value is not None:
208+
return parsed_value.isoformat()
209+
return value
210+
return str(value)
211+
212+
213+
def build_search_filter(*, is_reference: bool | None = None, exclude_content_id: int | None = None):
214+
conditions = []
215+
if is_reference is not None:
216+
conditions.append(FieldCondition(key="is_reference", match=MatchValue(value=is_reference)))
217+
if exclude_content_id is not None:
218+
conditions.append(FieldCondition(key="content_id", match=MatchValue(value=exclude_content_id)))
219+
if not conditions:
220+
return None
221+
return Filter(must=conditions if exclude_content_id is None else conditions[:-1], must_not=conditions[-1:] if exclude_content_id is not None else None)

0 commit comments

Comments
 (0)