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
152 lines
4.6 KiB
TypeScript
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,
|
|
};
|
|
}
|