Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
50b2171
feat(seekdb): add native chunk vector index
LRriver Jul 9, 2026
8f1d7a3
fix(seekdb): refresh and replace chunk index writes
LRriver Jul 9, 2026
67ddfea
fix(seekdb): make source replacement authoritative
LRriver Jul 9, 2026
857c5a5
fix(seekdb): make empty replacement idempotent
LRriver Jul 9, 2026
e5dbf32
fix(seekdb): preserve replacement semantics
LRriver Jul 9, 2026
cf83148
feat(seekdb): make native vector index primary
LRriver Jul 9, 2026
4485f94
fix(seekdb): require explicit sqlite vector fallback
LRriver Jul 9, 2026
3d10361
fix(seekdb): enforce native embedding writes
LRriver Jul 9, 2026
7aad089
fix(seekdb): avoid stale native chunk state
LRriver Jul 9, 2026
05f1579
fix(seekdb): keep chunk writes atomic with native index
LRriver Jul 9, 2026
3f4510b
fix(seekdb): reject inconsistent native metadata mutations
LRriver Jul 9, 2026
7328fbe
fix(seekdb): keep native failures strict
LRriver Jul 9, 2026
243eef0
feat(seekdb): use native hybrid retrieval
LRriver Jul 9, 2026
b3f8160
feat(config): expose actual vector backend status
LRriver Jul 9, 2026
c8846f0
feat(seekdb): backfill existing chunks into native index
LRriver Jul 10, 2026
50e9f55
fix(seekdb): separate native vectors from sqlite metadata
LRriver Jul 10, 2026
c2b87c6
test(seekdb): add native vector verification script
LRriver Jul 10, 2026
936c0e9
docs: add SeekDB primary retrieval implementation plan
LRriver Jul 10, 2026
2c1c8de
fix(storage): make SeekDB synchronization recoverable
LRriver Jul 10, 2026
a6e1b80
fix(storage): harden SeekDB recovery and lifecycle
LRriver Jul 10, 2026
8a47d8f
fix(storage): harden SeekDB retrieval lifecycle
LRriver Jul 10, 2026
2cc6b1c
fix(storage): address SeekDB review feedback
LRriver Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
PYTHONPATH: .
run: python -m pytest -q

- name: Verify native SeekDB retrieval
run: timeout 120s python scripts/verify_seekdb_native.py

- name: Compile backend modules
run: python -m compileall -q backend

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ Key backend choices:
- Python 3.10+
- Node.js 18+
- A text model endpoint configured in `config.yaml`
- Optional: embedding, rerank, and speech model endpoints
- An embedding endpoint when using the default strict SeekDB mode
- Optional: rerank and speech model endpoints

### Backend

Expand All @@ -111,16 +112,19 @@ cp config_example.yaml config.yaml
Edit `config.yaml` and fill in the profiles you want to use:

- `api.models.text_model`: chat, RAG answers, Studio artifacts, podcast scripts
- `api.models.embedding_model`: optional vector retrieval
- `api.models.embedding_model`: required for ingestion and vector retrieval when `seekdb_allow_sqlite_fallback` is `false`
- `api.models.rerank_model`: optional rerank
- `api.models.audio_model`: optional speech/audio generation
- `api.models.image_model`: slide image generation
- `api.models.edit_model`: single-slide image editing
- `storage.seekdb_path`: local knowledge database
- `storage.seekdb_path`: local repository base path; SQLite keeps business metadata and native SeekDB keeps chunk vectors and retrieval indexes beside it
- `storage.seekdb_allow_sqlite_fallback`: compatibility-only SQLite retrieval fallback; keep `false` to make SeekDB authoritative
- `documents.chunking`: Chonkie/simple chunking settings

Keep `config.yaml` local. It is ignored by git and should not contain committed keys.

Once sources are indexed, the runtime settings API rejects changes to the embedding model, adapter, or base URL because existing vectors would no longer be compatible. Delete and re-import the sources before changing that embedding identity. API-key rotation does not require re-indexing.

Start the backend:

```bash
Expand Down
10 changes: 7 additions & 3 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ backend/
- Python 3.10+
- Node.js 18+
- 一个可用的文本模型接口
- 可选:embedding、rerank、语音模型接口
- 默认严格 SeekDB 模式下需要可用的 embedding 接口
- 可选:rerank、语音模型接口

### 后端

Expand All @@ -111,16 +112,19 @@ cp config_example.yaml config.yaml
编辑 `config.yaml`,填写模型与存储配置:

- `api.models.text_model`:聊天、RAG 回答、Studio artifact、播客脚本
- `api.models.embedding_model`:可选向量召回
- `api.models.embedding_model`:当 `seekdb_allow_sqlite_fallback` 为 `false` 时,文档摄取与向量召回必填
- `api.models.rerank_model`:可选 rerank
- `api.models.audio_model`:可选语音/音频生成
- `api.models.image_model`:Slide Deck 图片生成
- `api.models.edit_model`:单页图片编辑
- `storage.seekdb_path`:本地知识库路径
- `storage.seekdb_path`:本地 repository 基础路径;SQLite 保存业务元数据,原生 SeekDB 在相邻目录保存 chunk 向量与检索索引
- `storage.seekdb_allow_sqlite_fallback`:仅用于兼容的 SQLite 检索回退;保持 `false` 时 SeekDB 是检索权威存储
- `documents.chunking`:Chonkie/simple chunk 参数

`config.yaml` 只用于本地运行,已被 git 忽略,不要提交真实 key。

已有 source 完成索引后,运行时配置接口会拒绝修改 embedding 模型、adapter 或 base URL,避免用不兼容的新查询向量检索旧索引。需要先删除并重新导入 source;仅轮换 API key 不需要重建索引。

启动后端:

```bash
Expand Down
55 changes: 49 additions & 6 deletions backend/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

from __future__ import annotations

from fastapi import APIRouter
from fastapi import APIRouter, HTTPException

from ...config import ModelProfile, ModelProfiles, get_settings, update_runtime_model_profiles
from ...config import (
ModelProfile,
ModelProfiles,
get_settings,
model_profile_identity,
update_runtime_model_profiles,
)
from ...dependencies import DependencyContainer
from ..schemas.config import PublicModelProfile, RuntimeConfigResponse, RuntimeConfigUpdate

Expand All @@ -24,9 +30,12 @@ def _public_profile(profile: ModelProfile) -> PublicModelProfile:
)


def _response(message: str = "") -> RuntimeConfigResponse:
async def _response(message: str = "") -> RuntimeConfigResponse:
settings = get_settings()
models = settings.api.models
vector_store = DependencyContainer.get_vector_store(settings=settings)
stats = await vector_store.get_stats()
storage_status = stats.get("storage", {})
return RuntimeConfigResponse(
models={
name: _public_profile(getattr(models, name))
Expand All @@ -40,23 +49,57 @@ def _response(message: str = "") -> RuntimeConfigResponse:
},
storage={
"vector_store_type": settings.vector_store_type,
"configured_vector_store_type": settings.vector_store_type,
"seekdb_path": settings.seekdb_path,
"seekdb_allow_sqlite_fallback": settings.seekdb_allow_sqlite_fallback,
"actual_vector_backend": storage_status.get("vector_backend", stats.get("backend", "unknown")),
"native_available": storage_status.get("native_available", False),
"embedding_required": (
settings.vector_store_type == "seekdb"
and not settings.seekdb_allow_sqlite_fallback
),
"embedding_configured": bool(
models.embedding_model.model and models.embedding_model.api_key
),
},
message=message,
)


@router.get("", response_model=RuntimeConfigResponse)
async def get_runtime_config() -> RuntimeConfigResponse:
return _response()
return await _response()


@router.post("", response_model=RuntimeConfigResponse)
async def update_runtime_config(request: RuntimeConfigUpdate) -> RuntimeConfigResponse:
requested_embedding = request.models.get("embedding_model")
if requested_embedding is not None:
settings = get_settings()
current_profile = settings.api.models.embedding_model
requested_values = requested_embedding.model_dump(exclude_none=True)
if requested_values.get("api_key") == "":
requested_values.pop("api_key")
next_profile = current_profile.model_copy(update=requested_values)
if model_profile_identity(next_profile) != model_profile_identity(current_profile):
vector_store = DependencyContainer.get_vector_store(settings=settings)
stats = await vector_store.get_stats()
indexed_vector_chunks = stats.get(
"indexed_vector_chunks",
stats.get("total_chunks", 0),
)
if indexed_vector_chunks > 0:
raise HTTPException(
status_code=409,
detail=(
"The embedding model, adapter, or base URL cannot change while indexed "
"sources exist. Delete and re-index the sources first."
),
)
profile_data = {
name: profile.model_dump(exclude_none=True)
for name, profile in request.models.items()
}
update_runtime_model_profiles(profile_data)
DependencyContainer.reset_runtime_caches()
return _response("Runtime model configuration updated.")
DependencyContainer.reset_runtime_caches(preserve_repository=True)
return await _response("Runtime model configuration updated.")
4 changes: 4 additions & 0 deletions backend/api/routes/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ async def upload_document(
char_count=source.char_count
)

except HTTPException:
if os.path.exists(file_path):
os.remove(file_path)
raise
except Exception as e:
# 清理文件
if os.path.exists(file_path):
Expand Down
11 changes: 11 additions & 0 deletions backend/api/routes/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def _detail(source: KnowledgeSource) -> SourceDetail:
return SourceDetail(**_info(source).model_dump(), text=source.text)


def _raise_if_failed(source: KnowledgeSource) -> None:
if source.status.value == "error":
raise HTTPException(status_code=422, detail=source.error or "Source ingestion failed")


@router.post("/text", response_model=SourceDetail)
async def create_text_source(
request: SourceCreateTextRequest,
Expand All @@ -55,6 +60,7 @@ async def create_text_source(
text=request.text,
metadata=request.metadata,
)
_raise_if_failed(source)
return _detail(source)


Expand All @@ -76,7 +82,12 @@ async def upload_source(
filename=filename,
mime_type=file.content_type,
)
_raise_if_failed(source)
return _detail(source)
except HTTPException:
if file_path.exists():
file_path.unlink()
raise
except Exception as exc:
if file_path.exists():
file_path.unlink()
Expand Down
22 changes: 21 additions & 1 deletion backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from __future__ import annotations

import hashlib
import json
import os
from functools import lru_cache
from pathlib import Path
Expand Down Expand Up @@ -36,6 +38,18 @@ class ModelProfile(BaseModel):
stream: bool = True


def model_profile_identity(profile: ModelProfile) -> str:
"""Return a stable, secret-free identity for embedding compatibility checks."""

payload = {
"adapter": profile.adapter.strip(),
"base_url": profile.base_url.strip().rstrip("/"),
"model": profile.model.strip(),
}
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If any of the fields profile.adapter, profile.base_url, or profile.model are None (which can happen if they are configured as null in config.yaml or passed as null in API updates), calling .strip() or .rstrip() on them will raise an AttributeError: 'NoneType' object has no attribute 'strip'.

To prevent potential crashes, we should safely handle None values by falling back to empty strings before stripping.

Suggested change
payload = {
"adapter": profile.adapter.strip(),
"base_url": profile.base_url.strip().rstrip("/"),
"model": profile.model.strip(),
}
payload = {
"adapter": (profile.adapter or "").strip(),
"base_url": (profile.base_url or "").strip().rstrip("/"),
"model": (profile.model or "").strip(),
}

serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()


class ModelProfiles(BaseModel):
"""Configured model roles."""

Expand Down Expand Up @@ -84,6 +98,7 @@ class Settings(BaseSettings):
vector_store_type: Literal["chroma", "faiss", "seekdb"] = "seekdb"
chroma_persist_dir: str = "./data/chroma"
seekdb_path: str = "./data/seekdb.db"
seekdb_allow_sqlite_fallback: bool = False

# 嵌入模型设置 (OpenAI API)
embedding_model: str = "text-embedding-3-small"
Expand Down Expand Up @@ -138,7 +153,12 @@ def _flatten_config(data: dict[str, Any]) -> dict[str, Any]:

storage = data.get("storage") or {}
if isinstance(storage, dict):
for key in ("vector_store_type", "chroma_persist_dir", "seekdb_path"):
for key in (
"vector_store_type",
"chroma_persist_dir",
"seekdb_path",
"seekdb_allow_sqlite_fallback",
):
if key in storage:
flattened[key] = storage[key]

Expand Down
4 changes: 3 additions & 1 deletion backend/core/services/source_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ async def _finish_source(
for chunk, embedding in zip(chunks, embeddings):
chunk.embedding = embedding
source.chunk_count = len(chunks)
source.status = SourceStatus.READY
source.updated_at = utc_now()
await self.repository.save_source(source)
await self.repository.save_chunks(source.id, chunks)
source.status = SourceStatus.READY
source.updated_at = utc_now()
await self.repository.save_source(source)
return source

async def _fail_source(self, source: KnowledgeSource, exc: Exception) -> KnowledgeSource:
Expand Down
27 changes: 24 additions & 3 deletions backend/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"""
from typing import Any, Optional

from .config import ModelProfile, Settings, get_settings
from .config import ModelProfile, Settings, get_settings, model_profile_identity
from .core.interfaces.vector_store import VectorStoreInterface
from .core.interfaces.knowledge_repository import KnowledgeRepositoryInterface
from .core.interfaces.llm_provider import LLMProviderInterface
Expand All @@ -22,11 +22,18 @@ class DependencyContainer:
_slide_deck_service: Optional[Any] = None

@classmethod
def reset_runtime_caches(cls) -> None:
def reset_runtime_caches(cls, *, preserve_repository: bool = False) -> None:
"""Drop cached services that depend on model settings."""

repository = cls._knowledge_repository
cls._vector_store = None
cls._slide_deck_service = None
if preserve_repository:
return
cls._knowledge_repository = None
close_repository = getattr(repository, "close_sync", None)
if callable(close_repository):
close_repository()

@classmethod
def get_vector_store(
Expand Down Expand Up @@ -86,9 +93,23 @@ def get_knowledge_repository(
force_new: bool = False,
) -> KnowledgeRepositoryInterface:
if cls._knowledge_repository is None or force_new:
if force_new and cls._knowledge_repository is not None:
cls._vector_store = None
cls._slide_deck_service = None
close_repository = getattr(cls._knowledge_repository, "close_sync", None)
if callable(close_repository):
close_repository()
settings = settings or get_settings()
from .infrastructure.repositories.seekdb_repository import SeekDBRepository
cls._knowledge_repository = SeekDBRepository(settings.seekdb_path)
cls._knowledge_repository = SeekDBRepository(
settings.seekdb_path,
allow_sqlite_vector_fallback=settings.seekdb_allow_sqlite_fallback,
embedding_profile_id=model_profile_identity(settings.api.models.embedding_model),
)
elif settings is not None:
embedding_profile_id = model_profile_identity(settings.api.models.embedding_model)
if hasattr(cls._knowledge_repository, "embedding_profile_id"):
cls._knowledge_repository.embedding_profile_id = embedding_profile_id
return cls._knowledge_repository

@classmethod
Expand Down
Loading
Loading