mirror of
https://github.com/ashwin271/obsidian-vector-search.git
synced 2026-07-22 05:35:32 +00:00
First commit
This commit is contained in:
commit
3b27bc8e7f
15 changed files with 2882 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
72
README.md
Normal file
72
README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Obsidian Vector Search Plugin
|
||||
|
||||
This plugin adds semantic search capabilities to Obsidian using Ollama's embedding API. It allows you to find semantically similar notes based on content rather than just keyword matching.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 Semantic search across your entire vault
|
||||
- 🤖 Powered by Ollama's embedding model
|
||||
- 📊 Configurable similarity threshold
|
||||
- 🚀 Fast local search once embeddings are generated
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Ollama](https://ollama.ai/) installed and running locally
|
||||
- The `nomic-embed-text` model pulled in Ollama
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone this repo to your `.obsidian/plugins/` folder
|
||||
2. Install dependencies: `npm install`
|
||||
3. Build the plugin: `npm run dev`
|
||||
4. Enable the plugin in Obsidian's settings
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Initial Setup**
|
||||
|
||||
- Go to Settings > Vector Search
|
||||
- Configure your Ollama URL (default: http://localhost:11434)
|
||||
- Set your desired similarity threshold (0-1)
|
||||
|
||||
2. **Building the Index**
|
||||
|
||||
- Click the vector search icon in the ribbon
|
||||
- Wait for all notes to be processed (progress will be shown)
|
||||
|
||||
3. **Searching**
|
||||
- Select any text in a note
|
||||
- Use the command "Find Similar Notes" (or set up a hotkey)
|
||||
- View results in the popup modal
|
||||
|
||||
## How it Works
|
||||
|
||||
1. The plugin creates vector embeddings for all your markdown notes using Ollama
|
||||
2. When you search, it:
|
||||
- Creates an embedding for your selected text
|
||||
- Uses cosine similarity to find the most similar notes
|
||||
- Shows results above your configured threshold
|
||||
|
||||
## Development
|
||||
|
||||
- `npm run dev` - Start compilation in watch mode
|
||||
- `npm run build` - Build the plugin
|
||||
- `npm test` - Run tests
|
||||
|
||||
## Logs
|
||||
|
||||
The plugin logs all API calls and operations to the Developer Console (Ctrl+Shift+I or Cmd+Option+I on Mac). This includes:
|
||||
|
||||
- Embedding API requests
|
||||
- Index building progress
|
||||
- File processing status
|
||||
- Any errors or issues
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"ollamaURL": "http://localhost:11434",
|
||||
"searchThreshold": 0.7
|
||||
}
|
||||
```
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
221
main.ts
Normal file
221
main.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import {
|
||||
App,
|
||||
Editor,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
TFile
|
||||
} from 'obsidian';
|
||||
|
||||
interface MyPluginSettings {
|
||||
ollamaURL: string;
|
||||
searchThreshold: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
ollamaURL: 'http://localhost:11434',
|
||||
searchThreshold: 0.8
|
||||
}
|
||||
|
||||
export default class VectorSearchPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
vectorStore: Map<string, number[]> = new Map();
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// Add a ribbon icon for rebuilding the vector index
|
||||
this.addRibbonIcon('refresh-cw', 'Rebuild Vector Index', async () => {
|
||||
await this.buildVectorIndex();
|
||||
new Notice('Vector index rebuilt!');
|
||||
});
|
||||
|
||||
// Add a command to search similar notes
|
||||
this.addCommand({
|
||||
id: 'search-similar-notes',
|
||||
name: 'Search Similar Notes',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
const selection = editor.getSelection();
|
||||
if (selection) {
|
||||
await this.searchSimilarNotes(selection);
|
||||
} else {
|
||||
new Notice('Please select some text to search');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new VectorSearchSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
// Clean up resources if needed
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
// Function to get embeddings from Ollama
|
||||
async getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
console.log(`[Vector Search] Requesting embedding for text of length ${text.length}`);
|
||||
const response = await fetch(`${this.settings.ollamaURL}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'nomic-embed-text',
|
||||
prompt: text
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`[Vector Search] Successfully received embedding of length ${data.embedding.length}`);
|
||||
return data.embedding;
|
||||
} catch (error) {
|
||||
console.error('[Vector Search] Error getting embedding:', error);
|
||||
new Notice('Error getting embedding from Ollama');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cosine similarity between two vectors
|
||||
cosineSimilarity(vec1: number[], vec2: number[]): number {
|
||||
const dotProduct = vec1.reduce((acc, val, i) => acc + val * vec2[i], 0);
|
||||
const mag1 = Math.sqrt(vec1.reduce((acc, val) => acc + val * val, 0));
|
||||
const mag2 = Math.sqrt(vec2.reduce((acc, val) => acc + val * val, 0));
|
||||
return dotProduct / (mag1 * mag2);
|
||||
}
|
||||
|
||||
async buildVectorIndex() {
|
||||
console.log('[Vector Search] Starting vector index build');
|
||||
this.vectorStore.clear();
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
|
||||
let processed = 0;
|
||||
const total = files.length;
|
||||
|
||||
for (const file of files) {
|
||||
console.log(`[Vector Search] Processing file: ${file.path}`);
|
||||
const content = await this.app.vault.read(file);
|
||||
const embedding = await this.getEmbedding(content);
|
||||
this.vectorStore.set(file.path, embedding);
|
||||
|
||||
processed++;
|
||||
if (processed % 10 === 0) {
|
||||
console.log(`[Vector Search] Progress: ${processed}/${total} files`);
|
||||
new Notice(`Indexing progress: ${processed}/${total} files`);
|
||||
}
|
||||
}
|
||||
console.log('[Vector Search] Completed vector index build');
|
||||
}
|
||||
|
||||
async searchSimilarNotes(query: string) {
|
||||
if (this.vectorStore.size === 0) {
|
||||
new Notice('Vector index is empty. Please rebuild the index first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const queryEmbedding = await this.getEmbedding(query);
|
||||
const results: Array<{path: string, similarity: number}> = [];
|
||||
|
||||
for (const [path, embedding] of this.vectorStore.entries()) {
|
||||
const similarity = this.cosineSimilarity(queryEmbedding, embedding);
|
||||
if (similarity >= this.settings.searchThreshold) {
|
||||
results.push({ path, similarity });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
if (results.length > 0) {
|
||||
new SimilarNotesModal(this.app, results).open();
|
||||
} else {
|
||||
new Notice('No similar notes found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SimilarNotesModal extends Modal {
|
||||
results: Array<{path: string, similarity: number}>;
|
||||
|
||||
constructor(app: App, results: Array<{path: string, similarity: number}>) {
|
||||
super(app);
|
||||
this.results = results;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createEl('h2', {text: 'Similar Notes'});
|
||||
|
||||
const list = contentEl.createEl('ul');
|
||||
for (const result of this.results) {
|
||||
const item = list.createEl('li');
|
||||
const link = item.createEl('a', {
|
||||
text: `${result.path} (${(result.similarity * 100).toFixed(2)}%)`,
|
||||
href: '#'
|
||||
});
|
||||
link.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const file = this.app.vault.getAbstractFileByPath(result.path);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.workspace.activeLeaf.openFile(file);
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class VectorSearchSettingTab extends PluginSettingTab {
|
||||
plugin: VectorSearchPlugin;
|
||||
|
||||
constructor(app: App, plugin: VectorSearchPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ollama URL')
|
||||
.setDesc('URL of your Ollama server')
|
||||
.addText(text => text
|
||||
.setPlaceholder('http://localhost:11434')
|
||||
.setValue(this.plugin.settings.ollamaURL)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ollamaURL = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Search Threshold')
|
||||
.setDesc('Minimum similarity score (0-1) for showing results')
|
||||
.addSlider(slider => slider
|
||||
.setLimits(0, 1, 0.1)
|
||||
.setValue(this.plugin.settings.searchThreshold)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.searchThreshold = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "obsidian-vector-search",
|
||||
"name": "Vector Search",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Semantic search for your notes using Ollama and nomic-embed-text embeddings",
|
||||
"author": "Your Name",
|
||||
"authorUrl": "https://github.com/yourusername",
|
||||
"isDesktopOnly": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
2397
package-lock.json
generated
Normal file
2397
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
8
styles.css
Normal file
8
styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue