AI/AI semantic search
Source
This example runs from source. Open the repository to clone, build, and run it locally.
Open on GitHubAI
AI semantic search
Search Gantt tasks by meaning instead of keywords, using locally generated embeddings from an Ollama model matched against precomputed task vectors.
- README.md
- docker-compose.yml
- index.html
- main.js
- styles.css
- main.py
- requirements.txt
README.md View on GitHub
# DHTMLX Gantt - Semantic Search Demo
AI-powered semantic search inside a [DHTMLX Gantt](https://dhtmlx.com/docs/products/dhtmlxGantt/) chart. Type natural-language queries and find relevant tasks by meaning, not just keywords.
For a step-by-step implementation guide, see the [Semantic Search tutorial](https://docs.dhtmlx.com/gantt/integrations/ai-tools/semantic-search/) in the DHTMLX Gantt documentation.
## Quick Start
```bash
docker compose up --build
```
Then open **http://localhost:11436** in your browser.
On first launch, the Ollama container downloads the embedding model and warms it up before semantic search becomes available. This can take a little while depending on your machine and network connection.
The sample uses Ollama as a local embedding runtime for simplicity and reproducibility.
## How It Works
1. The Gantt chart loads about 60 tasks from the backend.
2. When you search, the backend generates an embedding for your query using a local model (`all-minilm` via [Ollama](https://ollama.com/)).
3. The backend compares the query embedding with stored task embeddings and returns matches sorted by relevance score. Embeddings for the initial dataset are precomputed, while new or updated tasks are embedded during runtime.
4. Matching tasks are highlighted in the Gantt chart and non-matches are dimmed.
All processing runs locally. No external AI API is called while you use the demo.
## Runtime Notes
- CRUD changes are stored in memory only and are reset when the backend container restarts.
- Semantic search may be temporarily unavailable during initial model download or model warm-up.
- To test with a different embedding model, change `MODEL_NAME` in `backend/main.py`, restart the backend, and call `GET /api/embeddings/recalculate` to regenerate all task embeddings.
## Example Queries
- `"authentication and security"` - finds tasks about auth modules, security assessment, and social login
- `"deployment pipeline"` - finds CI/CD configuration, production setup, and monitoring tasks
- `"user interface"` - finds UI prototyping, responsive design, and onboarding flow tasks
- `"database and storage"` - finds schema design, ORM setup, and data migration tasks
## Architecture
```text
Browser (port 11436)
-> nginx (reverse proxy)
-> / -> static frontend (index.html)
-> /api/* -> FastAPI backend (port 11435)
-> /api/embeddings/* -> FastAPI backend (port 11435)
-> /search -> FastAPI backend (port 11435)
-> Ollama (port 11434)
```
| Service | Port | Description |
|----------|-------|-------------|
| Frontend | 11436 | nginx serving the Gantt UI |
| Backend | 11435 | FastAPI - CRUD plus semantic search |
| Ollama | 11434 | Local embedding runtime used by the demo |
## Project Structure
```text
frontend/
index.html # HTML shell and search toolbar
main.js # Gantt init, search logic, DataProcessor
styles.css # Dark theme, search highlight styling
nginx.conf # Reverse proxy config
Dockerfile
backend/
main.py # FastAPI server
data.json # Task data plus precomputed embeddings
requirements.txt
Dockerfile
ollama/
init.sh # Model pull script
Dockerfile
docker-compose.yml
```
## Requirements
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
## License
Source code in this repo is released under the **MIT License**.
**DHTMLX Gantt** is a commercial library - use under a valid [DHTMLX license](https://dhtmlx.com/docs/products/licenses.shtml) or evaluation agreement.
## Useful links
- [Semantic Search Tutorial](https://docs.dhtmlx.com/gantt/integrations/ai-tools/semantic-search/) - step-by-step implementation guide
- [DHTMLX Gantt Product Page](https://dhtmlx.com/docs/products/dhtmlxGantt/)
- [DHTMLX Gantt Documentation](https://docs.dhtmlx.com/gantt/)
- [Ollama](https://ollama.com/) - local embedding runtime used by this demo
- [all-minilm Model](https://ollama.com/library/all-minilm) - embedding model used for semantic search
- [DHTMLX Technical Support Forum](https://forum.dhtmlx.com/)
docker-compose.yml View on GitHub
services:
ollama:
build: ./ollama
container_name: ollama_server
ports:
- "11434:11434"
volumes:
- ollama:/root/.ollama
backend:
build: ./backend
container_name: backend_server
ports:
- "11435:11435"
environment:
- OLLAMA_HOST=http://ollama:11434
depends_on:
- ollama
command:
- python
- main.py
frontend:
build: ./frontend
container_name: frontend_server
ports:
- "11436:80"
depends_on:
- backend
volumes:
ollama:
frontend/index.html View on GitHub
<html>
<head>
<title>DHTMLX Gantt Semantic Search</title>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://docs.dhtmlx.com/gantt/codebase/dhtmlxgantt.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="toolbar">
<h1 id="toolbar_title">DHTMLX Gantt Semantic Search</h1>
<div id="search_group">
<input type="text" id="search_input" placeholder='Try: "authentication and security", "deployment pipeline", "user interface"'>
<button class="toolbar_btn" id="search_button">Search</button>
<button class="toolbar_btn" id="clear_button" style="display:none;">Clear</button>
</div>
<span id="toolbar_status"></span>
</div>
<div id="gantt"></div>
<script src="https://docs.dhtmlx.com/gantt/codebase/dhtmlxgantt.js"></script>
<script src="./main.js"></script>
</body>
</html>
frontend/main.js View on GitHub
window.addEventListener("DOMContentLoaded", () => {
let searchResults = null;
let matchedIds = new Set();
let scoreMap = new Map();
function isSearchActive() { return searchResults !== null; }
function isMatchedId(id) { return matchedIds.has(id); }
async function search() {
const input = document.getElementById("search_input");
const query = input.value.trim();
if (!query) {
flush();
return;
}
const status = document.getElementById("toolbar_status");
const searchBtn = document.getElementById("search_button");
searchBtn.disabled = true;
status.textContent = "Searching...";
status.className = "loading";
try {
const response = await fetch("/search", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ query: query })
});
if (response.ok) {
searchResults = await response.json();
matchedIds = new Set(searchResults.map(record => record.id));
scoreMap = new Map(searchResults.map(record => [record.id, record.score]));
matchedIds.forEach(id => {
gantt.eachParent((parent) => parent.$open = true, id);
});
gantt.render();
if (searchResults.length > 0) {
gantt.showTask(searchResults[0].id);
}
const count = searchResults.length;
status.textContent = count
? `${count} result${count === 1 ? "" : "s"} found`
: "No results";
status.className = "";
document.getElementById("clear_button").style.display = "";
} else {
const message = "Search failed.";
console.error(message);
gantt.message({ type: "error", text: message });
status.textContent = message;
status.className = "";
}
} catch (error) {
const message = error.message || "Search failed.";
console.error(message);
gantt.message({ type: "error", text: message });
status.textContent = "Search failed.";
status.className = "";
} finally {
searchBtn.disabled = false;
}
}
function flush() {
searchResults = null;
matchedIds.clear();
scoreMap.clear();
gantt.render();
document.getElementById("toolbar_status").textContent = "";
document.getElementById("toolbar_status").className = "";
document.getElementById("clear_button").style.display = "none";
}
const searchInput = document.getElementById("search_input");
const searchButton = document.getElementById("search_button");
const clearButton = document.getElementById("clear_button");
searchButton.addEventListener("click", () => { search() });
clearButton.addEventListener("click", () => { flush() });
searchInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") search();
if (e.key === "Escape") flush();
});
gantt.plugins({
tooltip: true,
});
gantt.setSkin("dark");
gantt.templates.grid_row_class = function (start, end, task) {
if (!isSearchActive()) return "casual";
return isMatchedId(task.id) ? "highlight" : "dimmed";
};
gantt.templates.task_row_class = function (start, end, task) {
if (!isSearchActive()) return "casual";
return isMatchedId(task.id) ? "highlight" : "dimmed";
};
gantt.templates.task_class = function (start, end, task) {
if (!isSearchActive()) return "casual_task";
return isMatchedId(task.id) ? "highlight_task" : "dimmed_task";
};
gantt.templates.link_class = function (link) {
if (!isSearchActive()) return "casual_link";
return "dimmed_link"
};
gantt.config.lightbox.sections = [
{ name: "title", type: "textarea", map_to: "text", focus: true, height: 40 },
{ name: "description", type: "textarea", map_to: "description", height: 70 },
{ name: "time", type: "duration", map_to: "auto" }
];
gantt.config.lightbox.project_sections = [
{ name: "title", type: "textarea", map_to: "text", focus: true, height: 40 },
{ name: "description", type: "textarea", map_to: "description", height: 70 },
{ name: "time", type: "duration", map_to: "auto" }
];
gantt.config.lightbox.milestone_sections = [
{ name: "title", type: "textarea", map_to: "text", focus: true, height: 40 },
{ name: "description", type: "textarea", map_to: "description", height: 70 },
{ name: "time", type: "duration", map_to: "auto" }
];
function getColumns() {
const columns = [{ name: "text", label: "Task name", resize: true, width: 300, tree: true, align: "left" }];
if (document.body.offsetWidth >= 1080) {
columns.push({ name: "start_date", label: "Start time", resize: true, width: 130, align: "center" })
columns.push({ name: "duration", label: "Duration", resize: true, width: 100, align: "center" })
}
if (isSearchActive()) {
columns.push({
name: "relevance",
label: "Relevance",
resize: true,
width: 130,
align: "center",
template: function (task) {
const score = scoreMap.get(task.id);
if (score === undefined) return "";
const percentage = Math.round(score * 100);
return `
<div class="relevance_bar_wrap">
<div class="relevance_bar_fill" style="width:${percentage}%"></div>
</div>
<span class="relevance_label">${percentage}%</span>
`;
}
})
}
columns.push({ name: "add", label: "", width: 40 });
return columns
}
gantt.config.columns = [...getColumns()];
gantt.attachEvent("onBeforeGanttRender", () => {
gantt.config.columns = [...getColumns()];
});
gantt.init("gantt");
const dataprocessor = gantt.createDataProcessor((entity, action, data, id) => {
if (entity != "task" && entity != "link") {
return Promise.resolve({});
}
const urlMap = {
create: "/api/" + entity + "s/create",
update: "/api/" + entity + "s/update",
delete: "/api/" + entity + "s/delete"
};
const url = urlMap[action];
if (!url) {
return Promise.resolve({});
}
return gantt.ajax.post({
headers: {"Content-Type": "application/json"},
url: url,
data: JSON.stringify(data)
}).catch((error) => {
const message = error.message || `Failed to execute ${action}`;
console.error(message);
gantt.message({ type: "error", text: message });
throw error;
});
});
try {
gantt.load("/api/read");
} catch (error) {
const status = document.getElementById("toolbar_status");
const message = error.message || "Failed to initialize data.";
status.textContent = message;
status.className = "";
console.error(message);
gantt.message({ type: "error", text: message });
}
});
frontend/styles.css View on GitHub
:root {
--agent-bg-1: #070708;
--agent-bg-2: #18181A;
--agent-accent: #8B6F5E;
--agent-foreground: #FFF9F7;
--agent-border: #1B1B1D;
--agent-radius: 12px;
--agent-shadow: 0 8px 22px rgba(10, 10, 12, 0.72), 0 6px 20px rgba(139, 111, 94, 0.12), 0 2px 6px rgba(139, 111, 94, 0.56) inset;
--agent-font-weight: 600;
--scroll-thumb: rgba(139, 111, 94, 0.26);
--scroll-thumb-hover: rgba(139, 111, 94, 0.36);
--scroll-track: rgba(255, 255, 255, 0.02);
--scroll-width: 10px;
}
body,
html {
width: 100%;
height: 100%;
margin: 0;
background-color: var(--agent-bg-1);
color: var(--agent-foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
#toolbar {
width: 100%;
background: var(--agent-bg-2);
border-bottom: 1px solid var(--agent-border);
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
box-sizing: border-box;
gap: 16px;
padding: 16px 20px;
min-height: 70px;
flex: 0 0 auto;
}
#toolbar_title {
font-size: 15px;
font-weight: 700;
color: var(--agent-foreground);
white-space: nowrap;
letter-spacing: 0.01em;
margin: 0;
flex: 0 1 auto;
min-width: 0;
}
#search_group {
display: flex;
align-items: center;
row-gap: 12px;
flex: 0 1 560px;
min-width: 0;
}
#search_input {
width: 100%;
min-width: 0;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(139, 111, 94, 0.35);
border-radius: var(--agent-radius);
color: var(--agent-foreground);
padding: 0 14px;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
#search_input:focus {
outline: none;
background: rgba(255, 255, 255, 0.09);
border-color: var(--agent-accent);
box-shadow: 0 0 0 2px rgba(139, 111, 94, 0.2);
}
#search_input::placeholder {
color: rgba(255, 249, 247, 0.5);
}
.toolbar_btn {
margin-left: 8px;
flex: 0 0 auto;
padding: 0 18px;
height: 36px;
background: linear-gradient(135deg, var(--agent-bg-1) 0%, var(--agent-bg-2) 100%);
color: var(--agent-foreground);
border: 1px solid var(--agent-border);
border-radius: var(--agent-radius);
cursor: pointer;
font-weight: var(--agent-font-weight);
font-size: 13px;
transition: transform .12s ease, box-shadow .12s ease, background 0.2s;
white-space: nowrap;
}
.toolbar_btn:hover {
background: var(--agent-accent);
border-color: var(--agent-accent);
box-shadow: var(--agent-shadow);
transform: translateY(-1px);
}
.toolbar_btn:disabled {
opacity: 0.4;
pointer-events: none;
}
#search_status {
margin-left: 12px;
font-size: 13px;
color: rgba(255, 249, 247, 0.5);
white-space: normal;
flex: 0 1 auto;
min-width: 0;
}
#search_status.loading {
color: var(--agent-accent);
}
#gantt {
width: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.casual {
--dhx-gantt-task-background: #8B6F5E;
--dhx-gantt-task-color: rgba(255, 249, 247, 0.6);
}
.casual_task {
--dhx-gantt-task-background: #8B6F5E;
--dhx-gantt-task-color: rgba(255, 249, 247, 0.6);
}
.casual_link {
opacity: 0.75 !important;
}
.highlight {
background: linear-gradient(90deg, rgba(139, 111, 94, 0.15), rgba(106, 63, 43, 0.05)) !important;
box-shadow: 7px 0 0px -2px var(--agent-accent) inset;
}
.highlight .gantt_cell {
color: var(--agent-foreground) !important;
font-weight: bold;
}
.highlight_task {
--dhx-gantt-task-background: #8B6F5E;
--dhx-gantt-task-color: rgba(255, 249, 247, 0.6);
filter: brightness(1.3) saturate(1.2) !important;
box-shadow: 0 0 12px rgba(139, 111, 94, 0.6), 0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
.dimmed {
opacity: 0.35;
transition: opacity 0.3s ease;
}
.dimmed .gantt_cell {
color: rgba(255, 249, 247, 0.35) !important;
}
.dimmed_task {
--dhx-gantt-task-background: #8B6F5E;
--dhx-gantt-task-color: rgba(255, 249, 247, 0.6);
opacity: 0.3 !important;
}
.dimmed_link {
opacity: 0.35;
transition: opacity 0.3s ease;
}
.relevance_bar_wrap {
display: inline-block;
width: 56px;
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
vertical-align: middle;
}
.relevance_bar_fill {
height: 100%;
background: linear-gradient(90deg, var(--agent-accent), #c4a68a);
border-radius: 3px;
transition: width 0.4s ease;
}
.relevance_label {
display: inline-block;
margin-left: 6px;
font-size: 11px;
font-weight: 600;
color: var(--agent-accent);
vertical-align: middle;
}
#gantt::-webkit-scrollbar,
.gantt_hor_scroll::-webkit-scrollbar,
.gantt_ver_scroll::-webkit-scrollbar {
width: var(--scroll-width);
height: var(--scroll-width);
background: transparent;
}
#gantt::-webkit-scrollbar-track,
.gantt_hor_scroll::-webkit-scrollbar-track,
.gantt_ver_scroll::-webkit-scrollbar-track {
background: var(--scroll-track);
border-radius: calc(var(--scroll-width) * 2);
}
#gantt::-webkit-scrollbar-thumb,
.gantt_hor_scroll::-webkit-scrollbar-thumb,
.gantt_ver_scroll::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--scroll-thumb), rgba(139, 111, 94, 0.18));
border-radius: calc(var(--scroll-width) * 2);
border: 2px solid transparent;
background-clip: padding-box;
}
#gantt::-webkit-scrollbar-thumb:hover,
.gantt_hor_scroll::-webkit-scrollbar-thumb:hover,
.gantt_ver_scroll::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--scroll-thumb-hover), rgba(139, 111, 94, 0.26));
}
@media (max-width: 1079px) {
#toolbar {
align-items: stretch;
justify-content: flex-start;
gap: 12px;
padding: 14px 16px;
}
#toolbar_title,
#search_group,
#search_status {
flex-basis: 100%;
}
#search_group {
flex-wrap: wrap;
}
#search_input {
flex: 1 1 100%;
}
.toolbar_btn {
margin-left: 0;
}
#search_status {
margin-left: 0;
}
}
backend/main.py View on GitHub
import fastapi
import fastapi.middleware.cors
import html
import json
import logging
import ollama
import pydantic
import uvicorn
TaskId = str | int
LinkId = str | int
class ModelTaskAPI(pydantic.BaseModel):
id: TaskId
text: str
description: str
parent: TaskId
progress: float
duration: int
start_date: str
end_date: str
class ModelLinkAPI(pydantic.BaseModel):
id: LinkId
source: TaskId
target: TaskId
type: str
class SearchRequest(pydantic.BaseModel):
query: str
class SearchResult(pydantic.BaseModel):
id: TaskId
score: float
class Application:
MODEL_NAME = "all-minilm"
SIMILARITY_THRESHOLD = 0.4
@staticmethod
def sanitize_task(task: ModelTaskAPI) -> ModelTaskAPI:
return task.model_copy(update = {
"text": html.escape(task.text),
"description": html.escape(task.description),
})
@staticmethod
def get_indexable_text(task: ModelTaskAPI) -> str:
return f"{task.text}\n{task.description}"
@staticmethod
def get_embedding(text: str) -> list[float]:
response = ollama.embed(
model = Application.MODEL_NAME,
input = text,
truncate = True
)
return response.embeddings[0]
def __init__(self):
with open("data.json", "r", encoding = "utf-8") as file:
data = json.load(file)
self.document_storage: dict[TaskId, ModelTaskAPI] = {
record["id"] : ModelTaskAPI(
id = record["id"],
text = record["text"],
description = record["description"],
parent = record["parent"],
progress = record["progress"],
duration = record["duration"],
start_date = record["start_date"],
end_date = record["end_date"]
)
for record in data["document_storage"].values()
}
self.link_storage: dict[LinkId, ModelLinkAPI] = {
record["id"] : ModelLinkAPI(
id = record["id"],
source = record["source"],
target = record["target"],
type = record["type"]
)
for record in data["link_storage"].values()
}
self.vector_storage: dict[TaskId, list[float]] = {
data["document_storage"][id]["id"] : record
for id, record in data["vector_storage"].items()
}
self.application = fastapi.FastAPI()
self.application.add_middleware(
fastapi.middleware.cors.CORSMiddleware,
allow_origins = ["*"],
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
)
@self.application.get("/api/read")
async def read():
try:
return fastapi.responses.JSONResponse(
content = fastapi.encoders.jsonable_encoder({
"tasks": [
{**document.model_dump(), "open": True}
for _, document in self.document_storage.items()
],
"links": [
{**link.model_dump()}
for _, link in self.link_storage.items()
]
})
)
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/tasks/create")
async def tasks_create(request: ModelTaskAPI):
try:
request = Application.sanitize_task(request)
embedding = Application.get_embedding(Application.get_indexable_text(request))
self.document_storage[request.id] = request
self.vector_storage[request.id] = embedding
return fastapi.responses.JSONResponse(
content = {
"action": "created",
"tid": request.id
}
)
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/tasks/update")
async def tasks_update(request: ModelTaskAPI):
try:
if request.id not in self.document_storage:
raise fastapi.HTTPException(status_code = 404, detail = f"Task '{request.id}' not found")
request = Application.sanitize_task(request)
embedding = Application.get_embedding(Application.get_indexable_text(request))
self.document_storage[request.id] = request
self.vector_storage[request.id] = embedding
return fastapi.responses.JSONResponse(
content = {
"action": "updated"
}
)
except fastapi.HTTPException:
raise
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/tasks/delete")
async def tasks_delete(request: ModelTaskAPI):
try:
if request.id not in self.document_storage:
raise fastapi.HTTPException(status_code = 404, detail = f"Task '{request.id}' not found")
del self.document_storage[request.id]
del self.vector_storage[request.id]
return fastapi.responses.JSONResponse(
content = {
"action": "deleted"
}
)
except fastapi.HTTPException:
raise
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/links/create")
async def links_create(request: ModelLinkAPI):
try:
self.link_storage[request.id] = request
return fastapi.responses.JSONResponse(
content = {
"action": "created",
"tid": request.id
}
)
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/links/update")
async def links_update(request: ModelLinkAPI):
try:
if request.id not in self.link_storage:
raise fastapi.HTTPException(status_code = 404, detail = f"Link '{request.id}' not found")
self.link_storage[request.id] = request
return fastapi.responses.JSONResponse(
content = {
"action": "updated"
}
)
except fastapi.HTTPException:
raise
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post("/api/links/delete")
async def links_delete(request: ModelLinkAPI):
try:
if request.id not in self.link_storage:
raise fastapi.HTTPException(status_code = 404, detail = f"Link '{request.id}' not found")
del self.link_storage[request.id]
return fastapi.responses.JSONResponse(
content = {
"action": "deleted",
"tid": request.id
}
)
except fastapi.HTTPException:
raise
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
# for testing/demo purposes only - recalculates all embeddings after changing the model
@self.application.get("/api/embeddings/recalculate")
async def embeddings_recalculate():
try:
for id, task in self.document_storage.items():
self.vector_storage[id] = Application.get_embedding(Application.get_indexable_text(task))
return fastapi.responses.JSONResponse(
content = {
"action": "recalculated",
"count": len(self.vector_storage)
}
)
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
@self.application.post(
"/search",
operation_id = "search",
summary = "Search",
description = (
"Receives user's query related to the Gantt tasks. "
"Performs semantic vector-based search. "
"Returns results sorted by similarity score (highest first)."
),
response_model = list[SearchResult]
)
async def search(request: SearchRequest) -> list[SearchResult]:
try:
embedding = Application.get_embedding(request.query)
response = []
for id, vector in self.vector_storage.items():
product = sum(x * y for x, y in zip(vector, embedding))
normX = sum(x * y for x, y in zip(vector, vector)) ** 0.5
normY = sum(x * y for x, y in zip(embedding, embedding)) ** 0.5
similarity = 0.0 if (normX == 0 or normY == 0) else product / (normX * normY)
if similarity > Application.SIMILARITY_THRESHOLD:
response.append(
SearchResult(
id = id,
score = round(similarity, 4)
)
)
response.sort(key = lambda record: record.score, reverse = True)
return response
except Exception as exception:
logging.exception(str(exception))
raise fastapi.HTTPException(status_code = 500, detail = str(exception))
uvicorn.run(self.application, host = "0.0.0.0", port = 11435)
if __name__ == "__main__":
application = Application()
backend/requirements.txt View on GitHub
fastapi==0.135.1
ollama==0.6.1
pydantic==2.12.5
uvicorn==0.41.0