Rene Fichtmueller 0260d0b365 feat: Phase 4 — Vector embeddings + semantic search
Ollama nomic-embed-text (768 dim) → Qdrant vector search pipeline.
Embeds all 89 transceivers with rich text representation and payload
filters (form_factor, speed_gbps, fiber_type, wdm_type).

- embeddings/client.ts: Ollama embed + Qdrant upsert/search
- embeddings/seed-products.ts: Batch seeder for product_embeddings
- routes/search.ts: GET /api/search, /search/products, /search/stats
- 6 Qdrant collections: products, datasheets, FAQs, manuals, troubleshooting, news
2026-03-28 00:05:29 +13:00

152 lines
4.6 KiB
TypeScript

/**
* Embedding + Qdrant client for vector search.
*
* Ollama nomic-embed-text (768 dim) → Qdrant collections.
* Supports: products, datasheets, FAQs, manuals, troubleshooting, news.
*/
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
const EMBED_MODEL = process.env.EMBED_MODEL || "nomic-embed-text";
export type CollectionName =
| "product_embeddings"
| "datasheet_chunks"
| "faq_embeddings"
| "manual_chunks"
| "troubleshooting_embeddings"
| "news_embeddings";
/** Generate embedding vector from text */
export async function embed(text: string): Promise<number[]> {
const resp = await fetch(`${OLLAMA_URL}/api/embeddings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
throw new Error(`Ollama embed failed: ${resp.status} ${await resp.text()}`);
}
const data = await resp.json() as { embedding: number[] };
return data.embedding;
}
/** Batch embed multiple texts */
export async function embedBatch(texts: ReadonlyArray<string>): Promise<number[][]> {
const results: number[][] = [];
// Ollama doesn't support batch embedding natively, so we serialize
// with concurrency limit to avoid overloading
const CONCURRENCY = 3;
for (let i = 0; i < texts.length; i += CONCURRENCY) {
const batch = texts.slice(i, i + CONCURRENCY);
const embeddings = await Promise.all(batch.map((t) => embed(t)));
results.push(...embeddings);
}
return results;
}
/** Upsert a point into Qdrant */
export async function upsertPoint(
collection: CollectionName,
id: string,
vector: number[],
payload: Record<string, unknown>,
): Promise<void> {
const resp = await fetch(`${QDRANT_URL}/collections/${collection}/points`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
points: [{ id, vector, payload }],
}),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
throw new Error(`Qdrant upsert failed: ${resp.status} ${await resp.text()}`);
}
}
/** Batch upsert points */
export async function upsertPoints(
collection: CollectionName,
points: ReadonlyArray<{ id: string; vector: number[]; payload: Record<string, unknown> }>,
): Promise<void> {
if (points.length === 0) return;
const resp = await fetch(`${QDRANT_URL}/collections/${collection}/points`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ points }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
throw new Error(`Qdrant batch upsert failed: ${resp.status} ${await resp.text()}`);
}
}
/** Search Qdrant with vector similarity + optional payload filter */
export async function searchSimilar(
collection: CollectionName,
queryVector: number[],
limit: number = 10,
filter?: Record<string, unknown>,
): Promise<Array<{ id: string; score: number; payload: Record<string, unknown> }>> {
const body: Record<string, unknown> = {
vector: queryVector,
limit,
with_payload: true,
};
if (filter) {
body.filter = filter;
}
const resp = await fetch(`${QDRANT_URL}/collections/${collection}/points/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
throw new Error(`Qdrant search failed: ${resp.status} ${await resp.text()}`);
}
const data = await resp.json() as { result: Array<{ id: string; score: number; payload: Record<string, unknown> }> };
return data.result;
}
/** Semantic search: embed query text then search Qdrant */
export async function semanticSearch(
collection: CollectionName,
query: string,
limit: number = 10,
filter?: Record<string, unknown>,
): Promise<Array<{ id: string; score: number; payload: Record<string, unknown> }>> {
const vector = await embed(query);
return searchSimilar(collection, vector, limit, filter);
}
/** Get collection info (point count, etc.) */
export async function getCollectionInfo(
collection: CollectionName,
): Promise<{ pointsCount: number; vectorsCount: number }> {
const resp = await fetch(`${QDRANT_URL}/collections/${collection}`, {
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) {
throw new Error(`Qdrant info failed: ${resp.status}`);
}
const data = await resp.json() as { result: { points_count: number; vectors_count: number } };
return {
pointsCount: data.result.points_count,
vectorsCount: data.result.vectors_count,
};
}