qmd.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250
  1. #!/usr/bin/env bun
  2. import { Database } from "bun:sqlite";
  3. import { Glob } from "bun";
  4. import { mkdirSync, existsSync } from "node:fs";
  5. import { homedir } from "node:os";
  6. import { resolve } from "node:path";
  7. import * as sqliteVec from "sqlite-vec";
  8. // On macOS, use Homebrew's SQLite which supports extensions
  9. if (process.platform === "darwin") {
  10. const homebrewSqlitePath = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib";
  11. if (existsSync(homebrewSqlitePath)) {
  12. Database.setCustomSQLite(homebrewSqlitePath);
  13. }
  14. }
  15. const DEFAULT_EMBED_MODEL = "embeddinggemma";
  16. const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
  17. const DEFAULT_QUERY_MODEL = "qwen3:0.6b";
  18. const DEFAULT_GLOB = "**/*.md";
  19. const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
  20. // Terminal colors (respects NO_COLOR env)
  21. const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
  22. const c = {
  23. reset: useColor ? "\x1b[0m" : "",
  24. dim: useColor ? "\x1b[2m" : "",
  25. bold: useColor ? "\x1b[1m" : "",
  26. cyan: useColor ? "\x1b[36m" : "",
  27. yellow: useColor ? "\x1b[33m" : "",
  28. green: useColor ? "\x1b[32m" : "",
  29. magenta: useColor ? "\x1b[35m" : "",
  30. blue: useColor ? "\x1b[34m" : "",
  31. };
  32. // Global state for --index option
  33. let customIndexName: string | null = null;
  34. // Terminal progress bar using OSC 9;4 escape sequence
  35. const progress = {
  36. set(percent: number) {
  37. process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
  38. },
  39. clear() {
  40. process.stderr.write(`\x1b]9;4;0\x07`);
  41. },
  42. indeterminate() {
  43. process.stderr.write(`\x1b]9;4;3\x07`);
  44. },
  45. error() {
  46. process.stderr.write(`\x1b]9;4;2\x07`);
  47. },
  48. };
  49. // Format seconds into human-readable ETA
  50. function formatETA(seconds: number): string {
  51. if (seconds < 60) return `${Math.round(seconds)}s`;
  52. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
  53. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
  54. }
  55. function getDbPath(): string {
  56. const cacheDir = process.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
  57. const qmdCacheDir = resolve(cacheDir, "qmd");
  58. mkdirSync(qmdCacheDir, { recursive: true });
  59. const dbName = customIndexName || "index";
  60. return resolve(qmdCacheDir, `${dbName}.sqlite`);
  61. }
  62. function getPwd(): string {
  63. return process.env.PWD || process.cwd();
  64. }
  65. /*
  66. Schema:
  67. CREATE TABLE collections (
  68. id INTEGER PRIMARY KEY AUTOINCREMENT,
  69. pwd TEXT NOT NULL,
  70. glob_pattern TEXT NOT NULL,
  71. created_at TEXT NOT NULL,
  72. UNIQUE(pwd, glob_pattern)
  73. );
  74. CREATE TABLE documents (
  75. id INTEGER PRIMARY KEY AUTOINCREMENT,
  76. collection_id INTEGER NOT NULL,
  77. name TEXT NOT NULL,
  78. title TEXT NOT NULL,
  79. hash TEXT NOT NULL,
  80. filepath TEXT NOT NULL,
  81. body TEXT NOT NULL,
  82. created_at TEXT NOT NULL,
  83. modified_at TEXT NOT NULL,
  84. active INTEGER NOT NULL DEFAULT 1,
  85. FOREIGN KEY (collection_id) REFERENCES collections(id)
  86. );
  87. CREATE TABLE content_vectors (
  88. hash TEXT PRIMARY KEY,
  89. embedding BLOB NOT NULL,
  90. model TEXT NOT NULL,
  91. embedded_at TEXT NOT NULL
  92. );
  93. CREATE VIRTUAL TABLE documents_fts USING fts5(...);
  94. */
  95. function getDb(): Database {
  96. const db = new Database(getDbPath());
  97. sqliteVec.load(db);
  98. db.exec("PRAGMA journal_mode = WAL");
  99. // Collections table
  100. db.exec(`
  101. CREATE TABLE IF NOT EXISTS collections (
  102. id INTEGER PRIMARY KEY AUTOINCREMENT,
  103. pwd TEXT NOT NULL,
  104. glob_pattern TEXT NOT NULL,
  105. created_at TEXT NOT NULL,
  106. UNIQUE(pwd, glob_pattern)
  107. )
  108. `);
  109. // Documents table with collection_id and full filepath
  110. db.exec(`
  111. CREATE TABLE IF NOT EXISTS documents (
  112. id INTEGER PRIMARY KEY AUTOINCREMENT,
  113. collection_id INTEGER NOT NULL,
  114. name TEXT NOT NULL,
  115. title TEXT NOT NULL,
  116. hash TEXT NOT NULL,
  117. filepath TEXT NOT NULL,
  118. body TEXT NOT NULL,
  119. created_at TEXT NOT NULL,
  120. modified_at TEXT NOT NULL,
  121. active INTEGER NOT NULL DEFAULT 1,
  122. FOREIGN KEY (collection_id) REFERENCES collections(id)
  123. )
  124. `);
  125. // Content vectors keyed by hash (UNIQUE)
  126. db.exec(`
  127. CREATE TABLE IF NOT EXISTS content_vectors (
  128. hash TEXT PRIMARY KEY,
  129. model TEXT NOT NULL,
  130. embedded_at TEXT NOT NULL
  131. )
  132. `);
  133. // FTS on documents
  134. db.exec(`
  135. CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
  136. name, body,
  137. content='documents',
  138. content_rowid='id',
  139. tokenize='porter unicode61'
  140. )
  141. `);
  142. db.exec(`
  143. CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
  144. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  145. END
  146. `);
  147. db.exec(`
  148. CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
  149. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  150. END
  151. `);
  152. db.exec(`
  153. CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
  154. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  155. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  156. END
  157. `);
  158. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection_id, active)`);
  159. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
  160. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_filepath ON documents(filepath, active)`);
  161. return db;
  162. }
  163. function ensureVecTable(db: Database, dimensions: number): void {
  164. const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
  165. if (tableInfo) {
  166. const match = tableInfo.sql.match(/float\[(\d+)\]/);
  167. if (match && parseInt(match[1]) === dimensions) return;
  168. db.exec("DROP TABLE IF EXISTS vectors_vec");
  169. }
  170. db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash TEXT PRIMARY KEY, embedding float[${dimensions}])`);
  171. }
  172. function getHashesNeedingEmbedding(db: Database): number {
  173. const result = db.prepare(`
  174. SELECT COUNT(DISTINCT d.hash) as count
  175. FROM documents d
  176. LEFT JOIN content_vectors v ON d.hash = v.hash
  177. WHERE d.active = 1 AND v.hash IS NULL
  178. `).get() as { count: number };
  179. return result.count;
  180. }
  181. async function hashContent(content: string): Promise<string> {
  182. const hash = new Bun.CryptoHasher("sha256");
  183. hash.update(content);
  184. return hash.digest("hex");
  185. }
  186. // Extract title from first markdown headline, or use filename as fallback
  187. function extractTitle(content: string, filename: string): string {
  188. const match = content.match(/^##?\s+(.+)$/m);
  189. if (match) return match[1].trim();
  190. return filename.replace(/\.md$/, "").split("/").pop() || filename;
  191. }
  192. // Format text for EmbeddingGemma
  193. function formatQueryForEmbedding(query: string): string {
  194. return `task: search result | query: ${query}`;
  195. }
  196. function formatDocForEmbedding(text: string, title?: string): string {
  197. return `title: ${title || "none"} | text: ${text}`;
  198. }
  199. // Auto-pull model if not found
  200. async function ensureModelAvailable(model: string): Promise<void> {
  201. try {
  202. const response = await fetch(`${OLLAMA_URL}/api/show`, {
  203. method: "POST",
  204. headers: { "Content-Type": "application/json" },
  205. body: JSON.stringify({ name: model }),
  206. });
  207. if (response.ok) return;
  208. } catch {
  209. // Continue to pull attempt
  210. }
  211. console.log(`Model ${model} not found. Pulling...`);
  212. progress.indeterminate();
  213. const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, {
  214. method: "POST",
  215. headers: { "Content-Type": "application/json" },
  216. body: JSON.stringify({ name: model, stream: false }),
  217. });
  218. if (!pullResponse.ok) {
  219. progress.error();
  220. throw new Error(`Failed to pull model ${model}: ${pullResponse.status} - ${await pullResponse.text()}`);
  221. }
  222. progress.clear();
  223. console.log(`Model ${model} pulled successfully.`);
  224. }
  225. async function getEmbedding(text: string, model: string, isQuery: boolean = false, title?: string, retried: boolean = false): Promise<number[]> {
  226. const input = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text, title);
  227. const response = await fetch(`${OLLAMA_URL}/api/embed`, {
  228. method: "POST",
  229. headers: { "Content-Type": "application/json" },
  230. body: JSON.stringify({ model, input }),
  231. });
  232. if (!response.ok) {
  233. const errorText = await response.text();
  234. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  235. await ensureModelAvailable(model);
  236. return getEmbedding(text, model, isQuery, title, true);
  237. }
  238. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  239. }
  240. const data = await response.json() as { embeddings: number[][] };
  241. return data.embeddings[0];
  242. }
  243. // Qwen3-Reranker prompt format (trained for yes/no relevance classification)
  244. const RERANK_SYSTEM = `Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".`;
  245. function formatRerankPrompt(query: string, title: string, doc: string): string {
  246. return `<Instruct>: Determine if this document from a Shopify knowledge base is relevant to the search query. The query may reference specific Shopify programs, competitions, features, or named concepts (e.g., "Build a Business" competition, "Shop Pay", "Polaris"). Match documents that discuss the queried topic, even if phrasing differs.
  247. <Query>: ${query}
  248. <Document Title>: ${title}
  249. <Document>: ${doc}`;
  250. }
  251. type LogProb = { token: string; logprob: number };
  252. type RerankResponse = {
  253. response: string;
  254. logprobs?: LogProb[];
  255. };
  256. async function rerankSingle(prompt: string, model: string, retried: boolean = false): Promise<number> {
  257. // Use generate with raw template for qwen3-reranker format
  258. // Include empty <think> tags as per HuggingFace reference implementation
  259. const fullPrompt = `<|im_start|>system
  260. ${RERANK_SYSTEM}<|im_end|>
  261. <|im_start|>user
  262. ${prompt}<|im_end|>
  263. <|im_start|>assistant
  264. <think>
  265. </think>
  266. `;
  267. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  268. method: "POST",
  269. headers: { "Content-Type": "application/json" },
  270. body: JSON.stringify({
  271. model,
  272. prompt: fullPrompt,
  273. raw: true,
  274. stream: false,
  275. logprobs: true,
  276. options: { num_predict: 1 },
  277. }),
  278. });
  279. if (!response.ok) {
  280. const errorText = await response.text();
  281. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  282. await ensureModelAvailable(model);
  283. return rerankSingle(prompt, model, true);
  284. }
  285. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  286. }
  287. const data = await response.json() as RerankResponse;
  288. // Extract score from logprobs - required for proper reranking
  289. if (!data.logprobs || data.logprobs.length === 0) {
  290. throw new Error("Reranker response missing logprobs - ensure Ollama supports logprobs");
  291. }
  292. const firstToken = data.logprobs[0];
  293. const token = firstToken.token.toLowerCase().trim();
  294. const confidence = Math.exp(firstToken.logprob); // 0-1, higher = more confident
  295. if (token === "yes") {
  296. // Relevant: return confidence (e.g., 0.93 for high confidence yes)
  297. return confidence;
  298. }
  299. if (token === "no") {
  300. // Not relevant: return low score, scaled by inverse confidence
  301. // High confidence "no" → very low score
  302. return (1 - confidence) * 0.3; // Cap at 0.3 for uncertain "no"
  303. }
  304. throw new Error(`Unexpected reranker token: "${token}" (expected "yes" or "no")`);
  305. }
  306. async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL): Promise<{ file: string; score: number }[]> {
  307. const results: { file: string; score: number }[] = [];
  308. const total = documents.length;
  309. const PARALLEL = 5;
  310. process.stderr.write(`Reranking ${total} documents with ${model} (parallel: ${PARALLEL})...\n`);
  311. progress.indeterminate();
  312. // Process in parallel batches
  313. for (let i = 0; i < documents.length; i += PARALLEL) {
  314. const batch = documents.slice(i, i + PARALLEL);
  315. const batchResults = await Promise.all(
  316. batch.map(async (doc) => {
  317. try {
  318. // Extract title from filename for reranker context
  319. const title = doc.file.split('/').pop()?.replace(/\.md$/, '') || doc.file;
  320. const prompt = formatRerankPrompt(query, title, doc.text.slice(0, 4000));
  321. const score = await rerankSingle(prompt, model);
  322. return { file: doc.file, score };
  323. } catch (err) {
  324. return { file: doc.file, score: 0 };
  325. }
  326. })
  327. );
  328. results.push(...batchResults);
  329. const processed = Math.min(i + PARALLEL, total);
  330. progress.set((processed / total) * 100);
  331. process.stderr.write(`\rReranking: ${processed}/${total}`);
  332. }
  333. progress.clear();
  334. process.stderr.write("\n");
  335. return results.sort((a, b) => b.score - a.score);
  336. }
  337. function getOrCreateCollection(db: Database, pwd: string, globPattern: string): number {
  338. const now = new Date().toISOString();
  339. const existing = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
  340. if (existing) return existing.id;
  341. db.prepare(`INSERT INTO collections (pwd, glob_pattern, created_at) VALUES (?, ?, ?)`).run(pwd, globPattern, now);
  342. return (db.prepare(`SELECT last_insert_rowid() as id`).get() as { id: number }).id;
  343. }
  344. function listCollections(): void {
  345. const db = getDb();
  346. const collections = db.prepare(`
  347. SELECT c.id, c.pwd, c.glob_pattern, c.created_at,
  348. COUNT(d.id) as doc_count,
  349. SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count
  350. FROM collections c
  351. LEFT JOIN documents d ON d.collection_id = c.id
  352. GROUP BY c.id
  353. ORDER BY c.created_at DESC
  354. `).all() as { id: number; pwd: string; glob_pattern: string; created_at: string; doc_count: number; active_count: number }[];
  355. if (collections.length === 0) {
  356. console.log("No collections found.");
  357. db.close();
  358. return;
  359. }
  360. console.log("Collections:\n");
  361. for (const c of collections) {
  362. console.log(` ${c.pwd}`);
  363. console.log(` Pattern: ${c.glob_pattern}`);
  364. console.log(` Documents: ${c.active_count} active (${c.doc_count} total)`);
  365. console.log(` Created: ${c.created_at}\n`);
  366. }
  367. const hashCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
  368. console.log(`Vectors: ${hashCount.count} unique content hashes embedded`);
  369. db.close();
  370. }
  371. async function updateAllCollections(): Promise<void> {
  372. const db = getDb();
  373. const collections = db.prepare(`SELECT id, pwd, glob_pattern FROM collections`).all() as { id: number; pwd: string; glob_pattern: string }[];
  374. if (collections.length === 0) {
  375. console.log("No collections found.");
  376. db.close();
  377. return;
  378. }
  379. db.close();
  380. console.log(`Updating ${collections.length} collection(s)...\n`);
  381. for (const c of collections) {
  382. console.log(`\n--- ${c.pwd} (${c.glob_pattern}) ---`);
  383. // Temporarily set PWD for indexing
  384. const originalPwd = process.env.PWD;
  385. process.env.PWD = c.pwd;
  386. await indexFiles(c.glob_pattern);
  387. process.env.PWD = originalPwd;
  388. }
  389. console.log("\nAll collections updated.");
  390. }
  391. async function dropCollection(globPattern: string): Promise<void> {
  392. const db = getDb();
  393. const pwd = getPwd();
  394. const collection = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
  395. if (!collection) {
  396. console.log(`No collection found for ${pwd} with pattern ${globPattern}`);
  397. db.close();
  398. return;
  399. }
  400. // Delete documents in this collection
  401. const deleted = db.prepare(`DELETE FROM documents WHERE collection_id = ?`).run(collection.id);
  402. // Delete the collection
  403. db.prepare(`DELETE FROM collections WHERE id = ?`).run(collection.id);
  404. console.log(`Dropped collection: ${pwd} (${globPattern})`);
  405. console.log(`Removed ${deleted.changes} documents`);
  406. console.log(`(Vectors kept for potential reuse)`);
  407. db.close();
  408. }
  409. async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
  410. const db = getDb();
  411. const pwd = getPwd();
  412. const now = new Date().toISOString();
  413. const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
  414. // Get or create collection for this (pwd, glob)
  415. const collectionId = getOrCreateCollection(db, pwd, globPattern);
  416. console.log(`Collection: ${pwd} (${globPattern})`);
  417. progress.indeterminate();
  418. const glob = new Glob(globPattern);
  419. const files: string[] = [];
  420. for await (const file of glob.scan({ cwd: pwd, onlyFiles: true, followSymlinks: true })) {
  421. // Skip node_modules, hidden folders (.*), and other common excludes
  422. const parts = file.split("/");
  423. const shouldSkip = parts.some(part =>
  424. part === "node_modules" ||
  425. part.startsWith(".") ||
  426. excludeDirs.includes(part)
  427. );
  428. if (!shouldSkip) {
  429. files.push(file);
  430. }
  431. }
  432. const total = files.length;
  433. if (total === 0) {
  434. progress.clear();
  435. console.log("No files found matching pattern.");
  436. db.close();
  437. return;
  438. }
  439. const insertStmt = db.prepare(`INSERT INTO documents (collection_id, name, title, hash, filepath, body, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`);
  440. const deactivateStmt = db.prepare(`UPDATE documents SET active = 0 WHERE collection_id = ? AND filepath = ? AND active = 1`);
  441. const findActiveStmt = db.prepare(`SELECT id, hash FROM documents WHERE collection_id = ? AND filepath = ? AND active = 1`);
  442. let indexed = 0, updated = 0, unchanged = 0, processed = 0;
  443. const seenFiles = new Set<string>();
  444. const startTime = Date.now();
  445. for (const relativeFile of files) {
  446. const filepath = resolve(pwd, relativeFile);
  447. seenFiles.add(filepath);
  448. const content = await Bun.file(filepath).text();
  449. const hash = await hashContent(content);
  450. const name = relativeFile.replace(/\.md$/, "").split("/").pop() || relativeFile;
  451. const title = extractTitle(content, relativeFile);
  452. const existing = findActiveStmt.get(collectionId, filepath) as { id: number; hash: string } | null;
  453. if (existing) {
  454. if (existing.hash === hash) {
  455. unchanged++;
  456. } else {
  457. deactivateStmt.run(collectionId, filepath);
  458. updated++;
  459. const stat = await Bun.file(filepath).stat();
  460. insertStmt.run(collectionId, name, title, hash, filepath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  461. }
  462. } else {
  463. indexed++;
  464. const stat = await Bun.file(filepath).stat();
  465. insertStmt.run(collectionId, name, title, hash, filepath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  466. }
  467. processed++;
  468. progress.set((processed / total) * 100);
  469. const elapsed = (Date.now() - startTime) / 1000;
  470. const rate = processed / elapsed;
  471. const remaining = (total - processed) / rate;
  472. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  473. process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
  474. }
  475. // Deactivate documents in this collection that no longer exist
  476. const allActive = db.prepare(`SELECT filepath FROM documents WHERE collection_id = ? AND active = 1`).all(collectionId) as { filepath: string }[];
  477. let removed = 0;
  478. for (const row of allActive) {
  479. if (!seenFiles.has(row.filepath)) {
  480. deactivateStmt.run(collectionId, row.filepath);
  481. removed++;
  482. }
  483. }
  484. // Check if vector index needs updating
  485. const needsEmbedding = getHashesNeedingEmbedding(db);
  486. progress.clear();
  487. console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
  488. if (needsEmbedding > 0) {
  489. console.log(`\nRun 'qmd vector' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  490. }
  491. db.close();
  492. }
  493. async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = false): Promise<void> {
  494. const db = getDb();
  495. const now = new Date().toISOString();
  496. // If force, clear all vectors
  497. if (force) {
  498. console.log("Force re-indexing: clearing all vectors...");
  499. db.exec(`DELETE FROM content_vectors`);
  500. db.exec(`DROP TABLE IF EXISTS vectors_vec`);
  501. }
  502. // Find unique hashes that need embedding (from active documents)
  503. const hashesToEmbed = db.prepare(`
  504. SELECT DISTINCT d.hash, d.title, d.body
  505. FROM documents d
  506. LEFT JOIN content_vectors v ON d.hash = v.hash
  507. WHERE d.active = 1 AND v.hash IS NULL
  508. `).all() as { hash: string; title: string; body: string }[];
  509. if (hashesToEmbed.length === 0) {
  510. console.log("All content hashes already have embeddings.");
  511. db.close();
  512. return;
  513. }
  514. const total = hashesToEmbed.length;
  515. console.log(`Embedding ${total} unique content hashes with ${model}...`);
  516. progress.indeterminate();
  517. const firstEmbedding = await getEmbedding(hashesToEmbed[0].body, model, false, hashesToEmbed[0].title);
  518. console.log(`Embedding dimensions: ${firstEmbedding.length}`);
  519. ensureVecTable(db, firstEmbedding.length);
  520. const insertVecStmt = db.prepare(`INSERT INTO vectors_vec (hash, embedding) VALUES (?, ?)`);
  521. const insertContentVectorStmt = db.prepare(`INSERT OR REPLACE INTO content_vectors (hash, model, embedded_at) VALUES (?, ?, ?)`);
  522. let embedded = 0, errors = 0;
  523. const startTime = Date.now();
  524. // Insert first
  525. insertVecStmt.run(hashesToEmbed[0].hash, new Float32Array(firstEmbedding));
  526. insertContentVectorStmt.run(hashesToEmbed[0].hash, model, now);
  527. embedded++;
  528. progress.set((embedded / total) * 100);
  529. process.stderr.write(`\rEmbedding: ${embedded}/${total}`);
  530. for (let i = 1; i < hashesToEmbed.length; i++) {
  531. const item = hashesToEmbed[i];
  532. try {
  533. const embedding = await getEmbedding(item.body, model, false, item.title);
  534. insertVecStmt.run(item.hash, new Float32Array(embedding));
  535. insertContentVectorStmt.run(item.hash, model, now);
  536. embedded++;
  537. } catch (err) {
  538. errors++;
  539. progress.error();
  540. console.error(`\nError embedding hash ${item.hash.slice(0, 8)}...: ${err}`);
  541. }
  542. const processed = embedded + errors;
  543. progress.set((processed / total) * 100);
  544. const elapsed = (Date.now() - startTime) / 1000;
  545. const rate = processed / elapsed;
  546. const remaining = (total - processed) / rate;
  547. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  548. process.stderr.write(`\rEmbedding: ${embedded}/${total}${errors > 0 ? ` (${errors} errors)` : ""}${eta} `);
  549. }
  550. progress.clear();
  551. const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
  552. console.log(`\nDone! Embedded ${embedded} hashes${errors > 0 ? `, ${errors} errors` : ""} in ${totalTime}s.`);
  553. db.close();
  554. }
  555. function escapeCSV(value: string): string {
  556. if (value.includes('"') || value.includes(',') || value.includes('\n')) {
  557. return `"${value.replace(/"/g, '""')}"`;
  558. }
  559. return value;
  560. }
  561. function extractSnippet(body: string, query: string, maxLen = 500): { line: number; snippet: string } {
  562. const lines = body.split('\n');
  563. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  564. let bestLine = 0, bestScore = -1;
  565. for (let i = 0; i < lines.length; i++) {
  566. const lineLower = lines[i].toLowerCase();
  567. let score = 0;
  568. for (const term of queryTerms) {
  569. if (lineLower.includes(term)) score++;
  570. }
  571. if (score > bestScore) {
  572. bestScore = score;
  573. bestLine = i;
  574. }
  575. }
  576. const startLine = Math.max(0, bestLine - 1);
  577. const endLine = Math.min(lines.length, bestLine + 2);
  578. let snippet = lines.slice(startLine, endLine).join('\n');
  579. if (snippet.length > maxLen) snippet = snippet.substring(0, maxLen - 3) + "...";
  580. return { line: bestLine + 1, snippet };
  581. }
  582. type SearchResult = { file: string; body: string; score: number; source: "fts" | "vec" };
  583. // Build FTS5 query: phrase-aware with fallback to individual terms
  584. function buildFTS5Query(query: string): string {
  585. const terms = query
  586. .split(/\s+/)
  587. .filter(term => term.length >= 2); // Skip single chars
  588. if (terms.length === 0) return "";
  589. if (terms.length === 1) return `"${terms[0].replace(/"/g, '""')}"`;
  590. // Strategy: exact phrase OR proximity match OR individual terms
  591. // Exact phrase matches rank highest, then close proximity, then any term
  592. const phrase = `"${query.replace(/"/g, '""')}"`;
  593. const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
  594. // FTS5 NEAR syntax: NEAR(term1 term2, distance)
  595. const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
  596. const orTerms = quotedTerms.join(' OR ');
  597. // Exact phrase > proximity > any term
  598. return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
  599. }
  600. // Normalize BM25 score to 0-1 range using sigmoid
  601. function normalizeBM25(score: number): number {
  602. // BM25 scores are negative in SQLite (lower = better)
  603. // Typical range: -15 (excellent) to -2 (weak match)
  604. // Map to 0-1 where higher is better
  605. const absScore = Math.abs(score);
  606. // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
  607. return 1 / (1 + Math.exp(-(absScore - 5) / 3));
  608. }
  609. function searchFTS(db: Database, query: string, limit: number = 20): SearchResult[] {
  610. const ftsQuery = buildFTS5Query(query);
  611. if (!ftsQuery) return [];
  612. // BM25 weights: name=10, body=1 (title matches ranked higher)
  613. const stmt = db.prepare(`
  614. SELECT d.filepath, d.body, bm25(documents_fts, 10.0, 1.0) as score
  615. FROM documents_fts f
  616. JOIN documents d ON d.id = f.rowid
  617. WHERE documents_fts MATCH ? AND d.active = 1
  618. ORDER BY score
  619. LIMIT ?
  620. `);
  621. const results = stmt.all(ftsQuery, limit) as { filepath: string; body: string; score: number }[];
  622. return results.map(r => ({
  623. file: r.filepath,
  624. body: r.body,
  625. score: normalizeBM25(r.score),
  626. source: "fts" as const,
  627. }));
  628. }
  629. async function searchVec(db: Database, query: string, model: string, limit: number = 20): Promise<SearchResult[]> {
  630. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  631. if (!tableExists) return [];
  632. const queryEmbedding = await getEmbedding(query, model, true);
  633. const queryVec = new Float32Array(queryEmbedding);
  634. // Join: documents -> content_vectors -> vectors_vec
  635. const stmt = db.prepare(`
  636. SELECT d.filepath, d.body, vec.distance
  637. FROM vectors_vec vec
  638. JOIN documents d ON d.hash = vec.hash
  639. WHERE vec.embedding MATCH ? AND k = ? AND d.active = 1
  640. ORDER BY vec.distance
  641. `);
  642. const results = stmt.all(queryVec, limit) as { filepath: string; body: string; distance: number }[];
  643. return results.map(r => ({
  644. file: r.filepath,
  645. body: r.body,
  646. score: 1 / (1 + r.distance),
  647. source: "vec" as const,
  648. }));
  649. }
  650. function normalizeScores(results: SearchResult[]): SearchResult[] {
  651. if (results.length === 0) return results;
  652. const maxScore = Math.max(...results.map(r => r.score));
  653. const minScore = Math.min(...results.map(r => r.score));
  654. const range = maxScore - minScore || 1;
  655. return results.map(r => ({ ...r, score: (r.score - minScore) / range }));
  656. }
  657. // Reciprocal Rank Fusion: combines multiple ranked lists
  658. // RRF score = sum(1 / (k + rank)) across all lists where doc appears
  659. // k=60 is standard, provides good balance between top and lower ranks
  660. type RankedResult = { file: string; body: string; score: number };
  661. function reciprocalRankFusion(
  662. resultLists: RankedResult[][],
  663. weights: number[] = [], // Weight per result list (default 1.0)
  664. k: number = 60
  665. ): RankedResult[] {
  666. const scores = new Map<string, { score: number; body: string; bestRank: number }>();
  667. for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
  668. const results = resultLists[listIdx];
  669. const weight = weights[listIdx] ?? 1.0;
  670. for (let rank = 0; rank < results.length; rank++) {
  671. const doc = results[rank];
  672. const rrfScore = weight / (k + rank + 1);
  673. const existing = scores.get(doc.file);
  674. if (existing) {
  675. existing.score += rrfScore;
  676. existing.bestRank = Math.min(existing.bestRank, rank);
  677. } else {
  678. scores.set(doc.file, { score: rrfScore, body: doc.body, bestRank: rank });
  679. }
  680. }
  681. }
  682. // Add bonus for best rank: documents that ranked #1-3 in any list get a boost
  683. // This prevents dilution of exact matches by expansion queries
  684. return Array.from(scores.entries())
  685. .map(([file, { score, body, bestRank }]) => {
  686. let bonus = 0;
  687. if (bestRank === 0) bonus = 0.05; // Ranked #1 somewhere
  688. else if (bestRank <= 2) bonus = 0.02; // Ranked top-3 somewhere
  689. return { file, body, score: score + bonus };
  690. })
  691. .sort((a, b) => b.score - a.score);
  692. }
  693. type OutputFormat = "cli" | "csv" | "md" | "xml";
  694. type OutputOptions = {
  695. format: OutputFormat;
  696. full: boolean;
  697. limit: number;
  698. minScore: number;
  699. };
  700. // Extract snippet with more context lines for CLI display
  701. function extractSnippetWithContext(body: string, query: string, contextLines = 3): { line: number; snippet: string; hasMatch: boolean } {
  702. const lines = body.split('\n');
  703. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  704. let bestLine = 0, bestScore = -1;
  705. for (let i = 0; i < lines.length; i++) {
  706. const lineLower = lines[i].toLowerCase();
  707. let score = 0;
  708. for (const term of queryTerms) {
  709. if (lineLower.includes(term)) score++;
  710. }
  711. if (score > bestScore) {
  712. bestScore = score;
  713. bestLine = i;
  714. }
  715. }
  716. // No query match found - return beginning of file
  717. if (bestScore <= 0) {
  718. const preview = lines.slice(0, contextLines * 2).join('\n').trim();
  719. return { line: 1, snippet: preview, hasMatch: false };
  720. }
  721. const startLine = Math.max(0, bestLine - contextLines);
  722. const endLine = Math.min(lines.length, bestLine + contextLines + 1);
  723. const snippet = lines.slice(startLine, endLine).join('\n').trim();
  724. return { line: bestLine + 1, snippet, hasMatch: true };
  725. }
  726. // Highlight query terms in text (skip short words < 3 chars)
  727. function highlightTerms(text: string, query: string): string {
  728. if (!useColor) return text;
  729. const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  730. let result = text;
  731. for (const term of terms) {
  732. const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  733. result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
  734. }
  735. return result;
  736. }
  737. // Format score with color based on value
  738. function formatScore(score: number): string {
  739. const pct = (score * 100).toFixed(0).padStart(3);
  740. if (!useColor) return `${pct}%`;
  741. if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
  742. if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
  743. return `${c.dim}${pct}%${c.reset}`;
  744. }
  745. // Shorten filepath for display
  746. function shortPath(filepath: string): string {
  747. const cwd = getPwd();
  748. if (filepath.startsWith(cwd)) {
  749. return filepath.slice(cwd.length + 1);
  750. }
  751. // Show last 2 path components
  752. const parts = filepath.split('/');
  753. if (parts.length > 2) {
  754. return '.../' + parts.slice(-2).join('/');
  755. }
  756. return filepath;
  757. }
  758. function outputResults(results: { file: string; body: string; score: number }[], query: string, opts: OutputOptions): void {
  759. const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
  760. if (filtered.length === 0) {
  761. console.log("No results found above minimum score threshold.");
  762. return;
  763. }
  764. if (opts.format === "cli") {
  765. for (let i = 0; i < filtered.length; i++) {
  766. const row = filtered[i];
  767. const { line, snippet, hasMatch } = extractSnippetWithContext(row.body, query, 2);
  768. // Header: score and filename
  769. const score = formatScore(row.score);
  770. const path = shortPath(row.file);
  771. const lineInfo = hasMatch ? `:${line}` : "";
  772. console.log(`${c.bold}${score}${c.reset} ${c.cyan}${path}${c.dim}${lineInfo}${c.reset}`);
  773. // Snippet with highlighting
  774. const highlighted = highlightTerms(snippet, query);
  775. const indented = highlighted.split('\n').map(l => ` ${c.dim}│${c.reset} ${l}`).join('\n');
  776. console.log(indented);
  777. if (i < filtered.length - 1) console.log();
  778. }
  779. } else if (opts.format === "md") {
  780. for (const row of filtered) {
  781. if (opts.full) {
  782. console.log(`---\n# ${row.file}\n\n${row.body}\n`);
  783. } else {
  784. const { snippet } = extractSnippet(row.body, query);
  785. console.log(`---\n# ${row.file}\n\n${snippet}\n`);
  786. }
  787. }
  788. } else if (opts.format === "xml") {
  789. for (const row of filtered) {
  790. if (opts.full) {
  791. console.log(`<file name="${row.file}">\n${row.body}\n</file>\n`);
  792. } else {
  793. const { snippet } = extractSnippet(row.body, query);
  794. console.log(`<file name="${row.file}">\n${snippet}\n</file>\n`);
  795. }
  796. }
  797. } else {
  798. // CSV format
  799. console.log("score,file,line,snippet");
  800. for (const row of filtered) {
  801. const { line, snippet } = extractSnippet(row.body, query);
  802. const content = opts.full ? row.body : snippet;
  803. console.log(`${row.score.toFixed(4)},${escapeCSV(row.file)},${line},${escapeCSV(content)}`);
  804. }
  805. }
  806. }
  807. function search(query: string, opts: OutputOptions): void {
  808. const db = getDb();
  809. const results = searchFTS(db, query, 50);
  810. db.close();
  811. if (results.length === 0) {
  812. console.log("No results found.");
  813. return;
  814. }
  815. outputResults(results, query, opts);
  816. }
  817. async function vectorSearch(query: string, opts: OutputOptions, model: string = DEFAULT_EMBED_MODEL): Promise<void> {
  818. const db = getDb();
  819. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  820. if (!tableExists) {
  821. console.error("Vector index not found. Run 'qmd vector' first to create embeddings.");
  822. db.close();
  823. return;
  824. }
  825. // Expand query to multiple variations
  826. const queries = await expandQuery(query);
  827. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  828. // Collect results from all query variations
  829. const allResults = new Map<string, { file: string; body: string; score: number }>();
  830. for (const q of queries) {
  831. const vecResults = await searchVec(db, q, model, 20);
  832. for (const r of vecResults) {
  833. const existing = allResults.get(r.file);
  834. if (!existing || r.score > existing.score) {
  835. allResults.set(r.file, { file: r.file, body: r.body, score: r.score });
  836. }
  837. }
  838. }
  839. db.close();
  840. // Sort by max score and limit to requested count
  841. const results = Array.from(allResults.values())
  842. .sort((a, b) => b.score - a.score)
  843. .slice(0, opts.limit);
  844. if (results.length === 0) {
  845. console.log("No results found.");
  846. return;
  847. }
  848. outputResults(results, query, { ...opts, limit: results.length }); // Already limited
  849. }
  850. async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL): Promise<string[]> {
  851. process.stderr.write("Generating query variations...\n");
  852. const prompt = `Generate 3 search query variations to find documents about this topic.
  853. IMPORTANT: Keep multi-word phrases intact if they look like names (e.g., "Build a Business" should stay as "Build a Business", not "create a company").
  854. Query: "${query}"
  855. Output 3 variations, one per line:`;
  856. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  857. method: "POST",
  858. headers: { "Content-Type": "application/json" },
  859. body: JSON.stringify({
  860. model,
  861. prompt,
  862. stream: false,
  863. think: false, // Disable thinking mode for qwen3 models
  864. options: { num_predict: 150 },
  865. }),
  866. });
  867. if (!response.ok) {
  868. const errorText = await response.text();
  869. if (errorText.includes("not found") || errorText.includes("does not exist")) {
  870. await ensureModelAvailable(model);
  871. return expandQuery(query, model);
  872. }
  873. // Fall back to original query if expansion fails
  874. return [query];
  875. }
  876. const data = await response.json() as { response: string };
  877. const lines = data.response.trim().split('\n')
  878. .map(l => l.replace(/^[\d\.\-\*\"\s]+/, '').replace(/["\s]+$/, '').trim())
  879. .filter(l => l.length > 0 && !l.startsWith('<'))
  880. .slice(0, 1); // Only 1 expanded query to preserve original query signal
  881. // Original query + expansions (original gets 2x weight in RRF)
  882. const allQueries = [query, ...lines];
  883. process.stderr.write(`Queries:\n - ${allQueries.join('\n - ')}\n`);
  884. return allQueries;
  885. }
  886. async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
  887. const db = getDb();
  888. // Expand query to multiple variations
  889. const queries = await expandQuery(query);
  890. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  891. // Collect ranked result lists for RRF fusion
  892. const rankedLists: RankedResult[][] = [];
  893. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  894. for (const q of queries) {
  895. // FTS search - get ranked results
  896. const ftsResults = searchFTS(db, q, 20);
  897. if (ftsResults.length > 0) {
  898. rankedLists.push(ftsResults.map(r => ({ file: r.file, body: r.body, score: r.score })));
  899. }
  900. // Vector search - get ranked results
  901. if (hasVectors) {
  902. const vecResults = await searchVec(db, q, embedModel, 20);
  903. if (vecResults.length > 0) {
  904. rankedLists.push(vecResults.map(r => ({ file: r.file, body: r.body, score: r.score })));
  905. }
  906. }
  907. }
  908. // Apply Reciprocal Rank Fusion to combine all ranked lists
  909. // Give 2x weight to original query results (first 2 lists: FTS + vector)
  910. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  911. const fused = reciprocalRankFusion(rankedLists, weights);
  912. const candidates = fused.slice(0, 30); // Over-retrieve for reranking
  913. if (candidates.length === 0) {
  914. console.log("No results found.");
  915. db.close();
  916. return;
  917. }
  918. // Rerank with the original query
  919. const reranked = await rerank(
  920. query,
  921. candidates.map(c => ({ file: c.file, text: c.body })),
  922. rerankModel
  923. );
  924. db.close();
  925. // Blend RRF position score with reranker score using position-aware weights
  926. // Top retrieval results get more protection from reranker disagreement
  927. const bodyMap = new Map(candidates.map(c => [c.file, c.body]));
  928. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1])); // 1-indexed rank
  929. const finalResults = reranked.map(r => {
  930. const rrfRank = rrfRankMap.get(r.file) || 30;
  931. // Position-aware blending: top retrieval results preserved more
  932. // Rank 1-3: 75% RRF, 25% reranker (trust retrieval for exact matches)
  933. // Rank 4-10: 60% RRF, 40% reranker
  934. // Rank 11+: 40% RRF, 60% reranker (trust reranker for lower-ranked)
  935. let rrfWeight: number;
  936. if (rrfRank <= 3) {
  937. rrfWeight = 0.75;
  938. } else if (rrfRank <= 10) {
  939. rrfWeight = 0.60;
  940. } else {
  941. rrfWeight = 0.40;
  942. }
  943. const rrfScore = 1 / rrfRank; // Position-based: 1, 0.5, 0.33...
  944. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  945. return {
  946. file: r.file,
  947. body: bodyMap.get(r.file) || "",
  948. score: blendedScore,
  949. };
  950. }).sort((a, b) => b.score - a.score);
  951. outputResults(finalResults, query, opts);
  952. }
  953. // Parse CLI options
  954. function parseOptions(args: string[], defaultMinScore: number = 0): { opts: OutputOptions; query: string } {
  955. let format: OutputFormat = "cli";
  956. let full = false;
  957. let limit = 5;
  958. let minScore = defaultMinScore;
  959. const queryParts: string[] = [];
  960. for (let i = 0; i < args.length; i++) {
  961. const arg = args[i];
  962. if (arg === "-n" && i + 1 < args.length) {
  963. limit = parseInt(args[++i], 10) || 5;
  964. } else if (arg === "--min-score" && i + 1 < args.length) {
  965. minScore = parseFloat(args[++i]) || defaultMinScore;
  966. } else if (arg === "--full") {
  967. full = true;
  968. } else if (arg === "-csv" || arg === "--csv") {
  969. format = "csv";
  970. } else if (arg === "-md" || arg === "--md") {
  971. format = "md";
  972. } else if (arg === "-xml" || arg === "--xml") {
  973. format = "xml";
  974. } else if (!arg.startsWith("-")) {
  975. queryParts.push(arg);
  976. }
  977. }
  978. return {
  979. opts: { format, full, limit, minScore },
  980. query: queryParts.join(" "),
  981. };
  982. }
  983. // Parse global options and extract remaining args
  984. function parseGlobalOptions(args: string[]): string[] {
  985. const remaining: string[] = [];
  986. for (let i = 0; i < args.length; i++) {
  987. if (args[i] === "--index" && i + 1 < args.length) {
  988. customIndexName = args[++i];
  989. } else {
  990. remaining.push(args[i]);
  991. }
  992. }
  993. return remaining;
  994. }
  995. // Main CLI
  996. const rawArgs = process.argv.slice(2);
  997. const args = parseGlobalOptions(rawArgs);
  998. if (args.length === 0) {
  999. console.log("Usage:");
  1000. console.log(" qmd add [--drop] [glob] - Add/update collection from $PWD (default: **/*.md)");
  1001. console.log(" qmd collections - List all collections");
  1002. console.log(" qmd update-all - Re-index all collections");
  1003. console.log(" qmd embed [-f] - Create vector embeddings for all content");
  1004. console.log(" qmd search <query> - Full-text search (BM25)");
  1005. console.log(" qmd vsearch <query> - Vector similarity search");
  1006. console.log(" qmd query <query> - Combined search with query expansion + reranking");
  1007. console.log("");
  1008. console.log("Global options:");
  1009. console.log(" --index <name> - Use custom index name (default: index)");
  1010. console.log("");
  1011. console.log("Search options:");
  1012. console.log(" -n <num> - Number of results (default: 5)");
  1013. console.log(" --min-score <num> - Minimum similarity score");
  1014. console.log(" --full - Output full document instead of snippet");
  1015. console.log(" -csv - CSV output (default is colorized CLI)");
  1016. console.log(" -md - Markdown output");
  1017. console.log(" -xml - XML output");
  1018. console.log("");
  1019. console.log("Environment:");
  1020. console.log(" OLLAMA_URL - Ollama server URL (default: http://localhost:11434)");
  1021. console.log("");
  1022. console.log("Models:");
  1023. console.log(` Embedding: ${DEFAULT_EMBED_MODEL}`);
  1024. console.log(` Reranking: ${DEFAULT_RERANK_MODEL}`);
  1025. console.log("");
  1026. console.log(`Index: ${getDbPath()}`);
  1027. process.exit(1);
  1028. }
  1029. const cmd = args[0];
  1030. if (cmd === "add") {
  1031. const addArgs = args.slice(1);
  1032. const drop = addArgs.includes("--drop");
  1033. const globArg = addArgs.find(a => !a.startsWith("-"));
  1034. // Treat "." as "use default glob in current directory"
  1035. const globPattern = (!globArg || globArg === ".") ? DEFAULT_GLOB : globArg;
  1036. if (drop) {
  1037. await dropCollection(globPattern);
  1038. } else {
  1039. await indexFiles(globPattern);
  1040. }
  1041. } else if (cmd === "collections") {
  1042. listCollections();
  1043. } else if (cmd === "update-all") {
  1044. await updateAllCollections();
  1045. } else if (cmd === "embed") {
  1046. const embedArgs = args.slice(1);
  1047. const force = embedArgs.includes("-f") || embedArgs.includes("--force");
  1048. await vectorIndex(DEFAULT_EMBED_MODEL, force);
  1049. } else if (cmd === "search") {
  1050. const { opts, query } = parseOptions(args.slice(1), 0);
  1051. if (!query) {
  1052. console.error("Usage: qmd search [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1053. process.exit(1);
  1054. }
  1055. search(query, opts);
  1056. } else if (cmd === "vsearch") {
  1057. const { opts, query } = parseOptions(args.slice(1), 0.3);
  1058. if (!query) {
  1059. console.error("Usage: qmd vsearch [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1060. process.exit(1);
  1061. }
  1062. await vectorSearch(query, opts);
  1063. } else if (cmd === "query") {
  1064. const { opts, query } = parseOptions(args.slice(1), 0);
  1065. if (!query) {
  1066. console.error("Usage: qmd query [-n num] [--min-score num] [--full] [-csv|-md|-xml] <query>");
  1067. process.exit(1);
  1068. }
  1069. await querySearch(query, opts);
  1070. } else {
  1071. console.error(`Unknown command: ${cmd}`);
  1072. console.error("Run 'qmd' without arguments for usage.");
  1073. process.exit(1);
  1074. }