Skip to content

Commit bd91a33

Browse files
[ADD] RRF-like clustering
1 parent 9bb1349 commit bd91a33

4 files changed

Lines changed: 128 additions & 150 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Dict, List
2+
3+
def compute_entity_similarity(a: Dict, b: Dict) -> float:
4+
score = 0.0
5+
6+
if a["subject"] and a["subject"] == b["subject"]:
7+
score += 1.0
8+
9+
if a["event"] and a["event"] == b["event"]:
10+
score += 0.5
11+
12+
orgs_a = set(a.get("orgs", []))
13+
orgs_b = set(b.get("orgs", []))
14+
15+
if orgs_a and orgs_b:
16+
score += 0.2 * len(orgs_a & orgs_b)
17+
18+
return score
19+
20+
def compute_final_score(
21+
semantic_score: float,
22+
entity_score: float,
23+
w_sem: float = 0.6,
24+
w_ent: float = 0.4,
25+
) -> float:
26+
return w_sem * semantic_score + w_ent * entity_score
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SEMANTIC_SIMILARITY_THRESHOLD = 0.70
2+
FINAL_SCORE_THRESHOLD = 0.75

server/database.py

Lines changed: 84 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,32 @@
22

33
from contextlib import contextmanager
44
from datetime import UTC, datetime
5-
from typing import Dict, List, Optional, Tuple, Any
5+
from typing import Dict, Optional, Any, List
6+
import json
67

78
import numpy as np
89
import psycopg
910
from pgvector.psycopg import register_vector
1011
from psycopg.rows import dict_row
1112

13+
from clustering.cluster_scoring import (
14+
compute_entity_similarity,
15+
compute_final_score,
16+
)
17+
from clustering.cluster_thresholds import (
18+
SEMANTIC_SIMILARITY_THRESHOLD,
19+
FINAL_SCORE_THRESHOLD,
20+
)
1221

13-
class DatabaseManager:
14-
"""Manages unified database operations using PostgreSQL."""
1522

23+
class DatabaseManager:
1624
def __init__(self, db_url: str, embedding_dimension: int = 1536):
17-
"""Initialize the database manager and ensure schema is ready."""
1825
self.db_url = db_url
1926
self.embedding_dimension = embedding_dimension
2027
self.setup_database()
2128

2229
@contextmanager
2330
def get_connection(self):
24-
"""Context manager for PostgreSQL connections with pgvector registered."""
2531
conn = psycopg.connect(self.db_url, row_factory=dict_row, autocommit=False)
2632
register_vector(conn)
2733
try:
@@ -34,10 +40,8 @@ def get_connection(self):
3440
conn.close()
3541

3642
def setup_database(self):
37-
"""Initialize database and create tables (idempotent)."""
3843
with self.get_connection() as conn:
3944
cur = conn.cursor()
40-
4145
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
4246

4347
cur.execute(
@@ -55,20 +59,14 @@ def setup_database(self):
5559
published_date TIMESTAMPTZ,
5660
item_type TEXT,
5761
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
58-
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
62+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
63+
subject TEXT,
64+
organization_list JSONB,
65+
event_type TEXT,
66+
cluster_id INTEGER
5967
)
6068
"""
6169
)
62-
63-
cur.execute(
64-
"""
65-
ALTER TABLE articles
66-
ADD COLUMN IF NOT EXISTS subject TEXT,
67-
ADD COLUMN IF NOT EXISTS organization_list JSONB,
68-
ADD COLUMN IF NOT EXISTS event_type TEXT,
69-
ADD COLUMN IF NOT EXISTS cluster_id INTEGER
70-
"""
71-
)
7270

7371
cur.execute(
7472
f"""
@@ -82,136 +80,89 @@ def setup_database(self):
8280
"""
8381
)
8482

85-
cur.execute(
86-
"""
87-
CREATE TABLE IF NOT EXISTS sync_history (
88-
id SERIAL PRIMARY KEY,
89-
source_site TEXT NOT NULL,
90-
sync_mode TEXT NOT NULL,
91-
last_sync_time TIMESTAMPTZ,
92-
items_processed INTEGER DEFAULT 0,
93-
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
94-
)
95-
"""
96-
)
97-
98-
cur.execute("CREATE INDEX IF NOT EXISTS idx_content_hash ON articles(content_hash)")
99-
cur.execute("CREATE INDEX IF NOT EXISTS idx_source_site ON articles(source_site)")
10083
cur.execute("CREATE INDEX IF NOT EXISTS idx_cluster_id ON articles(cluster_id)")
10184

102-
def _compute_content_hash(self, item: Dict) -> str:
103-
import hashlib
104-
full_content = item.get("full_content", item.get("description", ""))
105-
return hashlib.sha256(full_content.encode()).hexdigest()
106-
107-
def save_article(self, item: Dict, conn: Optional[psycopg.Connection] = None) -> bool:
108-
content_hash = self._compute_content_hash(item)
109-
if conn is None:
110-
with self.get_connection() as owned_conn:
111-
return self.save_article(item, owned_conn)
112-
cur = conn.cursor()
113-
cur.execute(
114-
"""
115-
INSERT INTO articles
116-
(id, source_site, title, description, full_content, content_hash, author_info, keywords, content_url, published_date, item_type, created_at)
117-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
118-
ON CONFLICT DO NOTHING
119-
""",
120-
(
121-
item["id"],
122-
item["source_site"],
123-
item["title"],
124-
item.get("description", ""),
125-
item.get("full_content", item.get("description", "")),
126-
content_hash,
127-
item.get("author_info", ""),
128-
item.get("keywords", ""),
129-
item["content_url"],
130-
item.get("published_date", datetime.now(UTC)),
131-
item.get("item_type", "article"),
132-
datetime.now(UTC),
133-
),
134-
)
135-
return cur.rowcount > 0
136-
137-
def article_exists(self, article_id: str) -> bool:
138-
with self.get_connection() as conn:
139-
cur = conn.cursor()
140-
cur.execute("SELECT 1 FROM articles WHERE id = %s", (article_id,))
141-
return cur.fetchone() is not None
142-
143-
def article_exists_by_hash(self, content_hash: str) -> bool:
85+
def assign_cluster_with_similarity(
86+
self,
87+
article_id: str,
88+
subject: str,
89+
orgs: List[str],
90+
event: str,
91+
):
14492
with self.get_connection() as conn:
14593
cur = conn.cursor()
146-
cur.execute("SELECT 1 FROM articles WHERE content_hash = %s", (content_hash,))
147-
return cur.fetchone() is not None
14894

149-
def save_embedding(self, article_id: str, embedding: np.ndarray, model: str = "default") -> bool:
150-
with self.get_connection() as conn:
151-
cur = conn.cursor()
15295
cur.execute(
15396
"""
154-
INSERT INTO embeddings (article_id, embedding, embedding_model)
155-
VALUES (%s, %s, %s)
156-
ON CONFLICT (article_id) DO UPDATE
157-
SET embedding = EXCLUDED.embedding,
158-
embedding_model = EXCLUDED.embedding_model,
159-
created_at = CURRENT_TIMESTAMP
97+
SELECT embedding FROM embeddings WHERE article_id = %s
16098
""",
161-
(article_id, embedding.tolist(), model),
99+
(article_id,)
162100
)
163-
return cur.rowcount > 0
101+
row = cur.fetchone()
102+
if not row:
103+
return
104+
105+
article_embedding = row["embedding"]
164106

165-
def assign_cluster_by_entities(self, article_id: str, subject: str, orgs: str, event: str):
166-
with self.get_connection() as conn:
167-
cur = conn.cursor()
168107
cur.execute(
169108
"""
170-
SELECT cluster_id FROM articles
171-
WHERE subject = %s AND organization_list = %s::jsonb AND event_type = %s
172-
AND cluster_id IS NOT NULL LIMIT 1
109+
SELECT a.cluster_id, a.subject, a.organization_list, a.event_type,
110+
1 - (e.embedding <=> %s) AS semantic_similarity
111+
FROM articles a
112+
JOIN embeddings e ON a.id = e.article_id
113+
WHERE a.cluster_id IS NOT NULL
114+
AND (a.subject = %s OR a.event_type = %s)
173115
""",
174-
(subject, orgs, event)
116+
(article_embedding, subject, event),
175117
)
176-
row = cur.fetchone()
177-
if row:
178-
cid = row['cluster_id']
179-
else:
180-
cur.execute("SELECT COALESCE(MAX(cluster_id), 0) + 1 as next_id FROM articles")
181-
cid = cur.fetchone()['next_id']
182-
118+
119+
best_cluster_id = None
120+
best_score = 0.0
121+
122+
for row in cur.fetchall():
123+
semantic_score = row["semantic_similarity"]
124+
125+
if semantic_score < SEMANTIC_SIMILARITY_THRESHOLD:
126+
continue
127+
128+
entity_score = compute_entity_similarity(
129+
{
130+
"subject": subject,
131+
"orgs": orgs,
132+
"event": event,
133+
},
134+
{
135+
"subject": row["subject"],
136+
"orgs": row["organization_list"] or [],
137+
"event": row["event_type"],
138+
},
139+
)
140+
141+
final_score = compute_final_score(semantic_score, entity_score)
142+
143+
if final_score > best_score:
144+
best_score = final_score
145+
best_cluster_id = row["cluster_id"]
146+
147+
if best_cluster_id is None or best_score < FINAL_SCORE_THRESHOLD:
148+
cur.execute("SELECT COALESCE(MAX(cluster_id), 0) + 1 AS next_id FROM articles")
149+
best_cluster_id = cur.fetchone()["next_id"]
150+
183151
cur.execute(
184152
"""
185-
UPDATE articles SET subject=%s, organization_list=%s::jsonb,
186-
event_type=%s, cluster_id=%s, updated_at=CURRENT_TIMESTAMP
187-
WHERE id=%s
153+
UPDATE articles
154+
SET subject = %s,
155+
organization_list = %s::jsonb,
156+
event_type = %s,
157+
cluster_id = %s,
158+
updated_at = CURRENT_TIMESTAMP
159+
WHERE id = %s
188160
""",
189-
(subject, orgs, event, cid, article_id)
161+
(
162+
subject,
163+
json.dumps(orgs),
164+
event,
165+
best_cluster_id,
166+
article_id,
167+
),
190168
)
191-
192-
def get_stats(self) -> Dict:
193-
with self.get_connection() as conn:
194-
cur = conn.cursor()
195-
cur.execute("SELECT COUNT(*) AS count FROM articles")
196-
total_articles = cur.fetchone()["count"]
197-
cur.execute("SELECT COUNT(*) AS count FROM embeddings")
198-
total_embeddings = cur.fetchone()["count"]
199-
cur.execute("SELECT COUNT(DISTINCT cluster_id) AS count FROM articles WHERE cluster_id IS NOT NULL")
200-
total_clusters = cur.fetchone()["count"]
201-
cur.execute("SELECT source_site, COUNT(*) as count FROM articles GROUP BY source_site")
202-
articles_by_source = {row["source_site"]: row["count"] for row in cur.fetchall()}
203-
return {
204-
"total_articles": total_articles,
205-
"total_embeddings": total_embeddings,
206-
"articles_by_source": articles_by_source,
207-
"articles_without_embeddings": total_articles - total_embeddings,
208-
"total_clusters": total_clusters,
209-
}
210-
211-
def record_sync(self, source: str, mode: str, items_processed: int = 0):
212-
with self.get_connection() as conn:
213-
cur = conn.cursor()
214-
cur.execute(
215-
"INSERT INTO sync_history (source_site, sync_mode, last_sync_time, items_processed) VALUES (%s, %s, %s, %s)",
216-
(source, mode, datetime.now(UTC), items_processed),
217-
)

server/entity_llm_processor.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import json
33
from typing import Dict, Any
4-
from openai import OpenAI, APIError
4+
from openai import OpenAI
55

66
logger = logging.getLogger(__name__)
77

@@ -15,19 +15,15 @@ def __init__(self, model: str = "gpt-4o-mini"):
1515
"JSON format. Focus on high-level subjects, involved organizations, "
1616
"and the type of event the article describes."
1717
)
18-
self.entity_schema = {
19-
"type": "object",
20-
"properties": {
21-
"subject": {"type": "string"},
22-
"organization_list": {"type": "array", "items": {"type": "string"}},
23-
"event_type": {"type": "string"},
24-
},
25-
"required": ["subject", "organization_list", "event_type"]
26-
}
2718

2819
def process(self, article: Dict, db_manager: Any) -> bool:
2920
article_id = article["id"]
30-
content = f"Title: {article.get('title', '')}\nDescription: {article.get('description', '')}\nContent: {article.get('full_content', '')[:500]}..."
21+
content = (
22+
f"Title: {article.get('title', '')}\n"
23+
f"Description: {article.get('description', '')}\n"
24+
f"Content: {article.get('full_content', '')[:500]}..."
25+
)
26+
3127
try:
3228
response = self.client.chat.completions.create(
3329
model=self.model,
@@ -37,14 +33,17 @@ def process(self, article: Dict, db_manager: Any) -> bool:
3733
],
3834
response_format={"type": "json_object"},
3935
)
36+
4037
data = json.loads(response.choices[0].message.content)
41-
db_manager.assign_cluster_by_entities(
42-
article_id,
43-
subject=data.get("subject"),
44-
orgs=json.dumps(data.get("organization_list", [])),
45-
event=data.get("event_type")
38+
39+
db_manager.assign_cluster_with_similarity(
40+
article_id=article_id,
41+
subject=data.get("subject", ""),
42+
orgs=data.get("organization_list", []),
43+
event=data.get("event_type", "")
4644
)
4745
return True
46+
4847
except Exception as e:
4948
logger.error(f"Error for {article_id}: {e}")
50-
return False
49+
return False

0 commit comments

Comments
 (0)