Skip to content

Commit cc06496

Browse files
committed
embedding v1
1 parent a93d88d commit cc06496

14 files changed

Lines changed: 927 additions & 71 deletions

‎README.md‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Brightwave-Style Crawler Project
1+
Crawler Project
22

33
This project implements a single-machine web crawler and keyword search engine with a live dashboard. It is designed to satisfy the assignment requirements around:
44

@@ -12,7 +12,7 @@ This project implements a single-machine web crawler and keyword search engine w
1212
The system has two major parts:
1313

1414
- **Crawler service**: creates and runs crawl jobs, supports pause/resume, tracks queue and worker state, and persists job state.
15-
- **Search service**: indexes crawled pages and answers queries with relevant URLs and metadata.
15+
- **Search service**: keeps classic keyword ranking and semantic vector ranking side by side, and answers queries with relevant URLs and metadata.
1616

1717
## What The System Does
1818

@@ -26,11 +26,12 @@ When you submit an indexing request (`origin`, `k`), the backend creates a crawl
2626

2727
### 2) Search
2828

29-
Search uses an in-memory inverted index built from page title and body text.
29+
Search exposes two modes over indexed pages: classic term search and semantic vector search.
3030

31-
- Tokenization is regex-based and case-insensitive.
31+
- Classic tokenization is regex-based and case-insensitive.
32+
- Semantic search uses `all-MiniLM-L6-v2` embeddings generated from crawled page snapshots.
3233
- Results include `relevant_url`, `origin_url`, and `depth` (plus score/title for UI visibility).
33-
- Search is available while crawlers are still running, so newly indexed pages appear as they are discovered.
34+
- Both search modes are available while crawlers are still running, so newly indexed pages appear as they are discovered.
3435
- `I'm Feeling Lucky` picks a **random** result from matches and opens it.
3536

3637
### 3) Runtime Controls and Visibility
@@ -41,8 +42,10 @@ The dashboard allows:
4142
- pausing and resuming existing jobs,
4243
- changing **global queue limit** across all jobs,
4344
- changing **per-job request rate** live from each job card,
45+
- starting, pausing, and resuming **embedding jobs** with configurable embed speed and max-page limit,
4446
- inspecting detailed job state (frontier preview, counters, recent events),
45-
- viewing global metrics (processed/discovered/duplicates/failed, queue pressure, workers).
47+
- viewing global metrics (processed/discovered/duplicates/failed, queue pressure, workers),
48+
- viewing embedding progress metrics (embedded/remaining/failed and `% embedded`).
4649

4750
## Architecture Summary
4851

@@ -52,7 +55,9 @@ The dashboard allows:
5255
- `pages` table for indexed page snapshots,
5356
- `jobs` table for job metadata and counters,
5457
- `job_visited` and `job_frontier` for resumable crawl state,
55-
- `job_events` for job timeline details.
58+
- `job_events` for job timeline details,
59+
- `page_embeddings` for semantic vectors,
60+
- `embedding_jobs` for resumable embedding job state.
5661

5762
## API Endpoints
5863

@@ -64,6 +69,9 @@ The dashboard allows:
6469
- `GET /search?query=...&limit=...`
6570
Returns relevant indexed URLs.
6671

72+
- `GET /search/semantic?query=...&limit=...`
73+
Returns semantic nearest-neighbor matches over embedded pages.
74+
6775
### Job Control and Details
6876

6977
- `GET /jobs/{job_id}`
@@ -86,6 +94,26 @@ The dashboard allows:
8694
- `POST /settings/queue-limit`
8795
Updates the **global** queue limit shared by all jobs.
8896

97+
### Embeddings
98+
99+
- `POST /embeddings/jobs/start`
100+
Starts a manual embedding job with configurable speed and max-page scope.
101+
102+
- `GET /embeddings/jobs`
103+
Lists embedding jobs and progress.
104+
105+
- `GET /embeddings/jobs/{job_id}`
106+
Returns full embedding job status and counters.
107+
108+
- `POST /embeddings/jobs/{job_id}/pause`
109+
Pauses a running embedding job.
110+
111+
- `POST /embeddings/jobs/{job_id}/resume`
112+
Resumes a paused embedding job.
113+
114+
- `POST /embeddings/jobs/{job_id}/rate-limit`
115+
Updates embedding speed (`pages/s`) for a job.
116+
89117
## Backpressure and Load Management
90118

91119
The crawler enforces load control using:
@@ -124,17 +152,18 @@ Open the printed Vite URL (usually `http://127.0.0.1:5173`).
124152
3. Start a second crawler to demonstrate concurrent jobs.
125153
4. Update global queue limit and per-job req/s live.
126154
5. Pause and resume one job.
127-
6. Run Search and `I'm Feeling Lucky`.
128-
7. Click a job card and inspect frontier preview + event timeline.
155+
6. Open Embeddings mode and start a manual embedding run with speed/limit.
156+
7. Run Search and compare Classical vs Semantic result tables.
157+
8. Click a crawler job card and inspect frontier preview + event timeline.
129158

130159
## Limitations (Current Scope)
131160

132-
- Search is term-frequency based (no semantic embeddings).
161+
- Semantic quality depends on currently stored page snippets and embedding coverage.
133162
- Single-process deployment (no horizontal scaling).
134163
- No robots.txt policy enforcement yet.
135164
- No hard per-domain crawl budget yet.
136165

137-
## Production Next Steps (1-2 Paragraphs)
166+
## Production Next Steps
138167

139168
To productionize this system, the first step is to separate responsibilities into independent services: crawl scheduler, crawl workers, indexing pipeline, and query API. The current asyncio loop can evolve into a worker template that pulls tasks from a durable queue and writes normalized page documents to persistent storage. The in-memory inverted index should be replaced with a dedicated search layer (for example BM25-capable engine) to improve ranking quality, scalability, and operational resilience.
140169

5.36 KB
Binary file not shown.
658 Bytes
Binary file not shown.
9.78 KB
Binary file not shown.

‎backend/app.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .crawler import crawler_service
1212
from .indexer import index_service
1313
from .models import CrawlJob, JobStatus, summarize_jobs
14+
from .semantic_index import semantic_index_service
1415
from .storage import init_db, load_job_events, load_jobs, load_pages
1516

1617

@@ -37,6 +38,29 @@ class SearchResponse(BaseModel):
3738
results: List[SearchResult]
3839

3940

41+
class EmbeddingStartRequest(BaseModel):
42+
rate_limit_per_sec: float = 1.0
43+
max_pages: int | None = None
44+
45+
46+
class EmbeddingRateLimitRequest(BaseModel):
47+
rate_limit_per_sec: float
48+
49+
50+
class EmbeddingStatusResponse(BaseModel):
51+
updated_at: str
52+
status: str
53+
model_name: str
54+
rate_limit_per_sec: float
55+
max_pages: int | None = None
56+
total_pages: int
57+
embedded_pages: int
58+
failed_pages: int
59+
remaining_pages: int
60+
progress_percent: float
61+
error_message: str | None = None
62+
63+
4064
class JobEventResponse(BaseModel):
4165
created_at: str
4266
level: str
@@ -122,6 +146,71 @@ async def search(query: str, limit: int | None = None) -> SearchResponse:
122146
return SearchResponse(results=results)
123147

124148

149+
@app.get("/search/semantic", response_model=SearchResponse)
150+
async def semantic_search(query: str, limit: int | None = None) -> SearchResponse:
151+
raw_results = await semantic_index_service.search(query, limit=limit)
152+
results = [
153+
SearchResult(
154+
relevant_url=url,
155+
origin_url=origin_url,
156+
depth=depth,
157+
score=score,
158+
title=title or None,
159+
)
160+
for url, origin_url, depth, score, title in raw_results
161+
]
162+
return SearchResponse(results=results)
163+
164+
165+
async def _serialize_embedding_status() -> EmbeddingStatusResponse:
166+
status = await semantic_index_service.get_engine_status()
167+
return EmbeddingStatusResponse(
168+
updated_at=status.updated_at.isoformat(),
169+
status=status.status,
170+
model_name=status.model_name,
171+
rate_limit_per_sec=status.rate_limit_per_sec,
172+
max_pages=status.max_pages,
173+
total_pages=status.total_pages,
174+
embedded_pages=status.embedded_pages,
175+
failed_pages=status.failed_pages,
176+
remaining_pages=status.remaining_pages,
177+
progress_percent=status.progress_percent,
178+
error_message=status.error_message,
179+
)
180+
181+
182+
@app.get("/embeddings/status", response_model=EmbeddingStatusResponse)
183+
async def embedding_status() -> EmbeddingStatusResponse:
184+
return await _serialize_embedding_status()
185+
186+
187+
@app.post("/embeddings/start", response_model=EmbeddingStatusResponse)
188+
async def start_embedding_engine(request: EmbeddingStartRequest) -> EmbeddingStatusResponse:
189+
if request.rate_limit_per_sec <= 0:
190+
raise HTTPException(status_code=400, detail="rate_limit_per_sec must be positive")
191+
if request.max_pages is not None and request.max_pages <= 0:
192+
raise HTTPException(status_code=400, detail="max_pages must be positive")
193+
await semantic_index_service.start_engine(
194+
rate_limit_per_sec=request.rate_limit_per_sec,
195+
max_pages=request.max_pages,
196+
)
197+
return await _serialize_embedding_status()
198+
199+
200+
@app.post("/embeddings/pause", response_model=EmbeddingStatusResponse)
201+
async def pause_embedding_engine() -> EmbeddingStatusResponse:
202+
await semantic_index_service.pause_engine()
203+
return await _serialize_embedding_status()
204+
205+
206+
@app.post("/embeddings/rate-limit", response_model=EmbeddingStatusResponse)
207+
async def update_embedding_engine_rate_limit(request: EmbeddingRateLimitRequest) -> EmbeddingStatusResponse:
208+
if request.rate_limit_per_sec <= 0:
209+
raise HTTPException(status_code=400, detail="rate_limit_per_sec must be positive")
210+
await semantic_index_service.update_rate_limit(request.rate_limit_per_sec)
211+
return await _serialize_embedding_status()
212+
213+
125214
def _serialize_job(job_id: str) -> JobDetailResponse:
126215
ctx = crawler_service.get_job_context(job_id)
127216
if ctx is None:
@@ -243,4 +332,5 @@ async def on_startup() -> None:
243332

244333
for job in load_jobs():
245334
crawler_service.register_job(job)
335+
await semantic_index_service.initialize()
246336

‎backend/models.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ class CrawlJob:
4141
stats: CrawlStats = field(default_factory=CrawlStats)
4242

4343

44+
@dataclass
45+
class EmbeddingJobRecord:
46+
id: str
47+
created_at: datetime
48+
updated_at: datetime
49+
status: JobStatus
50+
model_name: str
51+
rate_limit_per_sec: float = 1.0
52+
max_pages: int | None = None
53+
total_pages: int = 0
54+
embedded_pages: int = 0
55+
failed_pages: int = 0
56+
error_message: Optional[str] = None
57+
pending_urls_json: str = "[]"
58+
59+
4460
PageRecord = Tuple[str, str, int]
4561
IndexEntry = Tuple[str, str, int, float]
4662

0 commit comments

Comments
 (0)