Skip to content

Commit 588ae9a

Browse files
committed
Add semantic search support to server store
1 parent 748845f commit 588ae9a

9 files changed

Lines changed: 1090 additions & 766 deletions

File tree

server/backend/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ dependencies = [
1616
"cq-sdk~=0.6.1",
1717
]
1818

19+
[project.optional-dependencies]
20+
semsearch = [
21+
"sqlite_vec",
22+
"numpy",
23+
"httpx",
24+
]
25+
1926
[project.scripts]
2027
cq-server = "cq_server.app:main"
2128

server/backend/src/cq_server/app.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,19 @@ def health() -> dict[str, str]:
8989

9090

9191
@api_router.get("/query")
92-
def query_units(
92+
async def query_units(
9393
domains: Annotated[list[str], Query()],
9494
languages: Annotated[list[str] | None, Query()] = None,
9595
frameworks: Annotated[list[str] | None, Query()] = None,
9696
limit: Annotated[int, Query(gt=0)] = 5,
9797
) -> list[KnowledgeUnit]:
9898
"""Search knowledge units by domain tags with relevance ranking."""
9999
store = _get_store()
100-
return store.query(domains, languages=languages, frameworks=frameworks, limit=limit)
100+
return await store.query(domains, languages=languages, frameworks=frameworks, limit=limit)
101101

102102

103103
@api_router.post("/propose", status_code=201)
104-
def propose_unit(request: ProposeRequest) -> KnowledgeUnit:
104+
async def propose_unit(request: ProposeRequest) -> KnowledgeUnit:
105105
"""Submit a new knowledge unit."""
106106
store = _get_store()
107107
normalized = normalize_domains(request.domains)
@@ -114,42 +114,42 @@ def propose_unit(request: ProposeRequest) -> KnowledgeUnit:
114114
tier=Tier.PRIVATE,
115115
created_by=request.created_by,
116116
)
117-
store.insert(unit)
117+
await store.insert(unit)
118118
return unit
119119

120120

121121
@api_router.post("/confirm/{unit_id}")
122-
def confirm_unit(unit_id: str) -> KnowledgeUnit:
122+
async def confirm_unit(unit_id: str) -> KnowledgeUnit:
123123
"""Confirm a knowledge unit, boosting its confidence."""
124124
store = _get_store()
125-
unit = store.get(unit_id)
125+
unit = await store.get(unit_id)
126126
if unit is None:
127127
raise HTTPException(status_code=404, detail="Knowledge unit not found")
128128
confirmed = apply_confirmation(unit)
129-
store.update(confirmed)
129+
await store.update(confirmed)
130130
return confirmed
131131

132132

133133
@api_router.post("/flag/{unit_id}")
134-
def flag_unit(unit_id: str, request: FlagRequest) -> KnowledgeUnit:
134+
async def flag_unit(unit_id: str, request: FlagRequest) -> KnowledgeUnit:
135135
"""Flag a knowledge unit, reducing its confidence."""
136136
store = _get_store()
137-
unit = store.get(unit_id)
137+
unit = await store.get(unit_id)
138138
if unit is None:
139139
raise HTTPException(status_code=404, detail="Knowledge unit not found")
140140
flagged = apply_flag(unit, request.reason)
141-
store.update(flagged)
141+
await store.update(flagged)
142142
return flagged
143143

144144

145145
@api_router.get("/stats")
146-
def stats() -> StatsResponse:
146+
async def stats() -> StatsResponse:
147147
"""Return store statistics."""
148148
store = _get_store()
149149
return StatsResponse(
150-
total_units=store.count(),
151-
tiers=store.counts_by_tier(),
152-
domains=store.domain_counts(),
150+
total_units=await store.count(),
151+
tiers=await store.counts_by_tier(),
152+
domains=await store.domain_counts(),
153153
)
154154

155155

server/backend/src/cq_server/auth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def get_current_user(request: Request) -> str:
103103

104104

105105
@router.post("/login")
106-
def login(request: LoginRequest, store: RemoteStore = Depends(get_store)) -> LoginResponse:
106+
async def login(request: LoginRequest, store: RemoteStore = Depends(get_store)) -> LoginResponse:
107107
"""Authenticate a user and return a JWT token.
108108
109109
Args:
@@ -116,15 +116,15 @@ def login(request: LoginRequest, store: RemoteStore = Depends(get_store)) -> Log
116116
Raises:
117117
HTTPException: With status 401 if credentials are invalid.
118118
"""
119-
user = store.get_user(request.username)
119+
user = await store.get_user(request.username)
120120
if user is None or not verify_password(request.password, user["password_hash"]):
121121
raise HTTPException(status_code=401, detail="Invalid username or password")
122122
token = create_token(request.username, secret=_get_jwt_secret())
123123
return LoginResponse(token=token, username=request.username)
124124

125125

126126
@router.get("/me")
127-
def me(username: str = Depends(get_current_user), store: RemoteStore = Depends(get_store)) -> MeResponse:
127+
async def me(username: str = Depends(get_current_user), store: RemoteStore = Depends(get_store)) -> MeResponse:
128128
"""Return the current user's info.
129129
130130
Args:
@@ -137,7 +137,7 @@ def me(username: str = Depends(get_current_user), store: RemoteStore = Depends(g
137137
Raises:
138138
HTTPException: With status 404 if the user no longer exists.
139139
"""
140-
user = store.get_user(username)
140+
user = await store.get_user(username)
141141
if user is None:
142142
raise HTTPException(status_code=404, detail="User not found")
143143
return MeResponse(username=user["username"], created_at=user["created_at"])

server/backend/src/cq_server/review.py

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def _build_decision(unit_id: str, row: dict[str, str | None]) -> ReviewDecisionR
8585

8686

8787
@router.get("/queue")
88-
def review_queue(
88+
async def review_queue(
8989
limit: int = 20,
9090
offset: int = 0,
9191
_user: str = Depends(get_current_user),
@@ -102,8 +102,8 @@ def review_queue(
102102
Returns:
103103
A paginated list of pending knowledge units with review metadata.
104104
"""
105-
items = store.pending_queue(limit=limit, offset=offset)
106-
total = store.pending_count()
105+
items = await store.pending_queue(limit=limit, offset=offset)
106+
total = await store.pending_count()
107107
return ReviewQueueResponse(
108108
items=[
109109
ReviewItem(
@@ -121,7 +121,7 @@ def review_queue(
121121

122122

123123
@router.post("/{unit_id}/approve")
124-
def approve_unit(
124+
async def approve_unit(
125125
unit_id: str,
126126
username: str = Depends(get_current_user),
127127
store: RemoteStore = Depends(get_store),
@@ -140,19 +140,19 @@ def approve_unit(
140140
HTTPException: With status 404 if the unit does not exist.
141141
HTTPException: With status 409 if the unit has already been reviewed.
142142
"""
143-
status = store.get_review_status(unit_id)
143+
status = await store.get_review_status(unit_id)
144144
if status is None:
145145
raise HTTPException(status_code=404, detail="Knowledge unit not found")
146146
if status["status"] != "pending":
147147
raise HTTPException(status_code=409, detail=f"Knowledge unit already {status['status']}")
148-
store.set_review_status(unit_id, "approved", username)
149-
updated = store.get_review_status(unit_id)
148+
await store.set_review_status(unit_id, "approved", username)
149+
updated = await store.get_review_status(unit_id)
150150
assert updated is not None # Unit exists; we just wrote to it.
151151
return _build_decision(unit_id, updated)
152152

153153

154154
@router.post("/{unit_id}/reject")
155-
def reject_unit(
155+
async def reject_unit(
156156
unit_id: str,
157157
username: str = Depends(get_current_user),
158158
store: RemoteStore = Depends(get_store),
@@ -171,19 +171,19 @@ def reject_unit(
171171
HTTPException: With status 404 if the unit does not exist.
172172
HTTPException: With status 409 if the unit has already been reviewed.
173173
"""
174-
status = store.get_review_status(unit_id)
174+
status = await store.get_review_status(unit_id)
175175
if status is None:
176176
raise HTTPException(status_code=404, detail="Knowledge unit not found")
177177
if status["status"] != "pending":
178178
raise HTTPException(status_code=409, detail=f"Knowledge unit already {status['status']}")
179-
store.set_review_status(unit_id, "rejected", username)
180-
updated = store.get_review_status(unit_id)
179+
await store.set_review_status(unit_id, "rejected", username)
180+
updated = await store.get_review_status(unit_id)
181181
assert updated is not None # Unit exists; we just wrote to it.
182182
return _build_decision(unit_id, updated)
183183

184184

185185
@router.get("/stats")
186-
def review_stats(
186+
async def review_stats(
187187
_user: str = Depends(get_current_user),
188188
store: RemoteStore = Depends(get_store),
189189
) -> ReviewStatsResponse:
@@ -197,24 +197,24 @@ def review_stats(
197197
Aggregated counts by status, domain distribution, confidence
198198
distribution, recent activity, and daily trend data.
199199
"""
200-
counts = store.counts_by_status()
200+
counts = await store.counts_by_status()
201201
return ReviewStatsResponse(
202202
counts={
203203
"pending": counts.get("pending", 0),
204204
"approved": counts.get("approved", 0),
205205
"rejected": counts.get("rejected", 0),
206206
},
207-
domains=store.domain_counts(),
208-
confidence_distribution=store.confidence_distribution(),
209-
recent_activity=store.recent_activity(),
207+
domains=await store.domain_counts(),
208+
confidence_distribution=await store.confidence_distribution(),
209+
recent_activity=await store.recent_activity(),
210210
trends=TrendsResponse(
211-
daily=[DailyCount(**d) for d in store.daily_counts()],
211+
daily=[DailyCount(**d) for d in await store.daily_counts()],
212212
),
213213
)
214214

215215

216216
@router.get("/units")
217-
def list_units(
217+
async def list_units(
218218
domain: str | None = None,
219219
confidence_min: float | None = None,
220220
confidence_max: float | None = None,
@@ -238,7 +238,7 @@ def list_units(
238238
Returns:
239239
List of knowledge units with review metadata.
240240
"""
241-
items = store.list_units(
241+
items = await store.list_units(
242242
domain=domain,
243243
confidence_min=confidence_min,
244244
confidence_max=confidence_max,
@@ -257,7 +257,7 @@ def list_units(
257257

258258

259259
@router.get("/{unit_id}")
260-
def get_unit(
260+
async def get_unit(
261261
unit_id: str,
262262
_user: str = Depends(get_current_user),
263263
store: RemoteStore = Depends(get_store),
@@ -275,10 +275,10 @@ def get_unit(
275275
Raises:
276276
HTTPException: With status 404 if the unit does not exist.
277277
"""
278-
ku = store.get_any(unit_id)
278+
ku = await store.get_any(unit_id)
279279
if ku is None:
280280
raise HTTPException(status_code=404, detail="Knowledge unit not found")
281-
review = store.get_review_status(unit_id)
281+
review = await store.get_review_status(unit_id)
282282
assert review is not None # Unit exists; get_any just returned it.
283283
return ReviewItem(
284284
knowledge_unit=ku,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import logging
2+
import os
3+
import sqlite3
4+
import numpy as np
5+
6+
from cq.models import KnowledgeUnit
7+
8+
logger = logging.getLogger(__name__)
9+
10+
_ENABLED = False
11+
_DIM = int(os.environ.get("SEMSEARCH_EMBEDDING_DIM", 768))
12+
13+
14+
_TOKEN_EMBEDDING_URL = os.environ.get("TOKEN_EMBEDDING_URL", "http://localhost:8080")
15+
if _TOKEN_EMBEDDING_URL:
16+
try:
17+
import sqlite_vec
18+
from httpx import AsyncClient
19+
_ENABLED = True
20+
21+
logger.info(f"Token embedding enabled using encoderfile endpoint at {_TOKEN_EMBEDDING_URL}")
22+
except ImportError:
23+
logger.warning(
24+
"TOKEN_EMBEDDING_URL is set but required packages are not installed; "
25+
"semantic search will be unavailable. To enable, install cq with "
26+
"the 'embedding' extra: pip install cq-sdk[embedding]",
27+
exc_info=True,
28+
)
29+
30+
31+
_VEC_SCHEMA_SQL = """
32+
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_units_vec
33+
USING vec0(
34+
id TEXT PRIMARY KEY,
35+
embedding float[{dim}]
36+
);
37+
"""
38+
39+
_VEC_SEARCH_SQL = """
40+
SELECT ku.data
41+
FROM knowledge_units_vec vec
42+
JOIN knowledge_units ku ON ku.id = vec.id
43+
WHERE ku.status = 'approved'
44+
AND vec.embedding MATCH ?
45+
AND k = ?
46+
ORDER BY vec.distance
47+
"""
48+
49+
_VEC_DELETE_SQL = "DELETE FROM knowledge_units_vec WHERE id = ?"
50+
_VEC_INSERT_SQL = "INSERT INTO knowledge_units_vec (id, embedding) VALUES (?, ?)"
51+
52+
53+
async def _get_embeddings(wordlist: list[str]) -> list[np.array]:
54+
"""Get embeddings for a list of words using the embedding API."""
55+
if not _ENABLED:
56+
raise RuntimeError("Semantic search is not enabled. Set TOKEN_EMBEDDING_URL and install required packages to enable.")
57+
async with AsyncClient(base_url=_TOKEN_EMBEDDING_URL) as client:
58+
request_data = {"inputs": wordlist}
59+
response = await client.post("/predict", json=request_data)
60+
response.raise_for_status()
61+
results = response.json().get("results")
62+
if not results:
63+
raise RuntimeError(f"Embedding API returned no embeddings for input: {request_data}")
64+
return [np.average(np.array([embedding.get("embedding") for embedding in embeddings["embeddings"]]), axis=0) for embeddings in results]
65+
66+
67+
def is_enabled() -> bool:
68+
"""Return whether semantic search dependencies are available."""
69+
return _ENABLED
70+
71+
72+
def _serialize_embedding(vec: np.ndarray) -> bytes:
73+
arr = np.asarray(vec, dtype=np.float32)
74+
if hasattr(sqlite_vec, "serialize_float32"):
75+
return sqlite_vec.serialize_float32(arr)
76+
return arr.tobytes()
77+
78+
def load(conn: sqlite3.Connection) -> None:
79+
if not _ENABLED:
80+
return
81+
conn.enable_load_extension(True)
82+
sqlite_vec.load(conn)
83+
conn.enable_load_extension(False)
84+
85+
86+
def ensure_schema(conn: sqlite3.Connection) -> None:
87+
"""Create semantic search virtual table if embedding is enabled."""
88+
if not _ENABLED:
89+
return
90+
conn.executescript(_VEC_SCHEMA_SQL.format(dim=_DIM))
91+
92+
93+
async def upsert_unit(conn: sqlite3.Connection, unit: KnowledgeUnit) -> None:
94+
"""Insert or update a knowledge unit embedding row."""
95+
if not _ENABLED:
96+
return
97+
text = " ".join([unit.insight.summary, unit.insight.detail, unit.insight.action]).strip()
98+
if not text:
99+
return
100+
embedding = (await _get_embeddings([text]))[0]
101+
serialized = _serialize_embedding(embedding)
102+
conn.execute(_VEC_DELETE_SQL, (unit.id,))
103+
conn.execute(_VEC_INSERT_SQL, (unit.id, serialized))
104+
105+
106+
async def query(conn: sqlite3.Connection, domains: list[str], *, limit: int = 5) -> list[KnowledgeUnit]:
107+
"""Semantic search implementation."""
108+
if not _ENABLED:
109+
return []
110+
if not domains:
111+
return []
112+
if limit <= 0:
113+
return []
114+
vec_emb_search = await _get_embeddings(domains)
115+
search_embedding = _serialize_embedding(vec_emb_search[0])
116+
try:
117+
vec_rows = conn.execute(_VEC_SEARCH_SQL, (search_embedding, limit)).fetchall()
118+
except sqlite3.OperationalError:
119+
vec_rows = []
120+
return [KnowledgeUnit.model_validate_json(row[0]) for row in vec_rows]

0 commit comments

Comments
 (0)