Skip to content

[Feat] Community Local: ingestion worker, local models, retrieval & grounded chat - #1743

Merged
CREDO23 merged 13 commits into
MODSetter:devfrom
CREDO23:feat/community-local-plans
Sep 5, 2026
Merged

[Feat] Community Local: ingestion worker, local models, retrieval & grounded chat#1743
CREDO23 merged 13 commits into
MODSetter:devfrom
CREDO23:feat/community-local-plans

Conversation

@CREDO23

@CREDO23 CREDO23 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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, or failed with 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 a Generator (anything that can answer) from a ModelStore (only a runtime that holds weights on disk can pull), so the download UI is gated by isinstance rather 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's retrieve() 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 so shared does not pull worker in 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 one ChatMessage each — the assistant row carrying its citations for the UI.

Proper SSE. Frames are data: {json}\n\n and the stream ends on a data: [DONE]\n\n sentinel, so a client tells completion apart from a dropped socket. Deltas and the citation list are separate frames; a generator failure is its own error frame and the partial turn still persists. The response sets Cache-Control: no-cache and X-Accel-Buffering: no so a proxy streams it through rather than buffering the whole reply into one late blob.

Notes for review

  • 102 tests, still split tests/unit/<feature> and tests/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.
  • Real-model tests fetch bge-small on first run and skip if it is absent, so the suite stays green on a machine that has never pulled it.
  • Migrations stay at one hand-written revision — selected_models folded in — since nothing has shipped.
  • The parser and encoder weights are bundled; a generation model is downloaded in-app on first run, not shipped. Recorded in api/05-packaging.md.
  • Plans say what was built rather than what to build; the phase index carries progress markers. Chat and search phases close here.

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 (Generator vs ModelStore) 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\n sentinel), 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 with selected_models included, 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 under surfsense_local/; nothing outside is touched.

⏱️ Estimated Review Time: 1-3 hours

💡 Review Order Suggestion
Order File Path
1 plans/community-local/00-umbrella-plan.md
2 plans/community-local/00c-data-model.md
3 plans/community-local/api/02-upload.md
4 plans/community-local/api/03-chat.md
5 plans/community-local/api/05-packaging.md
6 plans/community-local/worker/01-boot.md
7 plans/community-local/worker/02-ingest.md
8 plans/community-local/worker/03-search.md
9 surfsense_local/backend/alembic/versions/0001_initial_schema.py
10 surfsense_local/backend/shared/config.py
11 surfsense_local/backend/shared/db.py
12 surfsense_local/backend/shared/queue.py
13 surfsense_local/backend/modules/llm/models.py
14 surfsense_local/backend/modules/llm/providers/protocols.py
15 surfsense_local/backend/modules/llm/providers/types.py
16 surfsense_local/backend/modules/llm/providers/__init__.py
17 surfsense_local/backend/modules/llm/providers/ollama/catalog.py
18 surfsense_local/backend/modules/llm/providers/ollama/provider.py
19 surfsense_local/backend/modules/llm/dependencies.py
20 surfsense_local/backend/modules/llm/schemas.py
21 surfsense_local/backend/modules/llm/router.py
22 surfsense_local/backend/worker/consumer.py
23 surfsense_local/backend/worker/ingestion/parsing.py
24 surfsense_local/backend/worker/ingestion/chunking.py
25 surfsense_local/backend/worker/ingestion/embedding.py
26 surfsense_local/backend/worker/ingestion/indexing.py
27 surfsense_local/backend/worker/ingestion/pipeline.py
28 surfsense_local/backend/worker.py
29 surfsense_local/backend/shared/search.py
30 surfsense_local/backend/modules/chat/models.py
31 surfsense_local/backend/modules/chat/prompt.py
32 surfsense_local/backend/modules/chat/history.py
33 surfsense_local/backend/modules/chat/dependencies.py
34 surfsense_local/backend/modules/chat/schemas.py
35 surfsense_local/backend/modules/chat/router.py
36 surfsense_local/backend/api/main.py
37 surfsense_local/backend/modules/documents/storage.py
38 surfsense_local/backend/modules/documents/tasks.py
39 surfsense_local/backend/modules/documents/router.py
40 surfsense_local/backend/pyproject.toml
41 surfsense_local/backend/scripts/fetch_embedding_model.py
42 surfsense_local/backend/tests/conftest.py
43 surfsense_local/backend/tests/integration/test_registration.py
44 surfsense_local/backend/tests/unit/worker/test_chunking.py
45 surfsense_local/backend/tests/unit/chat/test_prompt.py
46 surfsense_local/backend/tests/unit/chat/test_history.py
47 surfsense_local/backend/tests/unit/llm/test_catalog.py
48 surfsense_local/backend/tests/integration/worker/conftest.py
49 surfsense_local/backend/tests/integration/worker/test_ingest.py
50 surfsense_local/backend/tests/integration/worker/test_consumer.py
51 surfsense_local/backend/tests/integration/search/test_retrieve.py
52 surfsense_local/backend/tests/integration/llm/conftest.py
53 surfsense_local/backend/tests/integration/llm/test_routes.py
54 surfsense_local/backend/tests/integration/chat/conftest.py
55 surfsense_local/backend/tests/integration/chat/test_chat.py
56 surfsense_local/backend/tests/integration/test_app_boot.py
57 SurfSense.code-workspace
58 .vscode/settings.json
59 surfsense_local/backend/.vscode/settings.json
60 surfsense_local/README.md

Need help? Join our Discord

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.
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

@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.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 82e86a36-7fb3-4777-8fc0-def19d8871bd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@CREDO23
CREDO23 merged commit b2c4017 into MODSetter:dev Sep 5, 2026
4 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant