vector_search
Vector Search
Semantic search using OpenAI embeddings for finding similar notes by meaning.
Overview
Vector search enables semantic similarity matching - finding notes by meaning rather than exact keywords. The similarNotes GraphQL query returns notes that are semantically similar to a given note.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Similar Notes Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Note: "/my-article" │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ In-Memory Cache (NoteViews) ││
│ │ - Note with Embedding []float32 (1536 dimensions) ││
│ │ - Loaded on startup via SQL JOIN ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ Cosine Similarity Calculation (in-memory) │
│ │ │
│ ▼ │
│ Top N Similar Notes (filtered by CanReadNote) │
│ │
└─────────────────────────────────────────────────────────────────┘
Database Schema
Embeddings are cached in SQLite:
create table note_version_embeddings (
version_id integer primary key references note_versions(id) on delete cascade,
embedding blob not null,
model_id integer not null,
content_hash blob not null,
tokens integer not null,
created_at datetime not null default (datetime('now'))
);
create index idx_note_version_embeddings_model_id on note_version_embeddings(model_id);
Fields
| Field | Type | Description |
|---|---|---|
version_id |
integer | FK to note_versions.id |
embedding |
blob | float32 array as bytes (1536 dims = 6KB) |
model_id |
integer | Model constant (1small, 2large, 3=ada) |
content_hash |
blob | SHA256 of title+content to detect changes |
tokens |
integer | Tokens consumed to generate this embedding |
created_at |
datetime | When embedding was generated |
Embedding Generation
Background Job
Embeddings are generated asynchronously via goqite queue:
Note Created/Updated (HandleLatestNotesAfterSave)
│
▼
Enqueue GenerateNoteVersionEmbedding job
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Background Worker │
│ 1. Get note from LatestNoteViews cache │
│ 2. Calculate content hash (SHA256 of title+content) │
│ 3. Check if embedding exists with same hash → skip │
│ 4. Call OpenAI embeddings API │
│ 5. Store in note_version_embeddings │
└─────────────────────────────────────────────────────────────┘
Cronjob for Bulk Regeneration
The regenerate_note_embeddings cronjob runs:
- Daily at 3:00 AM
- On server startup (ExecuteAfterStart: true)
It compares content hashes and enqueues jobs for notes with stale/missing embeddings.
In-Memory Cache
Embeddings are loaded into NoteView.Embedding field via SQL JOIN when notes are loaded:
-- AllLatestNotes query includes embedding
select value as path, p.id as path_id, v.id as version_id, content, v.created_at, e.embedding
from note_paths p
join note_versions v on p.id = v.path_id and p.version_count = v.version
left join note_version_embeddings e on v.id = e.version_id
where p.hidden_by is null;
This means:
- No database queries during
similarNotesrequests - Memory usage: ~6MB for 1000 notes (1536 floats × 4 bytes × 1000)
- Embeddings are refreshed when notes are reloaded
GraphQL API
similarNotes Query
input SimilarNotesInput {
noteId: String! # Note permalink
limit: Int # Max results (default: 5, max: 20)
}
type SimilarNote {
score: Float! # 0-1, higher is more similar
note: PublicNote!
}
type Query {
similarNotes(input: SimilarNotesInput!): [SimilarNote!]!
}
Example
query {
similarNotes(input: { noteId: "/my-article", limit: 5 }) {
score
note {
id
title
path
}
}
}
Configuration
Feature Flag
FEATURES='{"vector_search": {"enabled": true, "model": "text-embedding-3-small"}}'
Environment Variables
| Variable | Required | Description |
|---|---|---|
FEATURES |
No | JSON with feature configuration |
OPENAI_API_KEY |
When enabled | OpenAI API key for embeddings |
Models
| Model | ID | Dimensions | Cost |
|---|---|---|---|
text-embedding-3-small |
1 | 1536 | $0.02/1M tokens |
text-embedding-3-large |
2 | 3072 | $0.13/1M tokens |
text-embedding-ada-002 |
3 | 1536 | $0.10/1M tokens |
Implementation Details
Package Structure
internal/
├── features/
│ ├── features.go # Features struct, Parse()
│ └── vector_search.go # VectorSearchConfig, EmbeddingModel
├── openai/
│ └── client.go # OpenAI client wrapper
├── case/
│ ├── similarnotes/
│ │ └── resolve.go # similarNotes query resolver
│ └── backjob/
│ └── generatenoteversionembedding/
│ ├── job.go # Job registration
│ └── resolve.go # Embedding generation logic
└── noteloader/
└── loader.go # Loads embeddings via SQL JOIN
Cosine Similarity
func cosineSimilarity(a, b []float32) float64 {
var dotProduct, normA, normB float64
for i := range a {
dotProduct += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}
Cost Estimation
OpenAI embedding costs for text-embedding-3-small:
| Notes | Avg Tokens/Note | Total Tokens | Cost |
|---|---|---|---|
| 100 | 500 | 50,000 | $0.001 |
| 1,000 | 500 | 500,000 | $0.01 |
| 10,000 | 500 | 5,000,000 | $0.10 |
Graceful Degradation
- Vector search disabled:
similarNotesreturns empty array - Note has no embedding: Note is excluded from results
- OpenAI API error: Job is retried by goqite
Future Improvements
- Hybrid search - Combine vector similarity with bleve text search
- Batch embedding generation - Process multiple notes in one API call
- Local embeddings - Use local model to avoid API costs
- Semantic query search - Generate query embedding for search