Skip to content

Commit 99bc46e

Browse files
feat/ semantic search with redis
* fix: github workflow for semantic search probe * use for debug * fix: module import bug * fix: enhance semantic search deployment and timeout handling * fix: hardcode backend URL and add default semester to infra environment variables * use for debugging * fix: update Dockerfile and CI configuration for semantic search integration * fix: add context for semantic search image build in CI workflow * fix: make Dockerfile path conditional for semantic-search subdirectory context When using git URL context with subdirectory (:apps/semantic-search), the file path must be relative to that subdirectory, not repo root. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct kubectl label selector for semantic-search logs The label should be app.kubernetes.io/name=semantic-search, not the full deployment name Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: pre-download ML model in Docker build and increase startup timeout - Pre-download BAAI/bge-base-en-v1.5 model during Docker build so container doesn't need to download 420MB on every startup - Increase startupProbe to 10 minutes (from 5) for safety Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add retry logic for fetching courses and update environment variables in Docker Compose * feat: add 'building' status when building index so that we can monitor * fix: implement asynchronous index refresh and error tracking in semantic search * fix: correct backend port reference in semantic search configuration * fix: (1) update semantic search index refresh logic to exclude past terms and (2) make index save in disk -> not deleted by every deployment * ? * fix: restore semantic search after merge with main - Restore deleted semantic-search module files (client.ts, controller.ts, requirements.txt) - Re-add semantic search routes to express loader - Restore ClassBrowser AI search UI components - Update fuzzy-find imports to use @repo/common - Add semantic-search to typedef validation exclusions - Restore semantic search config in packages/common Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct imports in datapuller classes.ts - Change import from @repo/common to @repo/common/models - Add explicit type annotation for termsWithClasses.map Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * add semantic search label and variable to Helm chart * fix: resilient startup retry and PVC for all environments - Re-queue failed index builds with exponential backoff (up to 10 rounds) - Retry entire startup cycle when backend isn't ready yet - Enable PVC for dev environments so indexes persist across pod restarts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add SEMANTIC_SEARCH_URL to datapuller ConfigMap Datapuller needs this to call /refresh on the semantic search service after updating class data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused PVC storage value No longer needed since we use hostPath instead of PVC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: PR review * fix: search bar * fix: button arrangement * remove model pre-download from dockerfile to speed up deploys on k8s or local * fix: search bar arrangement * fix: pin to download cpu version of torch - reduce from 4.35GB -> 1GB * fix merge conflict * fix: merge conflict error * feat: refactor semantic serach to fit the new logic with pagination update from pine * fix: recover the class datapuller triggers the code rebuilding while merging before * fix: accidentially redundant file from merging * fix: adopt sasha's suggestion - only triggers rebuild index manually temporaily * Switch to uv and move torch installation to requirements.txt * Further optimizations for semantic search Dockerfile * Add uv compile and torch caching for semantic search image * Migrate semantic search to Redis * Make indexing a background task, return error response when the index isn't finished * Add REDIS_URI environment variable to semantic search chart * Add batching for semantic search embeddings and reduce query result size * Bug fixes, switch algorithm to HNSW and implement caching of embedding results --------- Co-authored-by: johngerving <gervingjohn@gmail.com>
1 parent 1c36612 commit 99bc46e

36 files changed

Lines changed: 1671 additions & 25 deletions

File tree

.env.template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ URL=http://localhost:5001
44
BACKEND_PATH=/api
55
GRAPHQL_PATH=/graphql
66
NODE_ENV=development
7+
SEMANTIC_SEARCH_URL=http://semantic-search:8000
78
MONGODB_URI=mongodb://mongodb:27017/bt?replicaSet=rs0
89
REDIS_URI=redis://redis:6379
910
BACKEND_URL=http://backend:8080

.github/workflows/cd-build.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
runs-on: ubuntu-latest
1919
strategy:
2020
matrix:
21-
image: [backend, frontend, datapuller, ag-frontend, staff-frontend]
21+
image: [backend, frontend, datapuller, semantic-search, ag-frontend, staff-frontend]
2222

2323
steps:
2424
- name: Login to Docker Hub
@@ -33,7 +33,8 @@ jobs:
3333
- name: Build and Push
3434
uses: docker/build-push-action@v6
3535
with:
36-
file: ./apps/${{ matrix.image }}/Dockerfile
36+
context: "https://github.com/${{ github.repository }}.git#${{ github.sha }}${{ matrix.image == 'semantic-search' && ':apps/semantic-search' || '' }}"
37+
file: ${{ matrix.image == 'semantic-search' && './Dockerfile' || format('./apps/{0}/Dockerfile', matrix.image) }}
3738
target: ${{ matrix.image }}-prod
3839
tags: ${{ secrets.DOCKER_USERNAME }}/bt-${{ matrix.image }}:${{ inputs.image_tag }}
3940
cache-from: |

.github/workflows/cd-deploy.yaml

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
host: ${{ secrets.SSH_HOST }}
4444
username: ${{ secrets.SSH_USERNAME }}
4545
key: ${{ secrets.SSH_KEY }}
46+
command_timeout: 15m
4647
script: |
4748
set -e # Exit immediately if a command fails
4849
@@ -60,6 +61,7 @@ jobs:
6061
if [ $status = true ]; then
6162
kubectl rollout restart deployment ${{ inputs.name }}-backend
6263
kubectl rollout restart deployment ${{ inputs.name }}-frontend
64+
kubectl rollout restart deployment ${{ inputs.name }}-semantic-search 2>/dev/null || true
6365
fi
6466
6567
# Deploy staff dashboard if requested
@@ -84,10 +86,34 @@ jobs:
8486
kubectl rollout status --timeout=300s deployment bt-staff-frontend
8587
fi
8688
87-
# Check container status
88-
kubectl rollout status --timeout=300s deployment ${{ inputs.name }}-backend
89+
# Check container status with debugging on failure
90+
if ! kubectl rollout status --timeout=300s deployment ${{ inputs.name }}-backend; then
91+
echo "=== Backend deployment failed, collecting debug info ==="
92+
kubectl -n bt get pods -l app.kubernetes.io/instance=${{ inputs.name }} -o wide
93+
echo "=== Pod descriptions ==="
94+
kubectl -n bt describe pods -l app.kubernetes.io/instance=${{ inputs.name }} | tail -200
95+
echo "=== Pod logs ==="
96+
kubectl -n bt logs -l app.kubernetes.io/instance=${{ inputs.name }} --tail=100 --all-containers=true 2>/dev/null || true
97+
exit 1
98+
fi
8999
kubectl rollout status --timeout=300s deployment ${{ inputs.name }}-frontend
90100
101+
# Semantic-search check (non-blocking, may take a while due to model loading)
102+
echo "=== Checking semantic-search status ==="
103+
kubectl -n bt get pods -l app.kubernetes.io/instance=${{ inputs.name }} | grep semantic || true
104+
105+
if kubectl rollout status --timeout=300s deployment ${{ inputs.name }}-semantic-search 2>/dev/null; then
106+
echo "Semantic-search deployed successfully"
107+
else
108+
echo "Semantic-search still starting or failed"
109+
fi
110+
111+
echo "=== Semantic-search logs ==="
112+
kubectl -n bt logs -l app.kubernetes.io/instance=${{ inputs.name }},app.kubernetes.io/name=semantic-search --tail=100 2>/dev/null || echo "No logs available yet"
113+
114+
echo "=== Semantic-search pod describe ==="
115+
kubectl -n bt describe pods -l app.kubernetes.io/instance=${{ inputs.name }},app.kubernetes.io/name=semantic-search 2>/dev/null | tail -50 || true
116+
91117
- name: Output Summary
92118
run: |
93119
echo "# :white_check_mark: Deployment available at [${{ inputs.host }}](https://${{ inputs.host }})." >> $GITHUB_STEP_SUMMARY

.github/workflows/cd-dev.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ jobs:
9393
backend:
9494
image:
9595
tag: '${{ needs.compute-sha.outputs.sha_short }}'
96+
semanticSearch:
97+
image:
98+
tag: '${{ needs.compute-sha.outputs.sha_short }}'
9699
datapuller:
97100
suspend: true
98101
image:

.github/workflows/cd-stage.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
backend:
3535
image:
3636
tag: latest
37+
semanticSearch:
38+
image:
39+
tag: latest
3740
datapuller:
3841
image:
3942
tag: latest

apps/backend/scripts/prepare-typedefs.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const typedefFiles = fs.readdirSync(sourceDir)
1111
.sort();
1212

1313
// Get all module directories from backend/src/modules (excluding non-module directories)
14-
const excludedDirs = ['cache', 'generated-types'];
14+
const excludedDirs = ['cache', 'generated-types', 'semantic-search'];
1515
const moduleDirs = fs.readdirSync(modulesDir, { withFileTypes: true })
1616
.filter(dirent => dirent.isDirectory() && !excludedDirs.includes(dirent.name))
1717
.map(dirent => dirent.name)

apps/backend/src/bootstrap/loaders/express.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RedisClientType } from "redis";
99
import { config } from "../../../../../packages/common/src/utils/config";
1010
import bannerRoutes from "../../modules/banner/routes";
1111
import routeRedirectRoutes from "../../modules/route-redirect/routes";
12+
import semanticSearchRoutes from "../../modules/semantic-search/routes";
1213
import staffRoutes from "../../modules/staff/routes";
1314
import targetedMessageRoutes from "../../modules/targeted-message/routes";
1415
import passportLoader from "./passport";
@@ -78,6 +79,9 @@ export default async (
7879
targetedMessageRoutes(root, redis);
7980
}
8081

82+
// load semantic search routes
83+
app.use("/semantic-search", semanticSearchRoutes);
84+
8185
// load staff routes
8286
staffRoutes(app);
8387

apps/backend/src/modules/catalog/controller.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "@repo/common/models";
2626

2727
import { getFields, hasFieldPath } from "../../utils/graphql";
28+
import { searchSemantic } from "../semantic-search/client";
2829
import { formatClass, formatSection } from "../class/formatter";
2930
import type { ClassModule } from "../class/generated-types/module-types";
3031
import { formatCourse } from "../course/formatter";
@@ -55,6 +56,7 @@ export interface CatalogQueryParams {
5556
sortOrder?: string | null;
5657
page?: number | null;
5758
pageSize?: number | null;
59+
semanticSearch?: boolean | null;
5860
}
5961

6062
type CatalogFilterCondition = Record<string, unknown>;
@@ -93,13 +95,26 @@ export const getCatalogSearch = async (params: CatalogQueryParams) => {
9395
sortOrder,
9496
page = 1,
9597
pageSize = 25,
98+
semanticSearch,
9699
} = params;
97100

98101
const effectivePage = Math.max(1, page ?? 1);
99102
const effectivePageSize = Math.min(100, Math.max(1, pageSize ?? 25));
100103
const skip = (effectivePage - 1) * effectivePageSize;
101104

102-
// If search is provided, use Atlas Search aggregation
105+
// Semantic search branch — calls Python FAISS service
106+
if (semanticSearch && search && search.trim().length > 0) {
107+
return getCatalogWithSemanticSearch({
108+
year,
109+
semester,
110+
searchTerm: search.trim(),
111+
filters,
112+
limit: effectivePageSize,
113+
skip,
114+
});
115+
}
116+
117+
// If search is provided, use in-memory Fuse.js index
103118
if (search && search.trim().length > 0) {
104119
return getCatalogWithSearch({
105120
year,
@@ -130,6 +145,59 @@ export const getCatalogSearch = async (params: CatalogQueryParams) => {
130145
return { results, totalCount };
131146
};
132147

148+
const getCatalogWithSemanticSearch = async ({
149+
year,
150+
semester,
151+
searchTerm,
152+
filters,
153+
limit,
154+
skip,
155+
}: {
156+
year: number;
157+
semester: string;
158+
searchTerm: string;
159+
filters: CatalogQueryParams["filters"];
160+
limit: number;
161+
skip: number;
162+
}) => {
163+
// Throws if the semantic service is unavailable — surfaces as GraphQL error
164+
const response = await searchSemantic(searchTerm, year, semester);
165+
166+
if (response.results.length === 0) {
167+
return { results: [], totalCount: 0 };
168+
}
169+
170+
// Throws if the semantic service is unavailable — surfaces as GraphQL error
171+
// Python already returns results sorted by score descending — preserve that order.
172+
// Build a rank map: "subject-courseNumber" → position in Python's ranked list
173+
const rankMap = new Map<string, number>();
174+
response.results.forEach(({ subject, courseNumber }, index) => {
175+
rankMap.set(`${subject}-${courseNumber}`, index);
176+
});
177+
178+
const query = buildFilterQuery(year, semester, filters);
179+
query.$or = response.results.map(({ subject, courseNumber }) => ({
180+
subject,
181+
courseNumber,
182+
})) as unknown as CatalogFilterCondition[];
183+
184+
// Fetch all matching docs — semantic result sets are small (bounded by threshold)
185+
const allResults = await CatalogClassModel.find(query).lean();
186+
187+
// Sort by Python's relevance rank, then by section number within the same course
188+
allResults.sort((a, b) => {
189+
const rankA = rankMap.get(`${a.subject}-${a.courseNumber}`) ?? 999;
190+
const rankB = rankMap.get(`${b.subject}-${b.courseNumber}`) ?? 999;
191+
if (rankA !== rankB) return rankA - rankB;
192+
return a.number.localeCompare(b.number);
193+
});
194+
195+
const totalCount = allResults.length;
196+
const results = allResults.slice(skip, skip + limit);
197+
198+
return { results, totalCount };
199+
};
200+
133201
const getCatalogWithSearch = async ({
134202
year,
135203
semester,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { config } from "../../../../../packages/common/src/utils/config";
2+
3+
interface SemanticSearchResult {
4+
subject: string;
5+
courseNumber: string;
6+
title: string;
7+
description: string;
8+
score: number;
9+
text: string;
10+
}
11+
12+
interface SemanticSearchResponse {
13+
query: string;
14+
threshold: number;
15+
count: number;
16+
year: number;
17+
semester: string;
18+
allowed_subjects: string[] | null;
19+
last_refreshed: string;
20+
results: SemanticSearchResult[];
21+
}
22+
23+
export async function searchSemantic(
24+
query: string,
25+
year: number,
26+
semester: string,
27+
allowedSubjects?: string[],
28+
threshold: number = 0.3
29+
): Promise<SemanticSearchResponse> {
30+
const url = `${config.semanticSearch.url}/search`;
31+
const body = {
32+
query,
33+
threshold,
34+
year,
35+
semester,
36+
allowed_subjects: allowedSubjects ?? null,
37+
};
38+
39+
const response = await fetch(url, {
40+
method: "POST",
41+
headers: { "Content-Type": "application/json" },
42+
body: JSON.stringify(body),
43+
});
44+
45+
if (!response.ok) {
46+
let detail: string | undefined;
47+
try {
48+
const body = await response.json();
49+
detail = body?.detail ?? body?.error;
50+
} catch {}
51+
throw new Error(detail ?? `Semantic search service error: ${response.statusText}`);
52+
}
53+
54+
return (await response.json()) as SemanticSearchResponse;
55+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Request, Response } from "express";
2+
3+
import { searchSemantic } from "./client";
4+
5+
/**
6+
* Lightweight semantic search endpoint that only returns course identifiers
7+
* Frontend will use these to filter the already-loaded catalog
8+
*/
9+
export async function searchCourses(req: Request, res: Response) {
10+
const { query, year, semester, threshold } = req.query;
11+
12+
if (!query || typeof query !== "string") {
13+
return res.status(400).json({ error: "query parameter is required" });
14+
}
15+
16+
const yearNum = year ? parseInt(year as string, 10) : undefined;
17+
const semesterStr = semester as string | undefined;
18+
const thresholdNum = threshold ? parseFloat(threshold as string) : 0.3;
19+
20+
try {
21+
const results = await searchSemantic(
22+
query,
23+
yearNum!,
24+
semesterStr!,
25+
undefined,
26+
thresholdNum
27+
);
28+
29+
// Return lightweight response: only subject + courseNumber + score
30+
const courseIds = results.results.map((r) => ({
31+
subject: r.subject,
32+
courseNumber: r.courseNumber,
33+
score: r.score,
34+
}));
35+
36+
return res.json({
37+
query,
38+
threshold: thresholdNum,
39+
results: courseIds,
40+
count: courseIds.length,
41+
});
42+
} catch (error) {
43+
console.error("Semantic search error:", error);
44+
return res.status(500).json({
45+
error: "Semantic search failed",
46+
results: [],
47+
count: 0,
48+
});
49+
}
50+
}

0 commit comments

Comments
 (0)