Bring the LiteLLM provider in line with the unit-norm contract from
sqlite_search_repository.py (lines 65-67): the cosine-similarity formula
`1 - L²/2` is correct only for unit-normalized vectors. LiteLLM routes to
many backends (Cohere, Vertex, Bedrock, etc.) that do not return normalized
embeddings, so normalize at the provider boundary — same fix shape as the
parallel FastEmbed change in #843.
Also align the response handling with OpenAIEmbeddingProvider:
- attribute access on response items (item.index / item.embedding)
- explicit duplicate-index guard
Tests cover the three behaviors directly (unit norm, zero-vector pass-through,
duplicate-index error) and the existing ordering test now reconstructs the
expected normalized vectors so a normalization regression would be caught.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Add configurable cache_dir, threads, and parallel settings for FastEmbed
to support cloud deployments where defaults fail. Cache embedding providers
at the process level to avoid re-creating heavy ONNX model instances.
- Add semantic_embedding_cache_dir, semantic_embedding_threads, and
semantic_embedding_parallel config fields
- Thread-safe provider cache with double-checked locking in factory
- Forward runtime knobs through to TextEmbedding and embed() calls
- Fix if/elif chain in factory for correct error handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Three changes to surface more answer text in search results:
- Populate matched_chunk_text for FTS-only hybrid results from content_snippet,
preventing fallback to truncated content when vector search has no match
- Increase TOP_CHUNKS_PER_RESULT from 3 to 5, catching answers in deeper chunks
for large notes (~2700 → ~4500 chars of matched context)
- Increase CONTENT_DISPLAY_LIMIT from 2000 to 4000, doubling the safety-net
content truncation for results without matched_chunk
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Two strategies to improve content hit rate when the right document is found:
1. Small notes (<=2000 chars): return full content_snippet as matched_chunk
so the answer is always present for correctly-retrieved small notes
2. Large notes: return top-3 chunks by similarity joined with \n---\n
instead of just the single best chunk (~2700 chars vs ~900 chars)
Also raises CONTENT_DISPLAY_LIMIT from 250 to 2000 for richer FTS results.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
RRF compressed all fused scores to ~0.016, destroying ranking differentiation.
The new formula `max(vec, fts) + FUSION_BONUS * min(vec, fts)` preserves
dominant signals and rewards dual-source agreement.
Changes:
- Remove RRF_K constant; add FUSION_BONUS (0.3) and FTS_GATE_THRESHOLD (0.0)
- Use raw vector similarity scores instead of re-normalizing by vec_max
- Zero-score results now produce zero fused score (no 0.1 weight floor)
- Rename test_hybrid_rrf.py → test_hybrid_fusion.py with updated assertions
- Update docs and docstrings to reflect score-based fusion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
sqlite-vec enforces k <= 4096 for nearest-neighbor queries. Projects with
>4096 vector chunks would crash all vector/hybrid search because
candidate_limit = max(100, (limit + offset) * 10) exceeded this hard limit.
Clamp the knn k in _run_vector_query while keeping the outer SQL LIMIT
unclamped. Only affects SQLite — pgvector has no such constraint.
Fixes#604
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Replace f-string interpolation with parameterized queries for note_types,
search_item_types, and metadata filter paths to prevent SQL injection.
Fixes#591
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>