[Feat] Community Local: ingestion worker, local models, retrieval & grounded chat - #1743
Conversation
The repo has one editor root pointed at surfsense_backend/.venv, so every import the local backend does not share with cloud, sqlite_vec among them, reads as unresolved. Pyright's own monorepo guidance for two virtualenvs is a multi-root workspace: one analyzer per folder, each with its interpreter.
Registration walked modules/ with pkgutil and imported whatever ended in models, which is a lot of machinery for five lines and unreadable to anyone who has not met pkgutil. Both lists are written out instead, and a test reads modules/*/models.py and modules/*/tasks.py off disk in a clean interpreter so neither can quietly fall behind: an unimported model is a relationship SQLAlchemy cannot resolve, an unimported task is a job the consumer drops.
Uploads have been enqueueing ingest_document since phase 2 with nothing on the other end, which is why a document stayed pending forever. Started as worker.py beside the API's main.py, since a script is what a PyInstaller spec points at and the packaged sidecar installs no console scripts. One worker: ingest saturates a CPU and writes to the file the API is serving requests from, so a second would spend its life behind the first one's lock. The test starts the real process and waits for the job's outcome to reach the result store, rather than watching the queue drain, because an empty queue equally describes a consumer that took a job it could not name and dropped it.
Phase 1 of the worker workstream is built, so the index says so and the phase file describes what landed rather than what to build. What stands between an upload and a searchable document is now only the ingest body.
…undled encoder Parse with Docling (OCR and tables on), chunk with Chonkie on the exported markdown, embed with a bundled bge-small int8 encoder on onnxruntime, and index into the hybrid store. Notes and note edits now enqueue the same job as uploads. Torch is pinned to CPU wheels so the installer stays lean.
…lection A provider protocol split into a Generator contract and a ModelStore for runtimes that hold weights on disk, with Ollama behind it: health, installed models, a curated download catalog, streaming pull, and streaming chat. A selected_models row records one model per role, read and set over /llm.
The ingest stack, the generation provider and per-role selection, the bundled weights and the downloaded model, and the flat-history sliding-window chat.
Hybrid retrieval in shared/search.py: an FTS5 leg and a sqlite-vec KNN leg widen recall, then the union is rescored by cosine to the query embedding and cut to top_k. Keyword matches only add reach; meaning decides the order, so a cross-encoder reranker stays a later opt-in. Move the real_model fixture to the root conftest so search and worker tests share it. Update the phase plans to describe recall-then-cosine.
build_context turns hits into <source id> blocks and the citations their ids resolve to; a chunk's own source/context tags are stripped so it cannot close a block early or forge an id. build_messages slides a token-budget window over thread history, pinning the system prompt and the new turn.
Thread CRUD plus the orchestrator: a message resolves the selected model (409 if none), loads history, retrieves its own context, grounds a system prompt, streams the generator's deltas as SSE, and persists both turns with citations. Wired into the app; covered end to end with a stub generator and real retrieval.
Frame the stream as data: {json} with a [DONE] sentinel so a client tells the end
from a dropped connection, split delta and citations into their own frames, and
set Cache-Control and X-Accel-Buffering so a proxy streams through rather than
buffering the whole reply into one blob.
|
@CREDO23 is attempting to deploy a commit to the Rohan Verma's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Continues #1742. Everything here is under
surfsense_local/; nothing outside it is touched. This is the whole ingestion → retrieval → chat spine, plus the model setup it leans on.What this adds
A worker that ingests what the API enqueues. Docling parses the file with OCR and table structure on, Chonkie chunks the exported markdown against bge-small's own tokenizer so a chunk is measured in the tokens the encoder will actually see, and a bundled bge-small-en-v1.5 int8 model embeds each chunk in-process on onnxruntime — 384-dim, no model server, offline. Both index tables are written in the same pass, and the document lands
ready, orfailedwith the reason on the row. The chunker uses markdown-aware split rules so a heading is never severed from the body it introduces.A local generation provider and per-role model selection.
modules/llm/splits aGenerator(anything that can answer) from aModelStore(only a runtime that holds weights on disk can pull), so the download UI is gated byisinstancerather than a provider name — a remote API satisfies the first alone. Ollama is the default and ships behind those protocols; since it exposes no library API, the models offered for download are a curated Qwen catalog kept inside the Ollama provider.SelectedModel(role)holds one choice per role. Adding a provider later is a folder plus one registry line.Hybrid retrieval, workspace-scoped.
shared/search.py'sretrieve()widens recall with a BM25 leg and a sqlite-vec KNN leg, both filtered to the workspace, then rescores the union of candidates by cosine against the query vector — the same bundled encoder as ingest, imported lazily soshareddoes not pullworkerin at import. Cosine on the union orders; the two legs only decide what is in the running.Grounded, streaming chat. A turn retrieves its own context, wraps each hit in a numbered
<source>block inside a system message, and instructs the model to cite inline with[id]. Chunk text is defanged first, so a document cannot forge its own source tag and break out of the block. History is a flat per-thread walk with a sliding token window; the system message and the new turn are pinned above it. The reply streams over SSE and both turns persist as oneChatMessageeach — the assistant row carrying its citations for the UI.Proper SSE. Frames are
data: {json}\n\nand the stream ends on adata: [DONE]\n\nsentinel, so a client tells completion apart from a dropped socket. Deltas and the citation list are separate frames; a generator failure is its ownerrorframe and the partial turn still persists. The response setsCache-Control: no-cacheandX-Accel-Buffering: noso a proxy streams it through rather than buffering the whole reply into one late blob.Notes for review
tests/unit/<feature>andtests/integration/<feature>. Nothing is mocked: chat and retrieval integration tests build a real SQLite file, ingest a real document, and run against a stub Ollama server over HTTP.selected_modelsfolded in — since nothing has shipped.api/05-packaging.md.High-level PR Summary
This PR delivers the complete local ingestion → retrieval → chat pipeline for SurfSense Community Local. It adds an ingestion worker that processes documents using Docling for parsing, Chonkie for chunking, and a bundled bge-small-en-v1.5 int8 ONNX model for in-process CPU embeddings (384-dim, no model server, offline). Hybrid retrieval combines BM25 and sqlite-vec KNN for recall, then rescores by cosine similarity, with both legs scoped to workspaces. The LLM layer introduces a provider abstraction (
GeneratorvsModelStore) with Ollama as the default, a curated Qwen catalog for downloads, and per-role model selection. Streaming chat retrieves context per turn, grounds the system prompt with numbered<source>blocks, cites inline with[id], streams deltas over proper SSE (data: [DONE]\n\nsentinel), and persists both user and assistant turns with citations. The worker runs serially (-w 1) beside the API, migrations stay hand-written at one revision withselected_modelsincluded, and 102 tests cover the entire stack with real SQLite files and a stub Ollama server—real model tests skip when weights are absent. Everything lives undersurfsense_local/; nothing outside is touched.⏱️ Estimated Review Time: 1-3 hours
💡 Review Order Suggestion
plans/community-local/00-umbrella-plan.mdplans/community-local/00c-data-model.mdplans/community-local/api/02-upload.mdplans/community-local/api/03-chat.mdplans/community-local/api/05-packaging.mdplans/community-local/worker/01-boot.mdplans/community-local/worker/02-ingest.mdplans/community-local/worker/03-search.mdsurfsense_local/backend/alembic/versions/0001_initial_schema.pysurfsense_local/backend/shared/config.pysurfsense_local/backend/shared/db.pysurfsense_local/backend/shared/queue.pysurfsense_local/backend/modules/llm/models.pysurfsense_local/backend/modules/llm/providers/protocols.pysurfsense_local/backend/modules/llm/providers/types.pysurfsense_local/backend/modules/llm/providers/__init__.pysurfsense_local/backend/modules/llm/providers/ollama/catalog.pysurfsense_local/backend/modules/llm/providers/ollama/provider.pysurfsense_local/backend/modules/llm/dependencies.pysurfsense_local/backend/modules/llm/schemas.pysurfsense_local/backend/modules/llm/router.pysurfsense_local/backend/worker/consumer.pysurfsense_local/backend/worker/ingestion/parsing.pysurfsense_local/backend/worker/ingestion/chunking.pysurfsense_local/backend/worker/ingestion/embedding.pysurfsense_local/backend/worker/ingestion/indexing.pysurfsense_local/backend/worker/ingestion/pipeline.pysurfsense_local/backend/worker.pysurfsense_local/backend/shared/search.pysurfsense_local/backend/modules/chat/models.pysurfsense_local/backend/modules/chat/prompt.pysurfsense_local/backend/modules/chat/history.pysurfsense_local/backend/modules/chat/dependencies.pysurfsense_local/backend/modules/chat/schemas.pysurfsense_local/backend/modules/chat/router.pysurfsense_local/backend/api/main.pysurfsense_local/backend/modules/documents/storage.pysurfsense_local/backend/modules/documents/tasks.pysurfsense_local/backend/modules/documents/router.pysurfsense_local/backend/pyproject.tomlsurfsense_local/backend/scripts/fetch_embedding_model.pysurfsense_local/backend/tests/conftest.pysurfsense_local/backend/tests/integration/test_registration.pysurfsense_local/backend/tests/unit/worker/test_chunking.pysurfsense_local/backend/tests/unit/chat/test_prompt.pysurfsense_local/backend/tests/unit/chat/test_history.pysurfsense_local/backend/tests/unit/llm/test_catalog.pysurfsense_local/backend/tests/integration/worker/conftest.pysurfsense_local/backend/tests/integration/worker/test_ingest.pysurfsense_local/backend/tests/integration/worker/test_consumer.pysurfsense_local/backend/tests/integration/search/test_retrieve.pysurfsense_local/backend/tests/integration/llm/conftest.pysurfsense_local/backend/tests/integration/llm/test_routes.pysurfsense_local/backend/tests/integration/chat/conftest.pysurfsense_local/backend/tests/integration/chat/test_chat.pysurfsense_local/backend/tests/integration/test_app_boot.pySurfSense.code-workspace.vscode/settings.jsonsurfsense_local/backend/.vscode/settings.jsonsurfsense_local/README.md