vecfinder

Document searching tool based on embedding vectors
git clone git://git.finwo.net/app/vecfinder
Log | Files | Refs

commit 41b9f26d7b3233df867723a217ff673c8b7f2f86
Author: finwo <finwo@pm.me>
Date:   Sat, 21 Mar 2026 03:53:02 +0100

Project init

Diffstat:
Avecfinder.js | 351+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 351 insertions(+), 0 deletions(-)

diff --git a/vecfinder.js b/vecfinder.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const http = require('node:http'); +const readline = require('node:readline'); +const { stdin, stdout, stderr } = process; +const EventEmitter = require('node:events'); + +// Configuration +const CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'ragfinder.json'); +let config = { + endpoint: 'http://localhost:1234/v1/embeddings', + model: 'text-embedding-qwen3-0.6b-text-embedding', + apiKey: '', + parallelism: 2, +}; + +async function loadConfig() { + try { + const data = await fs.readFile(CONFIG_PATH, 'utf8'); + config = { ...config, ...JSON.parse(data) }; + } catch {} +} + +// Embedding +let currentQueryId = 0; + +function normalize(vec) { + let norm = 0; + for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i]; + norm = Math.sqrt(norm); + if (norm === 0) return vec; + return vec.map(v => v / norm); +} + +function dot(a, b) { + let sum = 0; + for (let i = 0; i < a.length; i++) sum += a[i] * b[i]; + return sum; +} + +async function fetchEmbedding(text) { + return new Promise((resolve, reject) => { + const req = http.request(config.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}), + }, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (!json.data?.[0]?.embedding) { + return reject(new Error('Invalid embedding response')); + } + resolve(json.data[0].embedding); + } catch (e) { + reject(e); + } + }); + }); + req.on('error', reject); + req.write(JSON.stringify({ model: config.model, input: text })); + req.end(); + }); +} + +// Document store with events +class DocumentStore extends EventEmitter { + constructor() { + super(); + this.documents = new Map(); + this.queryVec = null; + } + + async addDocument(id, content, metadata = {}) { + const raw = await fetchEmbedding(content); + const vector = normalize(raw); + this.documents.set(id, { content, vector, metadata }); + this.emit('documentAdded'); + } + + setQuery(queryText) { + currentQueryId++; + const queryId = currentQueryId; + + if (queryText.trim() === '') { + this.queryVec = null; + this.emit('queryUpdated'); + return; + } + + fetchEmbedding(queryText).then(raw => { + if (queryId !== currentQueryId) return; + this.queryVec = normalize(raw); + this.emit('queryUpdated'); + }).catch(err => { + console.error(`Embedding error: ${err.message}`); + }); + } + + getResults() { + const results = []; + for (const [id, doc] of this.documents.entries()) { + const score = this.queryVec ? dot(this.queryVec, doc.vector) : 0; + results.push({ + id, + score, + content: doc.content, + metadata: doc.metadata, + }); + } + + if (this.queryVec) { + results.sort((a, b) => b.score - a.score); + } else { + results.sort((a, b) => a.id.localeCompare(b.id)); + } + + return results; + } +} + +// File indexing +async function isTextFile(filePath) { + try { + const chunk = await fs.readFile(filePath, { length: 512 }); + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] === 0) return false; + } + return true; + } catch { + return false; + } +} + +async function walkDir(dirPath) { + const ignore = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt']); + const files = []; + const pending = [dirPath]; + + while (pending.length) { + const current = pending.pop(); + try { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!ignore.has(entry.name)) pending.push(fullPath); + } else { + files.push(fullPath); + } + } + } catch {} + } + + return files; +} + +async function indexDirectory(store, dirPath, ui) { + const files = await walkDir(dirPath); + + for (const filePath of files) { + try { + const stats = await fs.stat(filePath); + if (stats.size > 1024 * 1024) continue; + if (!(await isTextFile(filePath))) continue; + + const content = await fs.readFile(filePath, 'utf8'); + const id = path.relative(dirPath, filePath); + await store.addDocument(id, content, { path: filePath }); + } catch {} + } +} + +// UI +class UI { + constructor(store) { + this.store = store; + this.query = ''; + this.selectedIndex = 0; + this.previewOffset = 0; + this.rl = null; + + this.store.on('documentAdded', () => this.render()); + this.store.on('queryUpdated', () => { + this.selectedIndex = 0; + this.previewOffset = 0; + this.render(); + }); + } + + init() { + this.rl = readline.createInterface({ input: stdin, output: stderr, terminal: true }); + stdin.on('keypress', (str, key) => this.handleKeyPress(str, key)); + this.rl.on('line', () => this.handleEnter()); + this.rl.on('close', () => this.cleanup()); + this.render(); + } + + cleanup() { + stdin.removeAllListeners('keypress'); + if (this.rl) this.rl.close(); + } + + handleKeyPress(str, key) { + if (!key) return; + + if (key.name === 'enter') { + this.handleEnter(); + return; + } + + if (key.name === 'up') { + if (this.selectedIndex > 0) { + this.selectedIndex--; + this.previewOffset = 0; + this.render(); + } + return; + } + + if (key.name === 'down') { + const results = this.store.getResults(); + if (this.selectedIndex < results.length - 1) { + this.selectedIndex++; + this.previewOffset = 0; + this.render(); + } + return; + } + + if (key.name === 'pageup') { + this.previewOffset = Math.max(0, this.previewOffset - 10); + this.render(); + return; + } + + if (key.name === 'pagedown') { + this.previewOffset += 10; + this.render(); + return; + } + + if (key.name === 'backspace' || key.name === 'delete') { + this.query = this.query.slice(0, -1); + this.store.setQuery(this.query); + return; + } + + if (str && str.length === 1 && !key.ctrl && !key.meta) { + this.query += str; + this.store.setQuery(this.query); + return; + } + } + + handleEnter() { + this.cleanup(); + const results = this.store.getResults(); + if (results.length > 0 && this.selectedIndex < results.length) { + stdout.write(results[this.selectedIndex].id + '\n'); + } else { + stdout.write('\n'); + } + process.exit(0); + } + + render() { + stderr.write('\x1b[2J\x1b[H'); + + const width = process.stdout.columns || 80; + const height = process.stdout.rows || 24; + + const leftWidth = Math.floor(width * 0.35); + const rightWidth = width - leftWidth - 1; + const maxHeight = height - 4; + + const results = this.store.getResults(); + + // Header + stderr.write('RagFinder\n'); + stderr.write('─'.repeat(leftWidth) + '┬' + '─'.repeat(rightWidth) + '\n'); + + // Rows + for (let row = 0; row < maxHeight; row++) { + const result = results[row]; + const isSelected = (row === this.selectedIndex); + + // Left pane - name left, score right + let left; + if (result) { + let name = result.id; + const maxName = leftWidth - 10; + if (name.length > maxName) name = '...' + name.slice(-(maxName - 3)); + if (this.query && result.score !== undefined) { + const score = result.score.toFixed(3); + left = ' ' + name.padEnd(leftWidth - score.length - 3, ' ') + score + ' '; + } else { + left = ' ' + name; + } + } else { + left = ''; + } + left = left.padEnd(leftWidth, ' ').slice(0, leftWidth); + + // Right pane - preview for selected file, limited by pane height + let right = ''; + const selectedResult = results[this.selectedIndex]; + if (selectedResult) { + const lines = selectedResult.content.split('\n'); + right = lines[row + this.previewOffset] || ''; + } + right = right.padEnd(rightWidth, ' ').slice(0, rightWidth); + + // Write + if (isSelected) { + stderr.write(`\x1b[7m${left}\x1b[0m│${right}`); + } else { + stderr.write(`${left}│${right}`); + } + stderr.write('\n'); + } + + // Footer + stderr.write('─'.repeat(leftWidth) + '┴' + '─'.repeat(rightWidth) + '\n'); + stderr.write(`Query: ${this.query}`); + } +} + +// Main +async function main() { + await loadConfig(); + + const store = new DocumentStore(); + const ui = new UI(store); + + ui.init(); + + indexDirectory(store, process.cwd(), ui); +} + +main().catch(err => { + console.error(`Fatal error: ${err.message}`); + process.exit(1); +}); +\ No newline at end of file