Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions api/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Vercel Serverless Function — Hybrid search for developers
*
* Endpoint: /api/search?q=machine+learning+python&mode=hybrid
*
* Modes: vector | text | hybrid (default)
*/
import { CosmosClient } from '@azure/cosmos';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
const OPENAI_KEY = process.env.AZURE_OPENAI_KEY;
const EMBEDDING_DEPLOYMENT = process.env.EMBEDDING_DEPLOYMENT || 'text-embedding-3-small';

const DATABASE = 'devglobe';
const CONTAINER = 'developers';

async function getEmbedding(text) {
const url = `${OPENAI_ENDPOINT}/openai/deployments/${EMBEDDING_DEPLOYMENT}/embeddings?api-version=2024-02-01`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': OPENAI_KEY },
body: JSON.stringify({ input: [text] })
});
const data = await res.json();
return data.data[0].embedding;
}

export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');

const { q, mode = 'hybrid', top = '10' } = req.query;

if (!q) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}

if (!COSMOS_ENDPOINT || !COSMOS_KEY) {
return res.status(500).json({ error: 'Cosmos DB not configured' });
}

try {
const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
const container = client.database(DATABASE).container(CONTAINER);
const limit = Math.min(parseInt(top), 50);

let results;

if (mode === 'vector') {
if (!OPENAI_ENDPOINT || !OPENAI_KEY) {
return res.status(500).json({ error: 'OpenAI not configured for vector search' });
}
const embedding = await getEmbedding(q);
const { resources } = await container.items.query({
query: `
SELECT TOP ${limit}
c.id, c.login, c.name, c.avatarUrl, c.location, c.lat, c.lng,
c.topLanguage, c.score, c.totalStars, c.followers, c.soReputation,
VectorDistance(c.embedding, @embedding) AS relevance
FROM c
ORDER BY VectorDistance(c.embedding, @embedding)
`,
parameters: [{ name: '@embedding', value: embedding }]
}).fetchAll();
results = resources;

} else if (mode === 'text') {
const { resources } = await container.items.query({
query: `
SELECT TOP ${limit}
c.id, c.login, c.name, c.avatarUrl, c.location, c.lat, c.lng,
c.topLanguage, c.score, c.totalStars, c.followers, c.soReputation
FROM c
WHERE FullTextContains(c.login, @q)
OR FullTextContains(c.name, @q)
OR FullTextContains(c.location, @q)
OR FullTextContains(c.bio, @q)
OR FullTextContains(c.topLanguage, @q)
ORDER BY RANK FullTextScore(c.login, [@q]) +
FullTextScore(c.name, [@q]) +
FullTextScore(c.location, [@q]) +
FullTextScore(c.bio, [@q]) +
FullTextScore(c.topLanguage, [@q])
`,
parameters: [{ name: '@q', value: q }]
}).fetchAll();
results = resources;

} else {
// Hybrid: RRF fusion of vector + full-text
if (!OPENAI_ENDPOINT || !OPENAI_KEY) {
return res.status(500).json({ error: 'OpenAI not configured for hybrid search' });
}
const embedding = await getEmbedding(q);
const { resources } = await container.items.query({
query: `
SELECT TOP ${limit}
c.id, c.login, c.name, c.avatarUrl, c.location, c.lat, c.lng,
c.topLanguage, c.score, c.totalStars, c.followers, c.soReputation
FROM c
WHERE FullTextContains(c.login, @q)
OR FullTextContains(c.name, @q)
OR FullTextContains(c.location, @q)
OR FullTextContains(c.bio, @q)
OR FullTextContains(c.topLanguage, @q)
OR VectorDistance(c.embedding, @embedding) > 0.7
ORDER BY RANK RRF(
FullTextScore(c.login, [@q]) +
FullTextScore(c.name, [@q]) +
FullTextScore(c.location, [@q]) +
FullTextScore(c.bio, [@q]) +
FullTextScore(c.topLanguage, [@q]),
VectorDistance(c.embedding, @embedding)
)
`,
parameters: [
{ name: '@q', value: q },
{ name: '@embedding', value: embedding }
]
}).fetchAll();
results = resources;
}

res.json({ query: q, mode, count: results.length, results });
} catch (err) {
console.error('Search error:', err.message);
res.status(500).json({ error: 'Search failed' });
}
}
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ <h1 class="header__title">DevGlobe</h1>
</div>
<div class="header__search">
<input type="text" id="search-input" placeholder="Search developers..." autocomplete="off">
<select id="search-mode" title="Search mode">
<option value="text">Text</option>
<option value="vector">Vector (AI)</option>
<option value="hybrid" selected>Hybrid</option>
</select>
</div>
</header>

Expand Down
39 changes: 39 additions & 0 deletions scripts/container-arm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"properties": {
"resource": {
"id": "developers",
"partitionKey": {
"paths": ["/location"],
"kind": "Hash"
},
"indexingPolicy": {
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [{"path": "/*"}],
"excludedPaths": [{"path": "/embedding/*"}, {"path": "/\"_etag\"/?"}],
"vectorIndexes": [{"path": "/embedding", "type": "quantizedFlat"}],
"fullTextIndexes": [{"path": "/login"}, {"path": "/name"}, {"path": "/location"}, {"path": "/bio"}, {"path": "/topLanguage"}]
},
"vectorEmbeddingPolicy": {
"vectorEmbeddings": [
{
"path": "/embedding",
"dataType": "float32",
"dimensions": 1536,
"distanceFunction": "cosine"
}
]
},
"fullTextPolicy": {
"defaultLanguage": "en-US",
"fullTextPaths": [
{"path": "/login", "language": "en-US"},
{"path": "/name", "language": "en-US"},
{"path": "/location", "language": "en-US"},
{"path": "/bio", "language": "en-US"},
{"path": "/topLanguage", "language": "en-US"}
]
}
}
}
}
1 change: 1 addition & 0 deletions scripts/fulltext-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"defaultLanguage":"en-US","fullTextPaths":[{"path":"/login","language":"en-US"},{"path":"/name","language":"en-US"},{"path":"/location","language":"en-US"},{"path":"/bio","language":"en-US"},{"path":"/topLanguage","language":"en-US"}]}
112 changes: 112 additions & 0 deletions scripts/generate-embeddings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Generate vector embeddings for developers and upload to Cosmos DB
*
* Usage: node scripts/generate-embeddings.js
*
* Uses Azure OpenAI text-embedding-3-small (1536 dimensions)
* Reads existing docs from Cosmos DB, generates embeddings, patches them back
*/
import 'dotenv/config';
import { CosmosClient } from '@azure/cosmos';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
const OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT; // e.g., https://your-resource.openai.azure.com/
const OPENAI_KEY = process.env.AZURE_OPENAI_KEY;
const EMBEDDING_DEPLOYMENT = process.env.EMBEDDING_DEPLOYMENT || 'text-embedding-3-small';

const DATABASE_NAME = 'devglobe';
const CONTAINER_NAME = 'developers';
const BATCH_SIZE = 100; // OpenAI supports up to 2048 inputs per request

if (!OPENAI_ENDPOINT || !OPENAI_KEY) {
console.error('Required: AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_KEY in .env');
process.exit(1);
}

/**
* Create a searchable text representation of a developer
* This is what gets embedded into vector space
*/
function buildEmbeddingText(dev) {
const parts = [
dev.login,
dev.name || '',
dev.location || '',
dev.bio || '',
dev.topLanguage ? `Primary language: ${dev.topLanguage}` : '',
dev.totalStars > 1000 ? `${dev.totalStars} stars` : '',
dev.soReputation > 1000 ? `StackOverflow reputation: ${dev.soReputation}` : '',
dev.topRepos ? dev.topRepos.map(r => r.name).join(' ') : ''
];
return parts.filter(Boolean).join(' | ');
}

/**
* Call Azure OpenAI embeddings API
*/
async function getEmbeddings(texts) {
const url = `${OPENAI_ENDPOINT}/openai/deployments/${EMBEDDING_DEPLOYMENT}/embeddings?api-version=2024-02-01`;

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': OPENAI_KEY
},
body: JSON.stringify({ input: texts })
});

if (!response.ok) {
const err = await response.text();
throw new Error(`OpenAI API error ${response.status}: ${err}`);
}

const data = await response.json();
return data.data.map(d => d.embedding);
}

async function main() {
const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
const container = client.database(DATABASE_NAME).container(CONTAINER_NAME);

console.log('📊 Generating embeddings for developers...\n');

// Fetch all developers without embeddings
const { resources: developers } = await container.items
.query('SELECT c.id, c.login, c.name, c.location, c.bio, c.topLanguage, c.totalStars, c.soReputation, c.topRepos FROM c WHERE NOT IS_DEFINED(c.embedding)')
.fetchAll();

console.log(` Found ${developers.length} developers needing embeddings\n`);

let processed = 0;
for (let i = 0; i < developers.length; i += BATCH_SIZE) {
const batch = developers.slice(i, i + BATCH_SIZE);
const texts = batch.map(buildEmbeddingText);

// Generate embeddings
const embeddings = await getEmbeddings(texts);

// Patch each document with its embedding
for (let j = 0; j < batch.length; j++) {
const dev = batch[j];
await container.item(dev.id, dev.location || '').patch({
operations: [
{ op: 'add', path: '/embedding', value: embeddings[j] }
]
});
}

processed += batch.length;
console.log(` Embedded: ${processed}/${developers.length}`);

// Rate limit: ~3 requests/sec for embedding API
if (i + BATCH_SIZE < developers.length) {
await new Promise(r => setTimeout(r, 400));
}
}

console.log(`\n✅ Done! ${processed} developers now have vector embeddings.`);
}

main().catch(err => { console.error(err); process.exit(1); });
8 changes: 8 additions & 0 deletions scripts/indexing-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [{"path": "/*"}],
"excludedPaths": [{"path": "/embedding/*"}, {"path": "/\"_etag\"/?"}],
"vectorIndexes": [{"path": "/embedding", "type": "quantizedFlat"}],
"fullTextIndexes": [{"path": "/login"}, {"path": "/name"}, {"path": "/location"}, {"path": "/bio"}, {"path": "/topLanguage"}]
}
Loading
Loading