Status (2026-06): Shipped — unified
corpus.db+ sqlite-vec (schema v3) with hybrid FTS + semantic search (RRF fusion). Default embedding model:paraphrase-multilingual-MiniLM-L12-v2. Cross-file search is off by default; enable Embeddings + FTS in Settings → Vector Search.
Related: enabling_numpy_in_libreoffice.md · numpy-domains.md · multi-document-dev-plan.md · cython-extension.md
WriterAgent can index the sibling files in your document folder and answer “which file has this?” without opening every .odt, .ods, or .docx in the directory.
When Cross-file search is enabled, retrieval is hybrid: every query runs keyword search (FTS5 BM25/NEAR) and semantic vector search in parallel, then Reciprocal Rank Fusion (RRF) merges the ranked lists. Users and agents call search_nearby_files once — there is no per-query choice between grep and embeddings.
When Cross-file search is Off, the document_research agent uses grep_nearby_files for discovery instead. That is a settings choice, not a routing decision inside a single search.
The index lives beside your documents (not in the LibreOffice profile):
/home/user/projects/reporting/ ← document folder
Budget.odt
Notes_v3.odt
writeragent_embeddings/
corpus.db # chunks + FTS5 + vec0 + incremental state
corpus_meta.json # model, dim, chunk_count, updated_at
Linux example: ~/Desktop/Writing/writeragent_embeddings/ when working in ~/Desktop/Writing/.
- Settings → Python — set Python venv path and install packages (see Venv setup and enabling_numpy_in_libreoffice.md).
- Settings → Vector Search — set Cross-file search to Embeddings + FTS (
plugin/embeddings/module.yaml). - Optional: change Embedding Model or enable Enable cross-file rerank (second-stage cross-encoder; off by default).
The first search may return “index building in the background — retry shortly.” Background indexing catches up within minutes.
WriterAgent → Search Nearby Files… opens a modeless dialog (search_ui.py, SearchDialog.xdl) that runs the same hybrid path as the agent tool. It shows cache status, ranked hits, and a Rebuild button to cold-reindex the folder.
Same extensions as list_nearby_files / maintain (embeddings_fs.py):
| Family | Extensions |
|---|---|
| ODF | .odt, .ods, .odp, .odg, .ots, .fods, … |
| OOXML | .docx, .xlsx, .pptx |
| Legacy binary | .doc, .xls, .ppt (headless soffice --convert-to → temp ODF) |
| Plain | .csv, .txt, .rtf |
PDF is not indexed yet.
When the active document has no file:// URL, the listing root is LibreOffice My Documents (Tools → Options → Paths → Work). That applies to search, rebuild, and list_nearby_files.
Vector storage scales with chunk count × embedding dimension:
vector_bytes ≈ num_chunks × dim × 4 (float32)
Default 384-dim models → ~1.5 KiB per chunk plus FTS text in corpus.db. Example: 349 paragraphs ≈ 0.5 MiB vectors (Index size).
Delete <folder>/writeragent_embeddings/ and reopen a document in that folder (or use Rebuild in the search dialog). The next maintain pass cold-builds the index.
| Limit | Detail |
|---|---|
| Scope | One folder per query — no cross-directory search |
| Staleness | Index may lag edits by a few minutes (periodic refresh) |
| Venv | Local sentence-transformers + sqlite-vec required for default hybrid mode |
| Experimental backends | LlamaIndex, Zvec, LanceDB need extra pip packages — see Appendices |
Cross-file search powers the document_research specialized sub-agent (delegated from main chat, Writing plan, and Brainstorming). Librarian mode does not use embeddings — it is onboarding-only (librarian.py).
flowchart LR
User["User task on active doc"]
Main[Main chat]
Outer["Outer document_research"]
Index["Folder index\ncorpus.db"]
Hits["Top-k: doc_url + snippet"]
Inner["Inner sub-agent\none opened file"]
Read["search_in_document / range read"]
Main -->|"delegate"| Outer
Outer -->|"search_nearby_files"| Index
Index --> Hits
Hits -->|"delegate_read_document"| Inner --> Read
Outer tier discovers which sibling file(s) matter. Inner tier reads the opened file with precise tools (search_in_document, get_document_content, Calc ranges, etc.). The index is a router, not a document store.
Controlled by filter_document_research_discovery_tools:
embeddings.folder_search_mode |
Discovery | Always available |
|---|---|---|
none (Off, default) |
grep_nearby_files |
list_nearby_files, delegate_read_document |
hybrid (Embeddings + FTS) |
search_nearby_files |
same |
llama_index |
search_nearby_files |
same |
zvec (experimental) |
search_nearby_files |
same |
lancedb (experimental) |
search_nearby_files |
same |
Workflow hints (get_document_research_workflow_hint):
- Off:
list_nearby_files→grep_nearby_files→delegate_read_document - Index on:
list_nearby_files→search_nearby_files→delegate_read_document→ innersearch_in_documenton snippet or topic
search_embeddings is a legacy hidden tool; use search_nearby_files.
search_nearby_files returns:
{"doc_url": "file:///…/notes.odt", "score": 0.85, "snippet": "…passage…", "para_index": 12}snippet— for multi-chunk paragraphs, the full ODF paragraph (sibling sub-chunks merged bypara_index); otherwise the embedded chunk fromchunks.body(up to 512 characters).para_index— weak hint (ODF extract ordinal). Do not treat as an exact LibreOffice jump target.char_start/char_end— stored internally only; not returned in hits.
After a hit, the inner agent should search_in_document for the snippet text or query topic — not blind offset reads.
The expensive case is many documents in one folder, not one open file.
Without an index, the outer document_research agent lists siblings, guesses filenames from vague language, opens candidates, and greps each one. That is slow, token-heavy, and weak on paraphrase (“remote work policy” vs “WFH guidelines” in Notes_v3.odt).
Why office documents differ from code {#why-embeddings-semantic-search-vs-pure-lexicalgrep-and-why-the-difference-is-bigger-for-office-documents-than-code}
- Code is literal and structured — function names, symbols, and
grepare usually enough. Paraphrase is rare. - Office documents (Writer prose, Calc labels, policy files, research notes) use natural language, unhelpful filenames, and varied wording for the same idea.
WriterAgent’s ODF files are ZIP archives; opening and grepping every sibling repeats unzip + XML parse cost. A folder index ranks files before that cost. Industry pattern for large corpora: combine keyword and semantic retrieval — which is exactly what hybrid RRF does.
| Query style | Example | Hybrid behavior |
|---|---|---|
| Short keywords | grammar, MCP, web search |
FTS leg recalls literal matches |
| Paraphrase | proofreader called back by linguistic subsystem |
Vector leg recalls meaning |
Both agree (matched_by: fts + vec) |
Distinctive topic | High-confidence file routing |
Users do not choose grep vs vectors per query when hybrid is on — both legs always run.
| Not this | Why |
|---|---|
| Sidebar chat history | Separate SQLite (writeragent_history.db) |
| In-document search replacement | search_in_document, outline, sheet nav stay primary inside one file |
| Global machine-wide index | One cache per document directory only |
| Optional in-document RAG on send | Secondary future idea (Within-document retrieval) |
flowchart LR
Query[User query]
FTS[FTS5 BM25/NEAR]
Vec[vec0 kNN]
RRF[RRF fusion]
Parent[Parent paragraph merge]
Rerank[Optional cross-encoder rerank]
Hits[Top-k hits]
Query --> FTS
Query --> Vec
FTS --> RRF
Vec --> RRF
RRF --> Parent
Parent --> Rerank
Rerank --> Hits
- Encode query —
SentenceTransformerin the embeddings venv (embeddings_index.py). - FTS leg — BM25 + optional
NEARon FTS5passages(folder_fts.py). - Vector leg — sqlite-vec
MATCHonvec_chunks_<model_slug>(embeddings_search_graph.py). - RRF fusion — vendored reciprocal rank fusion (
hybrid_rrf.py, from liamca/sqlite-hybrid-search). - Parent merge — widen snippets to full paragraph (
embeddings_parent_hits.py). - Optional rerank — cross-encoder when Enable cross-file rerank is on (
embeddings_cross_encoder_rerank.py).
Retrieval pool (hybrid_retrieval_pool.py): over-fetch min(max(k×4, 20), 50) before fusion truncate / rerank — shared across hybrid backends.
No chat LLM in retrieval. The sidebar model only sees the final hit list after search completes.
Implementation: embeddings_hybrid_search.py, host RPC embeddings_service.hybrid_search, tool document_research_fts_tool.py.
From plugin/embeddings/module.yaml (Vector Search tab):
| Config key | UI label | Default | Notes |
|---|---|---|---|
embeddings.folder_search_mode |
Cross-file search | none (Off) |
hybrid, llama_index, zvec, lancedb |
embeddings.embedding_model |
Embedding Model | paraphrase-multilingual-MiniLM-L12-v2 |
HuggingFace id (local provider) |
embeddings.folder_rerank_enabled |
Enable cross-file rerank | false |
Cross-encoder after RRF |
embeddings.folder_rerank_model |
Rerank model | cross-encoder/ms-marco-MiniLM-L-6-v2 |
Or BAAI/bge-reranker-v2-m3 (~2.3 GB) |
LlamaIndex (llama_index) reuses the same corpus.db and tool surface as hybrid but runs stock QueryFusionRetriever + optional SentenceTransformerRerank — see Appendix A: LlamaIndex backend for design, eval commands, and Phase 2 roadmap.
Zvec (zvec) replaces the sqlite hybrid storage and query engine with Alibaba’s in-process vector DB — separate writeragent_embeddings/zvec/ store, side-by-side with corpus.db. See Appendix B: Zvec backend for design, comparison to LlamaIndex, eval, and roadmap.
embedding_provider in config.py defaults to local — only local sentence-transformers is implemented today. HTTP embed APIs are deferred (Appendix D).
When any index mode is on, background maintain builds FTS + vectors in one store (embeddings_indexer.py).
flowchart TB
subgraph host [LO host]
Outer["document_research / search UI"]
Cache["embeddings_cache.py\nembeddings_indexer.py"]
RPC["embeddings_service.py"]
end
subgraph venv [Embeddings venv worker]
ST["SentenceTransformer"]
Hybrid["hybrid_search / maintain"]
DB["corpus.db I/O"]
end
Outer --> Cache --> RPC
RPC -->|"shared Pickle5 IPC"| ST
ST --> Hybrid --> DB
Hybrid -->|"hits, counts"| RPC
NumPy, sqlite-vec, and sentence-transformers run only in the user venv subprocess (PythonWorkerManager) over the shared Pickle5 framing helpers in plugin/scripting/ipc.py. LibreOffice’s embedded Python stays stdlib — no 50–100 MB science stack in the OXT. See enabling_numpy_in_libreoffice.md.
| Layer | Responsibility |
|---|---|
| Host | Folder keys, cache paths, enqueue maintain, heartbeat RPC, corpus_meta.json orchestration |
| Venv (trusted modules) | Batch embed, sqlite-vec DML, LangGraph ingest/search, hybrid RRF — outside the LLM AST sandbox |
Trusted stubs bypass the sandbox via venv_sandbox.py (_is_trusted_embeddings_stub).
Embeddings use WORKER_POOL_EMBEDDINGS — a second warm venv child isolated from Calc =PYTHON() and chat scripts. Long folder re-embed jobs do not block user NumPy work.
| Pool | Use |
|---|---|
WORKER_POOL_DEFAULT |
Calc, chat run_venv_python_script |
WORKER_POOL_EMBEDDINGS |
Folder maintain, search_nearby_files, Search dialog |
Same scripting.python_venv_path; uses the single internal long trusted budget (via embeddings_worker_timeout_sec which delegates to LONG_TRUSTED_WORKER_TIMEOUT_SEC).
Indexing runs on a background maintenance worker — not inside the agent tool loop.
| Trigger | Source |
|---|---|
| Periodic tick | Every 300 s for active doc’s folder (embeddings_periodic.py) |
| document_research start | specialized_base.py |
| Empty/stale cache on search | document_research_fts_tool.py |
One job per folder key (_inflight guard in embeddings_indexer.py).
| Mode | When | Work |
|---|---|---|
| Cold build | No cache or model changed | Index all siblings |
| Incremental | Cache exists | mtime vs last_indexed_at → content_hash diff → embed changed chunks only |
Ingest batches: EMBEDDINGS_INGEST_BATCH_SIZE (64) chunks per embed window (embeddings_ingest_graph.py).
search_nearby_files never blocks on embed completion — it reads whatever is on disk and enqueues maintain if needed.
| Module | Role |
|---|---|
embeddings_cache.py |
Folder keys, paths, legacy store removal |
embeddings_indexer.py |
Background enqueue + _inflight guard |
embeddings_fs.py |
Folder scan + ODF/OOXML extract (no UNO) |
embeddings_locale.py |
Document default + run-level BCP-47 from ODF/OOXML for prose sentence breaking |
embeddings_folder_maintain.py |
Cold/incremental maintain loop |
embeddings_periodic.py |
Periodic folder tick |
document_research_fts_tool.py |
search_nearby_files tool |
embeddings_hybrid_search.py |
Hybrid corpus search |
embeddings_sqlite.py |
corpus.db schema |
embeddings_ingest_graph.py |
LangGraph: split → embed → upsert |
embeddings_search_graph.py |
LangGraph: vec0 retrieve → MMR (vec-only) |
embeddings_index.py |
Venv RPC facades |
embeddings_service.py |
Host index/search/stats RPC |
embedding_client.py |
Host embed_texts() RPC |
One writeragent_embeddings/ per document directory, beside the files — never per open document, never one global profile cache.
| File | Contents |
|---|---|
corpus.db |
chunks, FTS5 passages, vec_chunks_<model_slug>, indexed_files, indexed_paragraphs, model_metadata |
corpus_meta.json |
schema_version, embedding_model, dim, chunk_count, storage_backend, updated_at |
Experimental backends use side-by-side stores: writeragent_embeddings/zvec/ or lancedb/ — corpus.db remains untouched when flipping modes.
corpus.db supports multiple embedding models concurrently:
- Shared
chunkstable — paragraph text stored once. - Per-model
vec_chunks_<model_slug>— isolated vector tables. model_metadata— dimension and last update per model.- Relative expiry — models more than 7 days older than the active model are dropped +
VACUUMduring ingest.
Changing Embedding Model in Settings triggers a cold rebuild for that folder.
CREATE TABLE chunks (
chunk_id INTEGER PRIMARY KEY,
doc_url TEXT NOT NULL,
para_index INTEGER NOT NULL,
char_start INTEGER, char_end INTEGER,
content_hash TEXT NOT NULL,
file_mtime REAL, last_indexed_at REAL,
embedding_model TEXT NOT NULL,
body TEXT
);
CREATE VIRTUAL TABLE passages USING fts5(body, content='chunks', content_rowid='chunk_id');
CREATE VIRTUAL TABLE vec_chunks_<model_slug> USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding float[384]
);Host opens corpus.db with stdlib sqlite3 for locator/metadata; venv loads sqlite_vec for vec0 DML and search.
Upgrade: legacy index.db and separate fts5.db are deleted on first access; next maintain cold-builds into unified corpus.db.
Lexical leg: BM25 + FTS5 NEAR on passages — same corpus.db, stdlib sqlite3 on host for orchestration, venv for search. No separate FTS database.
- Index grain (prose): one sentence per chunk via
icu4pySentenceBreakerinembeddings_split.py. Consecutive sub-MIN_CHUNK(120 char) sentences within the same paragraph are glued until they reach the floor; a trailing short remainder folds into the previous chunk. Run-on sentences stay one chunk — full text is stored for FTS; the embedder truncates the vector atmax_seq_length(no error). - Index grain (tabular/slides): 512-character windows, 64 overlap — RecursiveCharacterTextSplitter for Calc rows, Impress/Draw slides, spreadsheets, etc. (
prose=Falseinembeddings_fs.py). - Extract grain: ODF paragraph, Calc row, Impress slide body (
embeddings_fs.py). - Invalidation:
content_hashper chunk text (Incremental maintenance). - Schema:
EMBEDDINGS_SCHEMA_VERSION6 — cold rebuild on upgrade from v5 (per-document locale only) or v4 English-only / fixed 512-char prose chunks.
| Layer | Unit | Role |
|---|---|---|
| Extract | Paragraph / row / slide | Stable para_index, content_hash at passage level |
| Index | Sentence (prose) or sub-passage char window (Calc/slides) | Bi-encoder + FTS recall |
| Display | Parent paragraph merge | Full snippet without re-index |
| Precision | search_in_document in opened file |
Exact wording in live LO |
Embedding dilution (multi-topic chunks): reduced for prose by sentence boundaries; hybrid FTS leg, wide retrieval pool, optional rerank, and parent-paragraph merge still apply. Locale-aware sentence breaking: Multilingual sentence breaking.
| Kind | Extract | para_index hint |
|---|---|---|
.ods |
embeddings_ods_extract.py — row per passage |
Row index |
.xlsx, .docx, .pptx, .csv, .txt, .rtf |
embeddings_ooxml_extract.py etc. |
Per extract rules |
.odp, .odg |
embeddings_odp_extract.py — slide + notes |
Monotonic over passages |
Shipped (2026-06): prose documents use one sentence per chunk via icu4py SentenceBreaker in the trusted venv — bundled ICU wheels on Linux/macOS/Windows (no LibreOffice ICU data discovery). Only consecutive sub-MIN_CHUNK sentences within the same extracted paragraph are glued; run-ons stay one chunk with full FTS text (vectors truncate at the embedder's max_seq_length). Calc rows and Impress/Draw slides keep 512/64 recursive-char splitting.
Shipped (2026-06, schema v5): Multilingual sentence breaking — one document locale per file from ODF/OOXML metadata or vendored langdetect on plain text; Thai/Lao/Khmer use grammar whitespace splitting.
Eval (~/Desktop/Writing, hybrid RRF, k=5, 2026-06-26 re-measured after v4 re-index):
| Metric | v3 (512-char, 1119 chunks) | v4 (sentence, 1322 chunks) |
|---|---|---|
| Top-1 | 56.1% | 58.5% |
| Top-3 | 75.6% | 75.6% |
| Mean MRR | 0.646 | 0.674 |
Not handled: rapid one-word script dialog (short lines glue via MIN_CHUNK when consecutive in one paragraph).
Defined in embeddings_split.py:
| Constant | Value | Role |
|---|---|---|
MIN_CHUNK |
120 chars | Glue floor — consecutive sub-floor sentences within one paragraph merge until the buffer reaches this size; trailing remainder folds into the previous chunk |
DEFAULT_SENTENCE_LOCALE |
en@ss=standard |
ICU SentenceBreaker locale (@ss=standard enables abbreviation filters, e.g. Dr.) |
CHUNK_SIZE / CHUNK_OVERLAP |
512 / 64 | Non-prose only (Calc rows, slides) — RecursiveCharacterTextSplitter |
prose |
by file extension | True for Writer/.docx/.doc/.rtf/.txt; False for Calc/Draw/spreadsheet formats (path_uses_prose_chunking) |
Tuning notes (2026-06): v4 eval improved with MIN_CHUNK=120; no overlap on prose sentence chunks (parent-paragraph merge widens snippets at query time). Run-on sentences stay one chunk — full text in FTS body, vector truncated at embedder max_seq_length (no error). MIN_CHUNK: consider lowering to 80–100 only if eval shows bad gluing (headers + captions in one paragraph, unrelated dialog lines). Do not raise without evidence — more unrelated short sentences get merged. Non-prose 512/64: leave unless long Calc cell text becomes a problem.
Not exposed in Settings — tune via constants + eval_folder_search_routing.py until multiple representative corpora exist.
Shipped (schema v6, 2026-06): embeddings_locale.py parses locale-tagged runs from ODF/OOXML content at extract time; indexable_chunks_from_path passes run lists into split_passage_locale_runs_to_chunk_meta. Plain .txt/.rtf use per-paragraph venv langdetect. Out of scope: per-sentence langdetect on plain text; Settings UI for chunk strategy.
Policy: each prose passage may contain multiple LocaleTextRun slices (char offsets + BCP-47). Each run uses its own SentenceBreaker ICU tag (via bcp47_to_icu_sentence_breaker_locale) or grammar whitespace splitting for Thai/Lao/Khmer (is_whitespace_sentence_locale). Unmarked runs inherit paragraph style, then document default from XML or langdetect. MIN_CHUNK gluing never crosses run/locale boundaries. When XML/langdetect yield nothing, fall back to DEFAULT_SENTENCE_LOCALE (en@ss=standard).
What breaks when the document locale is wrong (English breaker on a foreign doc):
| Case | Effect |
|---|---|
| French / German / etc. | Wrong period/abbrev boundaries; diluted or split mid-phrase vectors |
| CJK (ja, zh, ko) | Period-based splitting is a poor unit; one giant “sentence” or arbitrary cuts |
| Thai / Lao / Khmer | No reliable . boundaries — whitespace path instead of ICU |
Invalidation: run-level locale → different sentence boundaries → different chunks. v6 cold re-chunks folders indexed at v5 (document locale only).
Resolve document default once per file; resolve run locale from content XML while walking each paragraph.
LibreOffice ODT (empirical): on a sample of 15 .odt files in ~/Desktop/Writing, meta.xml <dc:language> was absent in all 15; styles.xml paragraph style:default-style (fo:language + fo:country) was present in all 15. Prefer styles default for LO-native ODF; keep dc:language as a secondary signal (Word exports, other tools, or LO when meta is populated).
Run locale (ODF): walk text:p children (text:span, text:s, …); resolve xml:lang → inline style:text-properties → named/automatic style (styles.xml + content.xml automatic styles) → paragraph text:style-name → document default.
Run locale (DOCX): walk w:p / w:r / w:rPr/w:lang; paragraph w:pStyle and word/styles.xml doc defaults as fallbacks (host-side stdlib XML, not python-docx).
Resolution order for document default:
| Priority | ODF (.odt, …) |
OOXML (.docx, …) |
Plain (.txt, .rtf) |
|---|---|---|---|
| 1 | styles.xml — style:default-style style:family="paragraph" (else "text") → style:text-properties → fo:language + fo:country → BCP-47 |
docProps/core.xml — dc:language |
Per-paragraph venv langdetect (see below) |
| 2 | meta.xml — dc:language if present |
word/styles.xml — w:docDefaults / default style w:lang |
Document-level langdetect sample (fallback default only) |
| 3 | DEFAULT_SENTENCE_LOCALE (en@ss=standard after ICU mapping) |
word/settings.xml or default-style fallback |
DEFAULT_SENTENCE_LOCALE |
Plain text — per-paragraph langdetect: for each extracted paragraph, run detection via langdetect_rpc.detect_lang_sample in the embeddings worker (requires langdetect in the venv). Map through normalize_detected_bcp47. Short paragraphs may mis-detect — fall back to document default from a file sample or DEFAULT_SENTENCE_LOCALE.
Parsing: stdlib zipfile + ElementTree in embeddings_locale.py (no UNO, no extra venv packages for locale).
Threading: indexable_chunks_from_path reads document default once, extracts LocaleTextRun lists per passage, calls split_passage_locale_runs_to_chunk_meta. No locale column in the index schema (locale is split-time only).
Map to splitters: normalize tag via grammar helpers → if whitespace locale, grammar whitespace split; else icu4py SentenceBreaker(text, locale) per run.
| Step | Work |
|---|---|
| — | Multilingual sentence breaking (v5 document locale) |
| — | Run-level locale from ODF/OOXML + per-paragraph plain text (v6) |
| — | Re-eval with non-English labeled query suite on multilingual corpora |
Knobs (constants, not Settings):
| Knob | Purpose |
|---|---|
LocaleTextRun |
Char offsets + optional BCP-47 per content run within a passage |
Document default locale_bcp47 |
Fallback for unmarked runs and plain-text paragraphs |
DEFAULT_SENTENCE_LOCALE |
Fallback when XML/langdetect yield nothing |
@ss=standard |
ICU sentence filter; keep for abbrev handling |
| Whitespace-locale list | th, lo, km — grammar-aligned, no SentenceBreaker |
Explicitly out of scope: per-sentence langdetect on plain text; Settings UI for chunk strategy; user-editable MIN_CHUNK; token-based body trimming; LO ICU setDataDirectory.
Other future (not shipped): selectable chunk strategies, heading-level parents, contextual chunk prepends, sub-question retrieval.
- List siblings; compare file mtime vs
last_indexed_at. - Skip unchanged files.
- Extract → split →
content_hashdiff againstchunks. - Embed and upsert changed chunks only (batch windows of 64).
mark_file_indexedwhen mtime bumped but hashes unchanged — avoids re-scan loops.
Live keystroke hooks (XProofreading) were considered and not planned — periodic background refresh is sufficient for cross-file discovery.
| Files (longdoc-sized) | Paragraphs | Vector data (384-dim) |
|---|---|---|
| 1 | 349 | ~0.5 MiB |
| 10 | 3,490 | ~5 MiB |
| 100 | 34,900 | ~51 MiB |
768-dim models double vector storage. Incremental edits patch rows in place.
| Part | Size driver |
|---|---|
vec_chunks (vec0) |
n × dim × 4 bytes |
chunks.body + FTS passages |
Indexed prose (duplicated for hybrid) |
| Locator rows | Tiny per chunk |
Shipped: hybrid RRF → parent merge → optional cross-encoder rerank. Vec-only debug path: kNN → MMR.
Future (not shipped): heading-level parents, contextual chunk prepends, sub-question retrieval — tune via eval_folder_search_routing.py before building.
python scripts/dump_embeddings_cache.py ~/Desktop/Writing
python scripts/dump_embeddings_cache.py --limit 20 --doc-url file:///path/to/doc.odt.venv/bin/python scripts/search_embeddings_folder.py "your query"
.venv/bin/python scripts/search_embeddings_folder.py "topic" --folder ~/Desktop/Writing --k 10 --jsonSingle-leg debug (engineering only — production uses hybrid):
.venv/bin/python scripts/search_embeddings_folder.py --fts "grammar checker"
.venv/bin/python scripts/search_embeddings_folder.py --vec "remote work policy".venv/bin/python scripts/index_embeddings_folder.py ~/Desktop/WritingPer-folder semantic search runs in the user venv via trusted RPC modules. Settings → Python Test reports these under Embeddings Libraries:
| Package | Install | Used by |
|---|---|---|
sentence-transformers (sentence_transformers) + numpy |
uv pip install sentence-transformers numpy sqlite-vec langgraph langchain-core langchain-text-splitters envwrap icu4py langdetect odfpy pandas openpyxl xlrd python-docx |
Encode queries and corpus chunks; Office sibling extract |
sqlite-vec (sqlite_vec) |
(same line) | vec0 KNN in unified corpus.db |
| LangGraph + langchain-core + langchain-text-splitters | (same line) | Ingest/search graphs in trusted venv modules |
| icu4py | (same line) | Sentence boundaries for prose chunking (SentenceBreaker; bundled ICU wheels) |
| langdetect | (same line) | Plain-text locale detection; grammar Local language detection (via embeddings worker RPC) |
| envwrap | (same line) | Transitive dependency for sentence-transformers / Hugging Face stack on some Python versions |
Canonical install constant: EMBEDDINGS_VENV_PIP_INSTALL in embeddings_index.py.
Settings → Python Test and venv_diagnostics.py report sqlite_vec, llama_index, zvec, lancedb availability in the configured venv.
Minimum install:
uv pip install numpy sentence-transformers sqlite-vec langgraph langchain-core langchain-text-splitters envwrap icu4py langdetect odfpy pandas openpyxl xlrd python-docxSee EMBEDDINGS_VENV_PIP_INSTALL for the canonical one-liner. Installing sqlite-vec if sqlite_vec.load() fails.
Historical note: Early benchmarks compared grep-only vs vector-only file routing and motivated hybrid RRF. That per-query choice is obsolete for product users — hybrid always runs both legs. The tables below remain useful for model/backend tuning and the eval harness (
eval_folder_search_routing.pyseeds queries from this section).
Corpus: ~/Desktop/Writing, 1322 chunks (sentence-level prose chunking, schema v4), model paraphrase-multilingual-MiniLM-L12-v2, 41 labeled queries, k=5, --no-mmr.
| Model / Leg | Top-1 | Top-3 | Mean MRR |
|---|---|---|---|
| Hybrid RRF (default model) | 58.5% (24/41) | 75.6% (31/41) | 0.674 |
Prior v3 baseline (512-char, 1119 chunks, 2026-06-13): Top-1 56.1%, Top-3 75.6%, MRR 0.646.
v3 per-leg reference (2026-06-13, 1119 chunks):
| Model / Leg | Top-1 | Top-3 | Mean MRR |
|---|---|---|---|
| Hybrid RRF (default model) | 56.1% (23/41) | 75.6% (31/41) | 0.646 |
| — Vec-only | 43.9% (18/41) | 63.4% (26/41) | 0.546 |
| — FTS-only | 24.4% (10/41) | 36.6% (15/41) | 0.301 |
BAAI/bge-small-en-v1.5 hybrid |
51.2% (21/41) | 70.7% (29/41) | 0.610 |
Hybrid beats either leg alone on this folder — the product rationale for fusing keyword and semantic retrieval.
Stack: parent-paragraph merge + optional cross-encoder rerank; shared hybrid_retrieval_pool; MMR commented out on hybrid path for fair comparison (2026-06).
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode all
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend hybrid --k 5 --no-mmr
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend llama_index --k 5| Config | Top-1 | Top-3 | Mean MRR |
|---|---|---|---|
| LlamaIndex + cross-encoder rerank | 56.1% | 65.9% | 0.626 |
| Custom hybrid RRF only | 53.7% | 70.7% | 0.629 |
| Custom hybrid + rerank | 51.2% | 73.2% | 0.612 |
| LlamaIndex RRF only | 43.9% | 70.7% | 0.584 |
Re-run eval when changing models, chunk size, or corpus — scores are folder-specific.
scripts/bench_embeddings.py on longdocsample.odt (349 paragraphs):
| Metric | all-MiniLM-L6-v2 |
|---|---|
| Batch encode | 1.062 s |
| Query encode (median) | 3.715 ms |
| Dot + top-k (median) | 0.167 ms |
Production search uses vec0/hybrid, not full-matrix reload per query.
Tier one: sentence-transformers in the configured venv — offline, batched CPU, no per-paragraph API cost.
| Model | Dim | Notes |
|---|---|---|
paraphrase-multilingual-MiniLM-L12-v2 |
384 | Default — multilingual |
BAAI/bge-small-en-v1.5 |
384 | Strong English retrieval |
all-MiniLM-L6-v2 |
384 | Fast baseline |
all-mpnet-base-v2 |
768 | Higher quality, larger index |
Always batch embed — never one paragraph at a time in a Python loop.
HF cache: embedding and rerank models load with local_files_only=True when files are already in the Hugging Face cache (~/.cache/huggingface/hub/). First use on a new machine still downloads once; later worker starts, search, and index runs do not send HEAD requests to huggingface.co. To force a re-download after upstream model updates, remove the model folder from that cache.
For the active document only: optional future injection of extra chunks beside [DOCUMENT CONTENT] on chat send when the 8k excerpt misses a distant section in one huge file. Not shipped. Implement after corpus cross-file search proves value; same chunker, scoped by doc_url.
| Area | Idea |
|---|---|
| Retrieval | Metadata filters (doc kind, heading level), sub-question decomposition, stronger default model |
| Ingest | Configurable chunk size, contextual chunk prepends, ingestion transform cache |
| Storage | Slimmer cache (snippet-only bodies), optional HNSW at very large N |
| Product | Main chat passes locators into delegate task; thematic clustering; gap analysis between drafts |
| Host perf | Optional Cython top_k_dot (Host Cython top_k_dot) if vec0 profiling shows need |
Live edit hooks for embeddings are not planned — periodic mtime/hash refresh is the maintained strategy.
Optional in-process top-k over normalized float32 vectors — mirror writeragent_vec. Defer until profiling on multi-file corpora shows need. See cython-extension.md.
Cross-file hybrid search is shipped (schema v3 corpus.db, search_nearby_files, background indexer, Search dialog, dedicated embeddings worker pool). Alternative backends (LlamaIndex, Zvec, LanceDB) are optional Settings toggles — see appendices.
| Topic | Doc |
|---|---|
| Venv / NumPy boundary | enabling_numpy_in_libreoffice.md |
| Multi-file discovery plan | multi-document-dev-plan.md |
| Cython build matrix | cython-extension.md |
| Realtime grammar / hash patterns | realtime-grammar-checker-plan.md |
| Chat memory (unrelated) | langchain-plan.md |
LlamaIndex is an alternative, optional backend for cross-file search (FTS + embeddings) under Settings → Vector Search → Cross-file search → LlamaIndex (folder_search_mode="llama_index").
Why it exists: the default hybrid backend is a lean, hand-rolled sqlite-vec + FTS5 + vendored RRF stack. LlamaIndex is the path to stock retrieval composition — QueryFusionRetriever, SentenceTransformerRerank, IngestionPipeline, metadata filters, sub-question engines — without growing a parallel custom framework in plugin/embeddings/venv/. If you plan to invest in retrieval quality beyond “good enough RRF,” LlamaIndex is the intended integration surface.
Roadmap: incremental fixes and big-advantage work items live in LlamaIndex roadmap (Phase 1 eval/ops done; Phase 2 retrieval and agent payoffs).
This option subclasses LlamaIndex core interfaces to map them directly to the existing unified SQLite database (corpus.db), keeping on-disk schema compatibility. Switching from Embeddings + FTS to LlamaIndex requires no re-index — same corpus.db, different retrieval stack.
What stays custom (WriterAgent-owned):
corpus.dbschema, FTS5passages, sqlite-vecvec0, incrementalindexed_*tables (embeddings_sqlite.py)- ODF/folder extraction, cache paths, background maintain loop (
embeddings_folder_maintain.py) - Agent tool contract (
doc_url,para_index,snippet,score, optionalmatched_by)
What LlamaIndex owns when folder_search_mode="llama_index":
- Hybrid retrieval: stock
QueryFusionRetriever— vector + FTS legs fused with reciprocal rank fusion (num_queries=1, offline). - Two-stage ranking: over-retrieve via shared
hybrid_retrieval_pool(max(k×4, 20)capped at 50) on bothhybridandllama_index, thenSentenceTransformerRerank(cross-encoder/ms-marco-MiniLM-L-6-v2by default) when rerank is enabled on the search RPC. Custom hybrid uses the same pool formula andembeddings_cross_encoder_rerank.pyinstead of LI postprocessors. - Ingest orchestration via
VectorStoreIndex.insert_nodes(still writes the same SQLite tables) - Warm worker process: all LlamaIndex imports run inside the trusted
PythonWorkerManagervenv — zero pollution of LibreOffice embedded Python.
SQLite adapters (storage boundary only — embeddings_llama_index.py):
| Adapter | Role |
|---|---|
WriterAgentEmbedding |
Local SentenceTransformer via embed_texts |
WriterAgentVectorStore |
Read/write chunks / vec_chunks |
WriterAgentFTSRetriever |
FTS5 over passages |
build_writer_agent_hybrid_retriever / run_hybrid_retrieval_pipeline |
RRF fusion → optional cross-encoder rerank → tool hits |
Background indexing and search both honor Settings embeddings.folder_search_mode (including llama_index) via embeddings_indexer.py and embeddings_service.py.
| Aspect | Custom hybrid |
LlamaIndex |
|---|---|---|
| Storage | corpus.db |
Same corpus.db |
| RRF fusion | Vendored hybrid_rrf.py |
QueryFusionRetriever |
| Rerank | embeddings_cross_encoder_rerank.py |
SentenceTransformerRerank |
| Dependencies | Minimal (sqlite-vec, langgraph) | + llama-index-core |
| Future features | Hand-roll each new retrieval idea | Compose from LI ecosystem (metadata, sub-questions, routers) |
On ~/Desktop/Writing (41 labeled queries, 2026-06), LlamaIndex + cross-encoder rerank led top-1 file routing (56.1%) vs custom hybrid RRF-only (53.7%); custom hybrid + rerank won top-3 (73.2%). See Latest stack evaluation. Either backend is viable — LlamaIndex pays off when you want framework composition without maintaining parallel fusion/rerank code.
There is no chat LLM in the LlamaIndex folder-search path. Sidebar / document_research chat models (OpenRouter, Ollama, etc.) only see the final hit list after retrieval finishes.
Pipeline when folder_search_mode="llama_index":
User query
→ [1] Bi-encoder embed (Settings embedding_model / SentenceTransformer)
Vector leg: sqlite-vec kNN on vec_chunks
→ [2] FTS5 keyword leg (BM25/NEAR on passages) — SQL only
→ [3] RRF fusion (QueryFusionRetriever, reciprocal rank — math, no model)
→ [4] Cross-encoder rerank (optional; see below)
→ Top-k hits (doc_url, snippet, score) → search_nearby_files tool → chat LLM reads them
| Step | Component | LLM? | Role |
|---|---|---|---|
| 1 | SentenceTransformer bi-encoder |
No | Encode query + compare to chunk vectors (semantic recall) |
| 2 | SQLite FTS5 | No | Keyword / literal recall |
| 3 | Reciprocal rank fusion | No | Merge two ranked lists by rank position |
| 4 | SentenceTransformerRerank (Settings Enable cross-file rerank) |
No | Score each (query, chunk) pair jointly; reorder top candidates |
| After | Sidebar chat model | Yes | Interprets hits, opens files, answers the user — not part of index search |
Rerank is not done by the chat LLM. Step 4 is a small local cross-encoder (~22M params for MiniLM): a classifier that outputs a relevance score. It does not generate text.
When rerank runs: cross-encoder rerank runs when Enable cross-file rerank is on, the search RPC has use_mmr=True + rerank_model, and final k > 1. Off by default — both backends use RRF-only until enabled.
| Rerank model (Settings dropdown) | Notes |
|---|---|
cross-encoder/ms-marco-MiniLM-L-6-v2 (default) |
English; ~22M params; fast |
BAAI/bge-reranker-v2-m3 |
100+ languages; ~568M params; ~2.3 GB download |
Embeddings stay on Embedding Model (default multilingual MiniLM). Rerank is query-time only — toggle or change model without rebuilding corpus.db.
Offline note: QueryFusionRetriever must receive MockLLM() when num_queries=1; otherwise LlamaIndex resolves Settings.llm (OpenAI) at init even though query expansion is disabled — see embeddings_llama_index.py.
LlamaIndex’s QueryFusionRetriever can optionally expand the query before retrieval: with num_queries > 1, it uses a chat LLM to generate alternate phrasings, runs each query against the retrievers, then fuses all result lists with RRF.
WriterAgent sets num_queries=1 in build_writer_agent_hybrid_retriever — only the user’s original query is used. No LLM call, no extra API cost, fully offline after models are cached.
| Setting | Behavior |
|---|---|
num_queries=1 (shipped) |
Single query → vector + FTS → RRF → rerank. No query expansion. |
num_queries>1 (not shipped) |
LLM generates extra queries → more retrieval passes → broader recall; needs chat model + network (unless local LLM) |
Query expansion is not on the roadmap — see Deferred / low priority. Higher-value LlamaIndex work is hierarchical retrieval, metadata, reranker presets, and sub-question retrieval.
Pluses:
- Offline and privacy-preserving — no external network or LLM query-generation API calls (
num_queries=1,use_async=False). - Framework alignment — standardize on
TextNode,NodeWithScore,QueryBundlefor external library compatibility and standard post-processors (node parsers, rerankers, metadata enrichment). - No LO pollution — LlamaIndex and transitive dependencies live entirely in the configured venv.
- Native reranking —
SentenceTransformerRerankinstead of reimplementing custom MMR / per-doc caps. - Same
corpus.db— flip backends without re-indexing; safe A/B on real folders.
Minuses:
- Dependency size —
llama-index-coreadds download time on cold venv bootstrap. - Abstraction overhead — Pydantic serialization vs ultra-lean sqlite-vec + FTS5 SQL.
- Cross-encoder cost — first rerank loads the model in the venv worker (extra RAM/latency vs RRF-only).
- Loss of raw score scales — RRF and reranker scores are relative rankings, not raw cosine/BM25. Raw leg scores remain in node
metadata["raw_score"]when present.
To evaluate and compare backends on the same corpus.db:
- Open Settings → Vector Search → Cross-file search and toggle between
hybridandllama_index. - Run folder routing eval:
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode all HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend hybrid --k 5 --no-mmr HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend llama_index --k 5 HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend llama_index --k 5 --no-mmr
- Compare top-1 file accuracy, top-3, mean MRR, and
matched_by(fts+vecagreement) between backends. - CLI search:
.venv/bin/python scripts/search_embeddings_folder.py "query" --backend llama_index .venv/bin/python scripts/search_embeddings_folder.py "query" --backend llama_index --no-mmr .venv/bin/python scripts/search_embeddings_folder.py "query" --backend llama_index --rerank-model cross-encoder/ms-marco-MiniLM-L-6-v2
- Optional agent eval:
make run_eval EVAL_ARGS="--models your-model-name -n 5 -j 1"
Both eval_folder_search_routing.py and search_embeddings_folder.py accept --backend hybrid|llama_index.
Venv install: llama-index-core is included in the embeddings venv install line (EMBEDDINGS_VENV_PIP_INSTALL). Verify with Settings → Python Test / venv_diagnostics.py.
Work below is ordered incremental fixes first, then big advantages that justify keeping LlamaIndex beyond “another RRF implementation.” Both phases share the same corpus.db, tool hit shape, and venv worker boundary.
| # | Item | Status |
|---|---|---|
| 1 | Eval parity — --backend hybrid|llama_index on eval + search CLI; A/B in Evaluation reference |
Done |
| 2 | Ops gaps — search UI rebuild honors embeddings.folder_search_mode; CLI rerank flags |
Done |
| 3 | Settings / UX — rerank on/off + model id; cross-encoder on both backends | Done |
Phase 1 exit criteria: measured A/B table for hybrid vs llama_index on a labeled query set; no hardcoded hybrid bypasses in search UI or indexer routing; documented venv install for LlamaIndex mode.
These are the reasons to keep LlamaIndex after Phase 1 — capabilities the custom stack does not compose cleanly.
| # | Advantage | Payoff | LlamaIndex building blocks |
|---|---|---|---|
| A1 | Hierarchical / parent–child retrieval | Shipped (paragraph level): merge sub-chunks by para_index, return full paragraph in snippet on all backends — embeddings_parent_hits.py. Section / heading parents remain future. |
AutoMergingRetriever semantics |
| A2 | Rich metadata on ingest | Filter/boost by heading level, doc type (Writer / Calc / ODP), file title, recency — cross-file routing when filenames lie | IngestionPipeline transforms, MetadataFilters, title/summary extractors on TextNode.metadata |
| A3 | Sub-question / decomposed retrieval | Complex prompts (“compare Q3 and Q4 budget language across the folder”) split into sub-queries, retrieve per part, fuse — better than one search_nearby_files call |
SubQuestionQueryEngine or custom multi-query retriever + RRF |
| A4 | Swappable rerankers and postprocessors | Quality tuning becomes config + eval presets, not a new embeddings_* module each time |
SentenceTransformerRerank, SimilarityPostprocessor, recency / metadata boosts |
| # | Advantage | Payoff | LlamaIndex building blocks |
|---|---|---|---|
| B1 | Dedicated “folder QA” query engine | document_research sub-agent gets a synthesized brief + cited doc_urls instead of raw snippet lists for the main chat model to re-read |
RetrieverQueryEngine over folder retriever + response synthesizer |
| B2 | Router retriever by document kind | Different retrieval mixes for Writer prose vs Calc tables vs Impress slides (FTS weight, chunk size, metadata filters) | RouterRetriever / routing by metadata["doc_kind"] |
| # | Advantage | Payoff | LlamaIndex building blocks |
|---|---|---|---|
| C1 | Ingestion pipeline with transform cache | Faster incremental folder refresh; declarative split → metadata → embed; less duplicate custom ingest logic | IngestionPipeline + cache keyed by chunk hash |
| C2 | Eval harness as LlamaIndex lab | Same corpus.db, swap presets (hybrid-RRF, li-RRF+rerank, li+hierarchical) without rewriting SQL |
Preset configs in eval scripts; compare on fixed query suite |
| C3 | Interop / community path | Homelab users reuse WriterAgent as LO extract + SQLite storage while owning retrieval composition externally | Adapter boundary (WriterAgentVectorStore, WriterAgentFTSRetriever); see community integration strategy |
| # | Advantage | Payoff |
|---|---|---|
| D1 | Replace custom hybrid as default |
One retrieval stack to maintain; hybrid becomes alias or deprecated if LlamaIndex ≥ hybrid on labeled sets |
| D2 | Multi-folder / scoped collections | Project subfolder or “siblings of active file only” via metadata-scoped retrievers or multiple logical indexes |
| Item | Status |
|---|---|
LLM query expansion (QueryFusionRetriever num_queries>1) |
Deferred. Requires chat LLM during retrieval (latency, privacy, offline complexity). Prefer A1–A4, B1–B2, C* first. See Query expansion. |
| Step | Focus | Rationale |
|---|---|---|
| 1 | A2 metadata on ingest | Cheap quality lift; feeds later routing and filters |
| 2 | A3 sub-questions | Only if eval shows recall gaps on complex multi-part queries |
| 3 | B1 folder QA tool | Agent-facing; retrieval quality must be stable first |
| 4 | C1 ingest cache, C3 interop, D1 default flip | Ops and consolidation once presets prove value |
Next implementation handoff: Rich metadata on ingest (A2) + metadata filters on retrieve.
- Swapping in upstream sqlite-vec
VectorStorewholesale — our SQLite layer stays the storage adapter boundary. - Re-adding hybrid-only hacks in LlamaIndex mode (per-doc hit caps, custom MMR postprocessor mirroring
embeddings_hybrid_search.py). - Running a full LlamaIndex query engine on every main-chat send — retrieval stays in the venv worker; the sidebar chat LLM remains downstream of tool hits.
- LLM query expansion during retrieval (
num_queries>1) — deferred; see Deferred / low priority.
zvec (Alibaba in-process vector database, C++ with PyBind11 bindings) is a selectable alternative backend for cross-file search under Settings → Vector Search → Zvec (experimental) (folder_search_mode="zvec"). Requires pip install zvec in the embeddings venv.
Why it exists: the default hybrid path is a hand-rolled sqlite-vec + FTS5 + vendored RRF stack — you own schema sync, hybrid glue, incremental state, and alignment repair. LlamaIndex (Appendix A) bets on a retrieval framework atop the same corpus.db. Zvec bets on replacing the storage and hybrid execution engine itself with a purpose-built embedded DB for dense + sparse vectors + native FTS + hybrid queries in one engine. It is the other major “get a better vector database than custom sqlite glue” path.
Current status (2026-06): Side-by-side implementation is complete — Settings toggle, mode-aware host checks and UI, venv RPC, full folder maintain (shared extraction + embedding model), knn and hybrid search with the same hit shapes as search_nearby_files and the Search dialog, tests (test_embeddings_zvec_backend.py). Deeper incremental maintenance, index tuning, and any decision to slim the custom sqlite hybrid path are gated on real A/B data from folders such as ~/Desktop/Writing.
zvec stores data in writeragent_embeddings/zvec/ — a first-class zvec collection independent of corpus.db. Both can coexist for the same listing folder; flip modes without data loss for the other store.
What stays custom (WriterAgent-owned):
- ODF / OOXML / text extraction and paragraph-level chunking (same
guess_indexable_paths,indexable_chunks_from_path,chunk_to_index_row, etc. as hybrid and LlamaIndex) - Background indexing heartbeat, enqueue, stale detection (
embeddings_folder_maintain.pydispatches tomaintain_folder_zvecwhen mode iszvec) - Embedding generation: same
embed_texts/SentenceTransformerwrapper, batching, and L2 normalization — switching backends does not change the embedding space - Tool contract and hit shaping (
{doc_url, score, snippet, para_index?, content_hash?, ...}) - Host:
resolve_index_context, mode-aware empty checks (zvec_collection_looks_populated),corpus_meta.jsonupdates, cache clearing, search UI - Sandbox allow-listing and venv diagnostics (
zvec: presentorNonein Python Test)
What zvec owns when folder_search_mode="zvec" (embeddings_zvec.py):
| Concern | zvec responsibility |
|---|---|
| On-disk store | WAL, index files under zvec/ |
| Schema | CollectionSchema: doc_url, body (FTS), para_index, content_hash, file_mtime + VectorSchema("embedding", VECTOR_FP32) |
| Hybrid query | Query legs (vector + Fts(match_string=...)) → coll.query(..., reranker=RrfReRanker(rank_constant=60) or Weighted) |
| ANN / FTS | Native C++ search and matching |
| Lifecycle | stats, flush, upsert, delete, delete_by_filter, destroy, future create_index / optimize (HNSW, DiskANN, Vamana, IVF, Flat, Invert) |
Maintain (v1): maintain_folder_zvec does per-file delete_by_filter('doc_url == "..."'), re-extracts paragraphs, embeds, builds Doc objects with stable ids ({doc_url}#{para_index}#{content_hash_prefix}), upserts, heartbeat progress. Updates corpus_meta.json with storage_backend: "zvec". Does not use sqlite indexed_files / indexed_paragraphs — a folder can have a good zvec index without ever building corpus.db.
Search: zvec_knn_search (semantic only) and zvec_hybrid_search (normal tool path) embed the query, build Query objects, apply reranker when configured, map through _shape_hit to standard hits. doc_url_filter becomes a query filter; near_slop accepted for future FTS parity.
Guarded by try: import zvec — if missing, worker raises pip install zvec. LibreOffice embedded Python never imports zvec.
The baseline hybrid stack (embeddings_sqlite.py, embeddings_hybrid_search.py, hybrid_rrf.py, embeddings_search_graph.py, parent merge, alignment repair, retrieval pool) is lean but you are the vector + FTS database author:
- Schema: chunks + external-content FTS5 + per-model vec tables +
indexed_*+model_metadata - Sync logic: insert rows, ensure FTS, ensure vectors per model, repair on incremental runs, vacuum old models
- Hybrid execution: two legs, over-fetch pool, vendored RRF, optional cross-encoder, parent widening
- Incremental invalidation: mtime +
content_hashdecision tree
Concrete benefits of zvec:
- Native hybrid query — vector + FTS in one
query([...])call; RRF/Weighted fusion in the engine, nothybrid_rrf.pyto maintain. - Durability — WAL; batch
upsertvisibility atomic after crash; hybrid invariants are the engine’s job. - Query-time metadata —
doc_url,para_index,content_hash,file_mtimeas typed fields; filters during retrieval, not post-filter in Python. - ANN flexibility — Flat default today;
HnswIndexParam, DiskANN, sparse vector legs later with minimal glue. - Codebase trajectory — if eval wins, custom storage/hybrid machinery can shrink for zvec users instead of growing with every new hybrid feature.
- Safe side-by-side eval — keep months-old
corpus.db; growzvec/in the same folder; flipfolder_search_modeanytime. - C++ hot path in trusted venv — ANN + FTS + RRF in native code inside the embeddings worker; no new LO packaging.
- DB primitives —
destroy,stats,flush,optimizeas first-class operations.
Both exist because custom hybrid is a lot of bespoke code to keep correct and extend. They buy different things:
| LlamaIndex | Zvec | |
|---|---|---|
| Bet | Retrieval framework and composition | Embedded database engine |
| Storage | Reuses corpus.db |
Own zvec/ directory |
| Re-index on switch | No (same DB, different retriever) | Yes (separate store) |
| Wins at | RRF/rerank/postprocessor ecosystem, sub-questions, folder QA engines | Native hybrid, WAL, ANN choice, less custom DB glue |
| You still own | Extraction, chunking, sqlite schema | Extraction, chunking, hit shaping |
- LlamaIndex pain/opportunity: retrieval orchestration is hard to improve — feed paragraphs via adapters, compose from LI recipes.
- Zvec pain/opportunity: being the author of vector + FTS + hybrid + durability is a lot of code — let a native engine own storage and query execution.
Nothing prevents a future where a zvec collection backs a LlamaIndex retriever, or LI pipelines feed zvec. For now they are independent experiments against the hybrid baseline.
Pluses:
- Engine, not glue — hybrid search complexity moves into a component built for vector + FTS + hybrid + durability;
embeddings_zvec.pysurface is much smaller than the sqlite hybrid union. - Durability you don’t argue about — WAL and collection lifecycle are table stakes for zvec.
- Index strategy flexibility — HNSW, DiskANN, sparse legs via engine capabilities, not new custom maintenance stories.
- Metadata at query time — filters on
doc_url,para_index, futureheading/doc_kind/ recency pushed into retrieval. - Safe evaluation — same paragraphs, tools, UI, eval scripts; zero risk to existing
corpus.db. - Credible path to less custom vector DB code — if data wins, bespoke hybrid becomes legacy for non–zvec users.
Minuses:
- Extra venv dependency —
pip install zvec; OXT never ships the wheel. - v1 maintain is per-file refresh —
delete_by_filter+ re-upsert; more work per touched file than sqlite mtime/hash incremental. Cheap incremental is Phase 2 if eval justifies. - Different debug tooling —
zvec/is notsqlite3CLI–friendly; rely oncollection_stats, meta JSON, enginestats/fetch/destroy. - Not a RAG framework — still your extraction, model choice, and agent tools; for chunking/pipeline composition see LlamaIndex.
- No deprecation without data — custom hybrid stays default until real-folder A/B proves zvec wins.
After pip install zvec and selecting Zvec (experimental) in Settings:
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode hybrid --backend zvec --k 5
HF_HUB_OFFLINE=1 .venv/bin/python scripts/eval_folder_search_routing.py --folder ~/Desktop/Writing --mode all --backend zvecDrive from UI (Search dialog, document_research tools) or CLI. Eval harness dispatches to zvec wrappers like hybrid and llama_index.
What to measure (same source paragraphs, three backends):
| Metric | Notes |
|---|---|
| Retrieval quality | Top-1 / top-3 / MRR; matched_by distribution; weak-query behavior |
| Latency | Cold and warm build + search |
| Qualitative | document_research sessions — right files faster? useful snippets? |
| Operations | Disk size, incremental update time, mode switching pain |
- Settings + full stack propagation (indexer, service, tools, search UI, empty/stale checks, rebuild)
- Guarded venv: schema, stable-id upsert, per-file delete, knn + hybrid search, maintain with shared extract + embedder
- Host path helpers, FS-only
looks_populated, meta updates, sandbox + diagnostics - Tests and documentation; zero interference with
corpus.dbusers
Exit criteria: flip setting on ~/Desktop/Writing, build/rebuild, search and routing eval behave like other modes — produce A/B data.
- Real incremental / delta maintain (per-doc state, surgical delete/upsert, avoid re-embedding unchanged paragraphs)
- Engine primitives:
stats,optimize(), explicit HNSW etc., possibly sparse vector legs - Richer scalar metadata + query-time filters (heading level, doc kind, recency)
- “Zvec mode only” maintain so stores don’t diverge subtly
- Dated results table in this doc (zvec vs hybrid vs LlamaIndex on same query suite)
Only after numbers and qualitative experience on real folders:
- Flip default for venv users with zvec installed
- Prune or mark legacy the custom sqlite + RRF + alignment glue for zvec path
- Production niceties (DiskANN for large folders, built-in sparse rerank)
If data does not show a clear win, zvec stays experimental (like llama_index today); custom hybrid remains the self-contained baseline.
- Shipping zvec inside the OXT or as a required dependency
- Replacing ODF extraction / chunking — zvec stores paragraphs; it does not parse
.odt - Full query engine or synthesizer on every chat turn — retrieval returns hit lists; sidebar LLM interprets downstream
- Deprecating custom hybrid without data
Experimental: Settings → LanceDB (experimental) (folder_search_mode="lancedb"). Requires pip install lancedb in the embeddings venv (embeddings_lancedb.py).
Store: writeragent_embeddings/lancedb/ — side-by-side with corpus.db, same pattern as Zvec.
Implementation: PyArrow tables via lancedb.connect(); lancedb_hybrid_search and lancedb_knn_search return the same hit shape as other backends. Maintain via maintain_folder_lancedb — per-file refresh similar to Zvec v1.
Host checks: lancedb_collection_path, lancedb_collection_looks_populated — no LanceDB import on the LibreOffice host.
Evaluation: same eval_folder_search_routing.py / Search dialog workflow; select LanceDB in Settings and rebuild.
Approximate nearest neighbor for bounded in-RAM subsets — PyPI hnsw-lite. Rebuild from stored vectors on load; not persisted by default. Research only; production folder search uses vec0 in corpus.db.
Cross-file hybrid search shipped as schema v3 corpus.db, search_nearby_files, background indexer, and Search dialog. See Implementation status.
Pre–corpus.db installs used chunks.embedding BLOB + NumPy dot in the venv. Schema v3 uses vec0; legacy stores are removed on upgrade. bench_embeddings.py still validates the NumPy path for encode timing.
Schema v1 kept a ~60 s TTL matrix cache in the embeddings subprocess to avoid re-reading BLOBs per query. Schema v3 vec0 queries make this unnecessary for typical folder sizes.
Chroma FOSS lacked integrated self-hosted FTS + vector + fusion. Removed 2026-06; not in Settings or supported modes.
OpenRouter, Together, and Ollama embed endpoints are sketched in config (embedding_provider) but not implemented for folder index. Shipped path: local sentence-transformers only.
XProofreading keystroke-driven re-embed (~60 s debounce) was designed and superseded by periodic mtime/hash refresh — less code, acceptable staleness for cross-file discovery.
VENV=/path/to/your/venv
"$VENV/bin/pip" install sqlite-vec
"$VENV/bin/python" -c "import sqlite3, sqlite_vec; db=sqlite3.connect(':memory:'); db.enable_load_extension(True); sqlite_vec.load(db); print(db.execute('select vec_version()').fetchone())"Install into scripting.python_venv_path, not system Python or LibreOffice embedded Python. See sqlite-vec Python docs.
Do not vendor sqlite-vec into the OXT.