Skip to content

Commit e39d92c

Browse files
committed
Unified KB and MB to work with common vector db backends.
1 parent 562e5f2 commit e39d92c

24 files changed

Lines changed: 1310 additions & 489 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ src/backend/langflow/frontend/
268268
src/backend/base/langflow/frontend/
269269
.docker
270270
scratchpad*
271+
scratch/
271272
chroma*/*
272273
stuff/*
273274
src/frontend/playwright-report/index.html

src/backend/base/langflow/api/utils/kb_helpers.py

Lines changed: 115 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,115 @@ def chunk_text_for_ingestion(
100100
return splitter.split_text(text)
101101

102102

103+
def _coerce_backend_config_value(value: Any) -> dict[str, Any]:
104+
"""Normalize a stored ``backend_config`` into a plain dict."""
105+
if isinstance(value, dict):
106+
return dict(value)
107+
return {}
108+
109+
110+
async def resolve_backend_selection(
111+
*,
112+
user_id: uuid.UUID,
113+
kb_name: str,
114+
kb_path: Path,
115+
) -> tuple[str, dict[str, Any]]:
116+
"""Resolve ``(backend_type, backend_config)`` for a KB — DB row first, sidecar second.
117+
118+
The ``knowledge_base`` row is authoritative. The on-disk sidecar is a legacy
119+
fallback for KBs predating the row (or dropped in from an export), not a
120+
default: when neither source resolves this raises rather than assuming local
121+
storage. A replica that merely lacks the sidecar would otherwise write a
122+
remote-backed KB into a local Chroma directory while queries followed the row
123+
to the configured cluster and returned nothing, with no error anywhere.
124+
125+
Shared by Knowledge Bases and Memory Bases so the two cannot drift apart.
126+
"""
127+
from langflow.api.utils import knowledge_base_service
128+
129+
record = await knowledge_base_service.get_by_user_and_name(user_id, kb_name)
130+
if record is not None:
131+
return (
132+
record.backend_type or BackendType.CHROMA.value,
133+
_coerce_backend_config_value(record.backend_config),
134+
)
135+
136+
# A sidecar with no ``backend_type`` is an explicit legacy local KB; only the
137+
# *absence* of both sources is ambiguous.
138+
metadata_file = kb_path / "embedding_metadata.json"
139+
if await asyncio.to_thread(metadata_file.exists):
140+
metadata = KBAnalysisHelper.get_metadata(kb_path, fast=True)
141+
return (
142+
str(metadata.get("backend_type") or BackendType.CHROMA.value),
143+
_coerce_backend_config_value(metadata.get("backend_config")),
144+
)
145+
146+
msg = (
147+
f"Cannot determine the vector-store backend for '{kb_name}': it has no "
148+
f"knowledge_base record and no embedding metadata on disk. Refusing to fall "
149+
f"back to local storage, which would write to a different store than queries "
150+
f"read from."
151+
)
152+
raise ValueError(msg)
153+
154+
155+
# Default embedding used when neither the DB row nor the sidecar records one.
156+
# Matches the historical fallback in ``resolve_embedding`` so behavior is
157+
# unchanged for callers that relied on it.
158+
_DEFAULT_EMBEDDING_PROVIDER = "OpenAI"
159+
_DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
160+
161+
162+
async def resolve_embedding_selection(
163+
*,
164+
user_id: uuid.UUID,
165+
kb_name: str,
166+
kb_path: Path | None = None,
167+
) -> tuple[str, str]:
168+
"""Resolve ``(embedding_provider, embedding_model)`` for a KB — DB row first, sidecar second.
169+
170+
Counterpart to :func:`resolve_backend_selection`: the ``knowledge_base`` row's
171+
``model_selection`` is authoritative, the on-disk ``embedding_metadata.json``
172+
sidecar is a legacy fallback (KBs predating the row / shared with file-based
173+
KBs), and a hardcoded default is the last resort.
174+
175+
Memory Bases always have a row (created at MB-create and backfilled for
176+
pre-existing ones), so they resolve entirely from the DB and never touch the
177+
sidecar — which is what lets a Memory Base work on a replica whose local disk
178+
never held the KB directory. ``kb_path`` is therefore optional: pass ``None``
179+
from a fully DB-driven path and the sidecar branch is skipped entirely (no
180+
dependency on ``knowledge_bases_dir`` or the local filesystem). The sidecar
181+
branch stays for Knowledge Bases, which still write it.
182+
183+
Shared by Knowledge Bases and Memory Bases so the two cannot drift apart.
184+
"""
185+
from langflow.api.utils import knowledge_base_service
186+
from langflow.api.utils.knowledge_base_service import get_embedding_model, get_embedding_provider
187+
188+
record = await knowledge_base_service.get_by_user_and_name(user_id, kb_name)
189+
if record is not None:
190+
model = get_embedding_model(record.model_selection)
191+
if model:
192+
return get_embedding_provider(record.model_selection), model
193+
194+
# Legacy fallback: read the sidecar. Only attempted when a ``kb_path`` is
195+
# supplied and there is no row (or a row with no model recorded) — i.e. never
196+
# for a properly provisioned Memory Base.
197+
if kb_path is not None:
198+
metadata_file = kb_path / "embedding_metadata.json"
199+
if await asyncio.to_thread(metadata_file.exists):
200+
metadata = KBAnalysisHelper.get_metadata(kb_path, fast=True)
201+
provider = str(metadata.get("embedding_provider") or "").strip()
202+
model = str(metadata.get("embedding_model") or "").strip()
203+
if model and model.lower() != "unknown":
204+
return (
205+
provider if provider and provider.lower() != "unknown" else _DEFAULT_EMBEDDING_PROVIDER,
206+
model,
207+
)
208+
209+
return _DEFAULT_EMBEDDING_PROVIDER, _DEFAULT_EMBEDDING_MODEL
210+
211+
103212
class KBStorageHelper:
104213
"""Helper class for Knowledge Base storage and path management."""
105214

@@ -981,62 +1090,6 @@ async def cleanup_chroma_chunks_by_job(
9811090
finally:
9821091
await backend.teardown()
9831092

984-
@staticmethod
985-
async def write_documents_to_chroma(
986-
*,
987-
documents: list[Document],
988-
chroma: Chroma,
989-
task_job_id: uuid.UUID,
990-
job_service: JobService,
991-
) -> int:
992-
"""Write pre-built Documents into an open Chroma collection.
993-
994-
This is the shared primitive used by both file-based KB ingestion
995-
(``perform_ingestion``) and message-based Memory Base ingestion.
996-
997-
Documents must already be chunked and have their metadata populated
998-
by the caller — this method only handles the batched write, cancellation
999-
checking, and retry logic.
1000-
1001-
Args:
1002-
documents: LangChain Document objects ready for embedding.
1003-
chroma: An already-constructed ``Chroma`` instance pointing at the
1004-
target collection.
1005-
task_job_id: Job ID used to poll for cancellation.
1006-
job_service: Service for checking job status.
1007-
1008-
Returns:
1009-
Number of documents successfully written. If the job is cancelled
1010-
mid-batch this will be less than ``len(documents)``.
1011-
1012-
Raises:
1013-
Exception: Re-raises any non-cancellation write failure after the
1014-
retry budget is exhausted.
1015-
"""
1016-
written = 0
1017-
for i in range(0, len(documents), INGESTION_BATCH_SIZE):
1018-
if await KBIngestionHelper.is_job_cancelled(job_service, task_job_id):
1019-
return written
1020-
1021-
batch = documents[i : i + INGESTION_BATCH_SIZE]
1022-
for attempt in range(MAX_RETRY_ATTEMPTS):
1023-
if await KBIngestionHelper.is_job_cancelled(job_service, task_job_id):
1024-
return written
1025-
try:
1026-
await chroma.aadd_documents(batch)
1027-
break
1028-
except Exception as e:
1029-
if attempt == MAX_RETRY_ATTEMPTS - 1:
1030-
raise
1031-
wait = (attempt + 1) * EXPONENTIAL_BACKOFF_MULTIPLIER
1032-
await logger.awarning("Write failed, retrying in %ds: %s", wait, e)
1033-
await asyncio.sleep(wait)
1034-
1035-
written += len(batch)
1036-
await asyncio.sleep(0.01)
1037-
1038-
return written
1039-
10401093
@staticmethod
10411094
async def write_documents_to_backend(
10421095
*,
@@ -1047,12 +1100,12 @@ async def write_documents_to_backend(
10471100
) -> int:
10481101
"""Write pre-built Documents through a ``BaseVectorStoreBackend``.
10491102
1050-
Backend-agnostic counterpart to :meth:`write_documents_to_chroma`.
1051-
Used by the multi-backend KB ingestion path so Mongo/Astra/
1052-
Postgres/OpenSearch ingestions share the same batching,
1053-
cancellation-checking, and exponential-backoff retry logic that
1054-
Memory Base's Chroma path gets from
1055-
:meth:`write_documents_to_chroma`.
1103+
The single write primitive for every ingestion path — file-based
1104+
Knowledge Bases and message-based Memory Bases alike — so batching,
1105+
cancellation checking, and exponential-backoff retry behave identically
1106+
whatever backend the KB is on. A Chroma-only counterpart used to exist
1107+
for Memory Bases; it was removed so the local-only path cannot be
1108+
reintroduced by accident.
10561109
10571110
Documents must already be chunked with metadata populated.
10581111

src/backend/base/langflow/api/utils/knowledge_base_service.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,67 @@ async def backfill_all_users_from_disk(*, kb_root: Path | None = None) -> int:
214214
return inserted
215215

216216

217+
async def backfill_memory_base_rows() -> int:
218+
"""Ensure every Memory Base has a backing ``knowledge_base`` row.
219+
220+
Memory Bases are DB-driven: their backend + embedding config resolve from the
221+
``knowledge_base`` row, never an on-disk sidecar. Memory Bases created before
222+
that row existed have only a ``memory_base`` row, so this startup reconcile
223+
creates the missing ``knowledge_base`` row from the ``memory_base`` table
224+
itself — no disk access, so it works on a replica whose filesystem never held
225+
the KB directory.
226+
227+
Only Memory Bases *lacking* a ``knowledge_base`` row are fetched (a single
228+
``NOT EXISTS`` query), so after the first run this returns zero rows and does
229+
almost nothing — startup cost is proportional to the backlog, not the total
230+
Memory Base count. ``backend_type="chroma"`` with empty config is the correct
231+
reconstruction: a Memory Base with no row predates the backend selector, when
232+
every Memory Base was local Chroma, and the ``memory_base`` table records no
233+
backend to recover. Memory Bases created through the selector get their real
234+
backend from ``_create_kb_record_for_memory_base`` and never reach this path.
235+
236+
Returns the number of rows inserted. Never raises — per-MB failures are logged
237+
and skipped so one bad row doesn't block startup.
238+
"""
239+
from sqlalchemy import exists
240+
241+
from langflow.services.database.models.memory_base.model import MemoryBase
242+
from langflow.services.memory_base.embedding_helpers import infer_embedding_provider
243+
244+
# Fetch only Memory Bases with no matching knowledge_base row (by owner + name).
245+
orphan_stmt = (
246+
select(MemoryBase)
247+
.where(MemoryBase.kb_name != "")
248+
.where(
249+
~exists(
250+
select(KnowledgeBaseRecord.id)
251+
.where(KnowledgeBaseRecord.user_id == MemoryBase.user_id)
252+
.where(KnowledgeBaseRecord.name == MemoryBase.kb_name)
253+
)
254+
)
255+
)
256+
async with session_scope() as session:
257+
orphans = list((await session.exec(orphan_stmt)).all())
258+
259+
inserted = 0
260+
for mb in orphans:
261+
try:
262+
provider = infer_embedding_provider(mb.embedding_model)
263+
await create_record(
264+
user_id=mb.user_id,
265+
name=mb.kb_name,
266+
model_selection={"name": mb.embedding_model, "provider": provider},
267+
backend_type="chroma",
268+
backend_config={},
269+
source_types=["memory"],
270+
)
271+
inserted += 1
272+
except Exception as exc: # noqa: BLE001
273+
await logger.aerror("memory-base backfill: failed to upsert KB row for %s: %s", mb.kb_name, exc)
274+
275+
return inserted
276+
277+
217278
async def update_stats(
218279
record_id: UUID,
219280
*,

0 commit comments

Comments
 (0)