bat-6's picture
feat: implement semantic search functionality and engine for project similarity analysis
eecb5f7
Raw
History Blame Contribute Delete
8.1 kB
import re
import ast
import logging
from pathlib import Path
from functools import lru_cache
import numpy as np
import pandas as pd
import faiss
from sentence_transformers import SentenceTransformer
from Data.database.sql_connector import (
load_preprocessed_projects
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s"
)
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "all-mpnet-base-v2"
TITLE_COL = "project_title"
TECH_COL = "technologies"
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
INDEX_PATH = _PROJECT_ROOT / "models" / "faiss_index.bin"
META_PATH = _PROJECT_ROOT / "models" / "metadata.parquet"
EMBED_PATH = _PROJECT_ROOT / "models" / "project_embeddings.npy"
TOP_K = 20
MIN_SCORE = 0.35
def normalize_text(text: str) -> str:
"""
Normalize user query to match preprocessing style.
"""
if pd.isna(text):
return ""
text = str(text).strip().lower()
text = re.sub(r"http\S+|www\S+|\S+@\S+", " ", text)
text = re.sub(r"[^a-z0-9\s\+\#\./\-]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def tokenize(text: str) -> set:
"""
Tokenize normalized text.
"""
return set(normalize_text(text).split())
import os
_cached_faiss_index = None
_cached_faiss_index_mtime = None
_cached_metadata = None
_cached_metadata_mtime = None
_cached_embeddings = None
_cached_embeddings_mtime = None
@lru_cache(maxsize=1)
def load_model():
logger.info(f"Loading model: {DEFAULT_MODEL}")
return SentenceTransformer(DEFAULT_MODEL)
def load_faiss_index():
global _cached_faiss_index, _cached_faiss_index_mtime
if not INDEX_PATH.exists():
raise FileNotFoundError("FAISS index not found.")
mtime = os.path.getmtime(INDEX_PATH)
if _cached_faiss_index is None or _cached_faiss_index_mtime != mtime:
logger.info(f"Loading FAISS index from {INDEX_PATH} (mtime: {mtime})...")
_cached_faiss_index = faiss.read_index(str(INDEX_PATH))
_cached_faiss_index_mtime = mtime
return _cached_faiss_index
def load_metadata():
global _cached_metadata, _cached_metadata_mtime
if not INDEX_PATH.exists():
raise FileNotFoundError("FAISS index not found for metadata alignment.")
mtime = os.path.getmtime(INDEX_PATH)
if _cached_metadata is None or _cached_metadata_mtime != mtime:
if META_PATH.exists():
logger.info(f"Loading metadata from local parquet {META_PATH} (syncing with FAISS index mtime: {mtime})...")
try:
df = pd.read_parquet(str(META_PATH))
if "features" in df.columns:
import json
def parse_features(x):
if not isinstance(x, str):
return x
try:
x = json.loads(x)
if isinstance(x, str):
x = json.loads(x)
return x
except Exception:
return []
df["features"] = df["features"].apply(parse_features)
_cached_metadata = df.reset_index(drop=True)
except Exception as e:
logger.warning(f"Failed to read local metadata.parquet: {e}. Falling back to database query...")
df = load_preprocessed_projects()
_cached_metadata = df.reset_index(drop=True)
else:
logger.info(f"Loading metadata from Azure SQL (syncing with FAISS index mtime: {mtime})...")
df = load_preprocessed_projects()
_cached_metadata = df.reset_index(drop=True)
_cached_metadata_mtime = mtime
return _cached_metadata
def load_embeddings():
global _cached_embeddings, _cached_embeddings_mtime
if not EMBED_PATH.exists():
raise FileNotFoundError("Embeddings not found.")
mtime = os.path.getmtime(EMBED_PATH)
if _cached_embeddings is None or _cached_embeddings_mtime != mtime:
logger.info(f"Loading embeddings from {EMBED_PATH} (mtime: {mtime})...")
_cached_embeddings = np.load(str(EMBED_PATH))
_cached_embeddings_mtime = mtime
return _cached_embeddings
def build_results(
df: pd.DataFrame,
ids,
scores,
query_text: str = "",
min_score: float = MIN_SCORE
) -> pd.DataFrame:
rows = []
query_words = tokenize(query_text)
for idx, score in zip(ids, scores):
if idx == -1:
continue
row = df.loc[idx]
final_score = float(score)
if query_words:
title_words = tokenize(row[TITLE_COL])
tech_words = tokenize(
row.get(TECH_COL, "")
)
overlap = len(query_words & title_words)
overlap += len(query_words & tech_words)
if overlap > 0:
final_score += 0.02 * overlap
final_score = min(final_score, 1.0)
if final_score < min_score:
continue
rows.append({
"project_id": int(idx),
"project_title": row[TITLE_COL],
"technologies": row.get(TECH_COL, ""),
"score": round(final_score, 4)
})
if not rows:
return pd.DataFrame([{
"message": "No similar projects found.",
"score": 0
}])
return (
pd.DataFrame(rows)
.sort_values("score", ascending=False)
.reset_index(drop=True)
)
def search_by_text(
query_text: str,
k: int = TOP_K,
min_score: float = MIN_SCORE
) -> pd.DataFrame:
model = load_model()
index = load_faiss_index()
df = load_metadata()
query_clean = normalize_text(query_text)
query_vec = model.encode(
[query_clean],
convert_to_numpy=True,
normalize_embeddings=True
).astype("float32")
scores, ids = index.search(query_vec, k)
return build_results(
df=df,
ids=ids[0],
scores=scores[0],
query_text=query_clean,
min_score=min_score
)
def search_by_project_id(
project_id: int,
k: int = TOP_K,
min_score: float = MIN_SCORE,
exclude_self: bool = True
) -> pd.DataFrame:
df = load_metadata()
index = load_faiss_index()
embeddings = load_embeddings()
if project_id < 0 or project_id >= len(df):
raise IndexError("Project ID out of range.")
query_vec = embeddings[
project_id
].reshape(1, -1).astype("float32")
extra_k = k + 1 if exclude_self else k
scores, ids = index.search(
query_vec,
extra_k
)
rows = []
for idx, score in zip(ids[0], scores[0]):
if idx == -1:
continue
if exclude_self and idx == project_id:
continue
final_score = min(float(score), 1.0)
if final_score < min_score:
continue
row = df.loc[idx]
rows.append({
"project_id": int(idx),
"project_title": row[TITLE_COL],
"technologies": row.get(TECH_COL, ""),
"score": round(final_score, 4)
})
if len(rows) == k:
break
if not rows:
return pd.DataFrame([{
"message": "No similar projects found.",
"score": 0
}])
return pd.DataFrame(rows)
def compare_two_ideas(
text_a: str,
text_b: str
) -> float:
model = load_model()
vecs = model.encode(
[
normalize_text(text_a),
normalize_text(text_b)
],
convert_to_numpy=True,
normalize_embeddings=True
).astype("float32")
score = float(np.dot(vecs[0], vecs[1]))
return round(score, 4)