Skip to content

feat: Unified KBs and MBs to use common vector db backends - #14239

Open
dkaushik94 wants to merge 4 commits into
release-1.12.0from
mb-refactor-with-kb
Open

feat: Unified KBs and MBs to use common vector db backends#14239
dkaushik94 wants to merge 4 commits into
release-1.12.0from
mb-refactor-with-kb

Conversation

@dkaushik94

@dkaushik94 dkaushik94 commented Jul 23, 2026

Copy link
Copy Markdown
Member

Unify Knowledge Bases and Memory Bases on shared vector-store backends

Summary

Memory Bases were hardwired to local Chroma — a directory plus an on-disk
sidecar file. Because their backend and embedding config could only be recovered
by reading that local directory, a replica that never touched it silently fell
back to local Chroma: a remote-backed Memory Base was indistinguishable from a
local one, and writes/queries could diverge to different stores with no error.

This PR makes every Memory Base a first-class Knowledge Base backed by a
knowledge_base row that is the single source of truth for its backend,
embedding, and cached stats. That one change unlocks the pluggable backends KBs
already support (Chroma local/cloud, OpenSearch, MongoDB, Postgres, Astra) for
Memory Bases, and makes all resolution DB-driven and replica-safe.

What changed

  • Row-driven backend/embedding resolution. resolve_backend_selection /
    resolve_embedding_selection read the knowledge_base row first and treat the
    on-disk sidecar as a legacy fallback only — so resolution no longer depends on
    local disk.
  • Create/delete provision the row. MemoryBaseCreate gains backend_type /
    backend_config (default chroma, empty config). These are persisted on the
    backing knowledge_base row, not on the memory_base table — so there is
    no schema migration. Deleting a Memory Base now also removes that row.
  • Ingestion, purge, and retrieval go through the shared backend abstraction
    (create_backend) instead of opening Chroma directly. The write helper is now
    write_documents_to_backend and metadata sync is sync_kb_stats_to_record, so
    a Memory Base on OpenSearch/Chroma Cloud ingests to that store from any replica.
  • Startup backfill. backfill_memory_base_rows reconciles pre-existing
    Memory Bases that lack a row. It is sourced from the memory_base table (no
    disk access), touches only orphans via a single NOT EXISTS query, and never
    raises — so it's cheap after the first run and safe on a replica.
  • Frontend. The Create Memory modal adds a DB-provider selector
    (DBProviderInput), required before submit, with matching i18n strings and
    payload types.
  • OpenSearch teardown leak fix. OpenSearchBackend.teardown() now closes the
    async client LangChain's OpenSearchVectorSearch creates internally. Previously
    the reference was dropped without closing it, leaking the aiohttp
    connector/socket and emitting Unclosed connector / unclosed transport
    ResourceWarnings after ingestion.

Compatibility & risk

  • Defaults (backend_type="chroma", empty config) keep existing callers on local
    Chroma — no behavior change unless a different backend is selected.
  • No database migration: the new fields live on the create payload and the
    knowledge_base row.
  • The startup backfill is idempotent and non-fatal (per-row failures are logged
    and skipped).

Testing

Unit tests added/updated across Memory Base CRUD, the ingestion task, memory
retrieval, knowledge components, connector endpoints, and the Create Memory modal
hook. make format is clean (ruff + biome).

E2E testing evidence:

The following data was written for MemoryBases into Chroma Cloud and a hosted OpenSearch Cluster for testing using testing queries.

Screenshot 2026-07-23 at 7 30 04 PM Screenshot 2026-07-23 at 7 32 30 PM

Summary by CodeRabbit

  • New Features

    • Memory Bases now support selecting and configuring their vector database provider during creation.
    • Knowledge and Memory Base workflows use the configured provider for ingestion and retrieval.
    • Backend settings and usage statistics are synchronized for more consistent knowledge base management.
  • Bug Fixes

    • Prevented silent fallback to local storage when the configured provider cannot be determined.
    • Improved session filtering and retrieval across supported vector database providers.
    • Added startup reconciliation for missing Memory Base knowledge base records.
    • Improved cleanup and resource handling during ingestion and provider shutdown.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5eaf01ba-6a6f-4006-963f-213640c07d6b

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

Walkthrough

Memory Base and Knowledge Base flows now persist backend configuration in database records, resolve vector stores through shared backend abstractions, reconcile legacy rows at startup, and support backend selection in the Memory creation UI. Chroma-specific ingestion, retrieval, filtering, and metadata paths are replaced with backend-neutral operations.

Changes

Backend selection and persistence

Layer / File(s) Summary
Selection and persistence foundation
src/backend/base/langflow/api/utils/kb_helpers.py, src/backend/base/langflow/services/memory_base/*, src/backend/base/langflow/main.py
Backend and embedding resolution prefer database records, Memory Base creation persists its backing Knowledge Base row, and startup backfills missing rows.
Backend-agnostic ingestion lifecycle
src/backend/base/langflow/services/memory_base/*, src/backend/base/langflow/api/utils/kb_helpers.py, src/lfx/src/lfx/base/knowledge_bases/backends/opensearch.py
Ingestion, purge, cleanup, statistics synchronization, and teardown use shared backend interfaces.
Database-driven guards and retrieval
src/backend/base/langflow/api/v1/knowledge_bases.py, src/lfx/src/lfx/components/files_and_knowledge/*, src/backend/base/langflow/initial_setup/starter_projects/*, src/lfx/src/lfx/_assets/component_index.json
Memory Base guards inspect database source types, while Knowledge and Memory retrieval resolve configured backends and use flat metadata filters.
Memory provider configuration UI
src/frontend/src/controllers/API/queries/memories/types.ts, src/frontend/src/modals/createMemoryModal/*, src/frontend/src/locales/en.json
Memory creation supports backend type and configuration selection, validates provider configuration, and submits the selected backend settings.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CreateMemoryModal
  participant MemoryBaseService
  participant KnowledgeBaseRecord
  participant VectorBackend
  CreateMemoryModal->>MemoryBaseService: submit backend type and config
  MemoryBaseService->>VectorBackend: initialize configured backend
  MemoryBaseService->>KnowledgeBaseRecord: create backing record
  MemoryBaseService-->>CreateMemoryModal: create result
Loading

Possibly related PRs

Suggested labels: enhancement, refactor

Suggested reviewers: erichare

🚥 Pre-merge checks | ✅ 7 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Excessive Mock Usage Warning ⚠️ Warning New ingestion/service tests are over-mocked: test_memory_base_task.py uses 15+ patches per case (326 patches/358 mocks total), obscuring real interactions. Trim mocks with shared fakes/fixtures and add a higher-level integration test for the backend/task flow; mock only external I/O.
✅ Passed checks (7 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed Updated tests cover DB-driven backend resolution, ingestion/retrieval, backfill/delete, connector guards, modal payloads, and OpenSearch lifecycle; filenames follow conventions.
Test Quality And Coverage ✅ Passed Broad pytest/Jest coverage exercises happy and failure paths for backend resolution, ingestion, purge, APIs, and the Memory modal; async tests use AsyncMock/await and assert real behavior.
Test File Naming And Structure ✅ Passed PASS: The added tests follow repo conventions—backend files are test_*.py pytest suites with fixtures/async marks, and the frontend .test.tsx file uses jest/RTL with descriptive describe/it...
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: unifying Knowledge Bases and Memory Bases on shared vector database backends.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mb-refactor-with-kb

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.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Test Coverage Advisor

No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉

Advisory check only — never blocks merge.

@dkaushik94
dkaushik94 force-pushed the mb-refactor-with-kb branch from e39d92c to 5560886 Compare July 23, 2026 23:36
@dkaushik94
dkaushik94 changed the base branch from release-1.11.0 to release-1.12.0 July 23, 2026 23:36
@github-actions

Copy link
Copy Markdown
Contributor

Migration Validation Passed

All migrations follow the Expand-Contract pattern correctly.

@dkaushik94
dkaushik94 force-pushed the mb-refactor-with-kb branch from c6321b5 to 5d45b78 Compare July 23, 2026 23:39
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Build successful! ✅
Deploying docs draft.
Deploy successful! View draft

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.38926% with 124 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.49%. Comparing base (d021b3e) to head (3829f4a).

Files with missing lines Patch % Lines
...rc/frontend/src/modals/createMemoryModal/index.tsx 0.00% 34 Missing ⚠️
src/backend/base/langflow/api/utils/kb_helpers.py 41.17% 20 Missing ⚠️
...langflow/services/memory_base/document_builders.py 5.88% 16 Missing ⚠️
...rc/lfx/base/knowledge_bases/backends/opensearch.py 0.00% 14 Missing ⚠️
...e/langflow/services/memory_base/kb_path_helpers.py 22.22% 7 Missing ⚠️
.../base/langflow/api/utils/knowledge_base_service.py 62.50% 6 Missing ⚠️
...nd/base/langflow/services/memory_base/ingestion.py 79.31% 6 Missing ⚠️
...kend/base/langflow/services/memory_base/service.py 45.45% 6 Missing ⚠️
...tend/src/controllers/API/queries/memories/types.ts 0.00% 6 Missing ⚠️
...c/modals/createMemoryModal/useCreateMemoryModal.ts 94.62% 5 Missing ⚠️
... and 2 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           release-1.12.0   #14239      +/-   ##
==================================================
- Coverage           61.52%   61.49%   -0.04%     
==================================================
  Files                2349     2349              
  Lines              238463   238649     +186     
  Branches            33541    33567      +26     
==================================================
+ Hits               146719   146750      +31     
- Misses              89929    90084     +155     
  Partials             1815     1815              
Flag Coverage Δ
backend 68.51% <56.95%> (-0.25%) ⬇️
frontend 59.82% <66.16%> (+0.01%) ⬆️
lfx 60.39% <0.00%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...rc/backend/base/langflow/api/v1/knowledge_bases.py 73.60% <100.00%> (-3.11%) ⬇️
...flow/services/database/models/memory_base/model.py 95.60% <100.00%> (-2.15%) ⬇️
...backend/base/langflow/services/memory_base/task.py 84.03% <90.00%> (-1.69%) ⬇️
src/backend/base/langflow/main.py 64.64% <57.14%> (-0.10%) ⬇️
...c/modals/createMemoryModal/useCreateMemoryModal.ts 85.92% <94.62%> (+4.29%) ⬆️
.../base/langflow/api/utils/knowledge_base_service.py 81.17% <62.50%> (-1.34%) ⬇️
...nd/base/langflow/services/memory_base/ingestion.py 72.85% <79.31%> (-0.35%) ⬇️
...kend/base/langflow/services/memory_base/service.py 73.03% <45.45%> (+0.57%) ⬆️
...tend/src/controllers/API/queries/memories/types.ts 0.00% <0.00%> (ø)
...e/langflow/services/memory_base/kb_path_helpers.py 50.81% <22.22%> (+5.36%) ⬆️
... and 4 more

... and 117 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/base/langflow/services/memory_base/service.py (1)

176-207: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Duplicate Memory Base name leaves an orphaned knowledge_base row and provisioned collection.

initialize_kb and _create_kb_record_for_memory_base run at step 4, but the name-uniqueness check happens afterward at Lines 201–207 (and the IntegrityError path at 217–221). Because kb_name carries a random hex suffix, the KB row never collides, so a duplicate-name creation provisions a collection and inserts an authoritative knowledge_base row, then raises ValueError on the MemoryBase insert — leaving both behind with no MemoryBase referencing them and no cleanup.

Move the existence pre-check ahead of provisioning so the common duplicate case fails fast; for the race that only surfaces as IntegrityError, also tear down the provisioned KB row/collection before re-raising.

🔧 Sketch: pre-check before provisioning
+        # Fail fast on duplicate name BEFORE provisioning a collection / KB row,
+        # otherwise the row (random kb_name, never collides) is orphaned on raise.
+        async with session_scope() as db:
+            existing = await db.exec(
+                select(MemoryBase).where(MemoryBase.user_id == user_id).where(MemoryBase.name == payload.name)
+            )
+            if existing.first() is not None:
+                msg = f"A Memory Base named '{payload.name}' already exists for this user"
+                raise ValueError(msg)
+
         # 4. Provision the backing KB: the vector-store collection plus its
         #    ``knowledge_base`` row. ...
-        embedding_provider = infer_embedding_provider(payload.embedding_model)
         await initialize_kb(...)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/services/memory_base/service.py` around lines 176 -
207, Move the existing MemoryBase name-uniqueness query in the creation flow
before `initialize_kb` and `_create_kb_record_for_memory_base`, raising the same
`ValueError` before provisioning on duplicates. For the concurrent-creation path
caught by `IntegrityError`, invoke the established KB cleanup mechanism to
remove the provisioned `knowledge_base` row and collection, then re-raise the
original error.
🧹 Nitpick comments (3)
src/backend/tests/unit/test_memory_base_task.py (1)

232-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ingestion-task tests never verify the DB-driven backend selection actually reaches create_backend. Both files hardcode resolve_backend_selection to return ("chroma", {}) and mock create_backend with a bare return_value, but no test asserts create_backend is called with that resolved value (or exercises a non-Chroma backend type) — the exact pass-through this PR is meant to unify.

  • src/backend/tests/unit/test_memory_base_task.py#L232-L243: capture the create_backend mock and assert it's called with the tuple returned by resolve_backend_selection (or parametrize with a non-"chroma" backend type) in at least one representative test (e.g. test_mark_messages_ingested_called_on_success).
  • src/backend/tests/unit/test_memory_bases.py#L1586-L1598: apply the same assertion in test_cursor_advanced_on_success, mirroring the fix above.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/test_memory_base_task.py` around lines 232 - 243,
Update the representative ingestion-task tests so they verify backend selection
is passed through to create_backend: in
src/backend/tests/unit/test_memory_base_task.py lines 232-243, capture the
create_backend mock and assert it receives the tuple returned by
resolve_backend_selection, optionally using a non-chroma backend; apply the same
assertion in src/backend/tests/unit/test_memory_bases.py lines 1586-1598 within
test_cursor_advanced_on_success.

Source: Path instructions

src/backend/base/langflow/api/v1/knowledge_bases.py (1)

344-378: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant DB round-trip: _assert_kb_not_memory_base re-fetches a record _guard_kb_action already resolved.

Every one of the 8 call sites first calls _guard_kb_action(...), which already resolves and stores the KB record on _KbGuardResult.record (owner-scoped, or the cross-user-matched candidate). _assert_kb_not_memory_base(kb_name, owner_user) then unconditionally re-queries knowledge_base_service.get_by_user_and_name(owner_user.id, kb_name) for the same row (since owner_user is derived from the resolved record's user_id), adding an avoidable DB round-trip to every guarded KB endpoint (upload, folder-ingest, get, chunks, metadata-keys, connector-ingest, delete, cancel).

Accept an optional pre-fetched record and only query when the caller doesn't already have one (e.g. _kb_guard.record is None for legacy disk-only KBs).

♻️ Proposed fix to avoid the extra query when the caller already has the record
-async def _assert_kb_not_memory_base(kb_name: str, owner_user) -> None:
+async def _assert_kb_not_memory_base(
+    kb_name: str, owner_user, *, record: KnowledgeBaseRecord | None = None
+) -> None:
     """Post-resolution memory-base check.
     ...
     """
-    record = await knowledge_base_service.get_by_user_and_name(owner_user.id, kb_name)
+    if record is None:
+        record = await knowledge_base_service.get_by_user_and_name(owner_user.id, kb_name)
     if _record_is_memory_base_associated(record):

Then at each call site (e.g. line 1075): await _assert_kb_not_memory_base(kb_name, _kb_guard.owner_user, record=_kb_guard.record).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/base/langflow/api/v1/knowledge_bases.py` around lines 344 - 378,
Update _assert_kb_not_memory_base to accept an optional pre-fetched record and
reuse it when provided, querying knowledge_base_service only when the record is
absent. Modify all guarded endpoint call sites to pass _kb_guard.record along
with _kb_guard.owner_user, preserving the existing memory-base rejection
behavior and fallback lookup for legacy disk-only KBs.
src/backend/tests/unit/components/files_and_knowledge/test_knowledge.py (1)

355-448: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

New test class bypasses the mandated ComponentTestBase* fixtures and leans heavily on mocking the DB layer.

TestBackendResolution instantiates KnowledgeComponent directly and doesn't inherit from ComponentTestBaseWithClient/ComponentTestBaseWithoutClient, and doesn't define component_class/default_kwargs/file_names_mapping. Separately, get_by_user_and_name is AsyncMock-patched in every async test (including the "wins over sidecar" and "falls back to sidecar" happy-path tests), where a real DB-backed knowledge_base row (via existing test fixtures) would exercise the actual persistence contract instead of a stand-in SimpleNamespace. The DB-failure test (test_database_lookup_failure_propagates) is a reasonable exception since a real transient DB error is hard to simulate with a real integration.

As per coding guidelines: "Component tests must use either ComponentTestBaseWithClient for components needing API access or ComponentTestBaseWithoutClient for pure logic components, and must include component_class, default_kwargs, and file_names_mapping fixtures" and "Backend unit tests should avoid mocking when possible and prefer real integrations for more reliable tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/components/files_and_knowledge/test_knowledge.py`
around lines 355 - 448, Refactor TestBackendResolution to use the appropriate
ComponentTestBase fixture, defining component_class, default_kwargs, and
file_names_mapping instead of directly constructing KnowledgeComponent. Replace
the AsyncMock database patches and SimpleNamespace records in the happy-path
tests with real persisted knowledge_base rows through the existing fixtures;
retain mocking only for test_database_lookup_failure_propagates, which
specifically simulates a transient DB error.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lfx/src/lfx/_assets/component_index.json`:
- Line 9620: Normalize filter_by_session through a shared boolean conversion
that recognizes string values such as "false" as false, then use the normalized
result in both _build_where_clause() and retrieve_memory(). Ensure session_id
validation and where-clause construction follow the actual user-selected boolean
state rather than Python truthiness.
- Line 8118: The retrieval flow currently limits client-side metadata filtering
to a fixed top_k multiplier, which can omit valid lower-ranked matches. Update
the retrieval method that handles metadata_filter to pass the filter to backends
supporting native filtering; for unsupported backends, continue paginating or
expanding unfiltered fetches until top_k matches are collected or the store is
exhausted. Preserve existing result ordering and behavior when no filter is
provided, using _parse_metadata_filter and _chunk_matches_filter for client-side
filtering.
- Line 8118: Update _convert_df_to_data_objects so Chroma Cloud ingestion does
not perform local Chroma lookups for duplicate detection. Preserve the
distinction between local Chroma and Chroma Cloud after backend normalization,
and obtain existing IDs through the resolved backend when using Chroma Cloud.
Ensure existing_ids is populated before duplicate filtering without constructing
Chroma with a local persist_directory for cloud writes.
- Line 8118: Update KnowledgeComponent.retrieve_data() to resolve the embedding
model and configuration from the database-backed knowledge-base record before
attempting to load embedding_metadata.json or requiring the local sidecar.
Preserve sidecar loading only as a legacy fallback or when stored credentials
are required, so remote-backed knowledge bases retrieve successfully on replicas
without local sidecar files.

In `@src/lfx/src/lfx/components/files_and_knowledge/knowledge.py`:
- Around line 1565-1607: Update retrieve_data’s embedding-model metadata
resolution to query the database record via
knowledge_base_service.get_by_user_and_name first, reusing read_metadata where
appropriate for model_selection, chunk_size, chunk_overlap, and separator. If no
record exists, preserve the existing embedding_metadata.json sidecar fallback
for legacy knowledge bases, but do not require local metadata when a database
row is available.

In `@src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py`:
- Around line 216-245: Update _build_backend to clean up the constructed backend
when await backend.ensure_ready() raises. Wrap the readiness call and return
path with exception handling that invokes backend.teardown() on failure, then
re-raises the original exception so retrieve_memory’s existing finally behavior
remains unchanged for successfully returned backends.
- Around line 216-245: Backend setup failures can leave partially initialized
vector stores undisposed. In
src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py#L216-L245,
update _build_backend to tear down the created backend if ensure_ready() fails,
then re-raise; regenerate or re-sync the identical embedded _build_backend
source in src/backend/base/langflow/initial_setup/starter_projects/Memory
Chatbot.json#L1628-L1628 and Price Deal Finder.json#L2288-L2288. In
src/backend/base/langflow/initial_setup/starter_projects/Vector Store
RAG.json#L1314-L1314, update KnowledgeComponent._create_vector_store to tear
down the backend on failure across create_backend, ensure_ready, iter_documents,
or add_documents before re-raising.

---

Outside diff comments:
In `@src/backend/base/langflow/services/memory_base/service.py`:
- Around line 176-207: Move the existing MemoryBase name-uniqueness query in the
creation flow before `initialize_kb` and `_create_kb_record_for_memory_base`,
raising the same `ValueError` before provisioning on duplicates. For the
concurrent-creation path caught by `IntegrityError`, invoke the established KB
cleanup mechanism to remove the provisioned `knowledge_base` row and collection,
then re-raise the original error.

---

Nitpick comments:
In `@src/backend/base/langflow/api/v1/knowledge_bases.py`:
- Around line 344-378: Update _assert_kb_not_memory_base to accept an optional
pre-fetched record and reuse it when provided, querying knowledge_base_service
only when the record is absent. Modify all guarded endpoint call sites to pass
_kb_guard.record along with _kb_guard.owner_user, preserving the existing
memory-base rejection behavior and fallback lookup for legacy disk-only KBs.

In `@src/backend/tests/unit/components/files_and_knowledge/test_knowledge.py`:
- Around line 355-448: Refactor TestBackendResolution to use the appropriate
ComponentTestBase fixture, defining component_class, default_kwargs, and
file_names_mapping instead of directly constructing KnowledgeComponent. Replace
the AsyncMock database patches and SimpleNamespace records in the happy-path
tests with real persisted knowledge_base rows through the existing fixtures;
retain mocking only for test_database_lookup_failure_propagates, which
specifically simulates a transient DB error.

In `@src/backend/tests/unit/test_memory_base_task.py`:
- Around line 232-243: Update the representative ingestion-task tests so they
verify backend selection is passed through to create_backend: in
src/backend/tests/unit/test_memory_base_task.py lines 232-243, capture the
create_backend mock and assert it receives the tuple returned by
resolve_backend_selection, optionally using a non-chroma backend; apply the same
assertion in src/backend/tests/unit/test_memory_bases.py lines 1586-1598 within
test_cursor_advanced_on_success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e38af72-fbe2-4f27-81ce-75ce166faa4a

📥 Commits

Reviewing files that changed from the base of the PR and between 52b1ea1 and b06bf74.

📒 Files selected for processing (32)
  • .gitignore
  • src/backend/base/langflow/api/utils/kb_helpers.py
  • src/backend/base/langflow/api/utils/knowledge_base_service.py
  • src/backend/base/langflow/api/v1/knowledge_bases.py
  • src/backend/base/langflow/initial_setup/starter_projects/Custom Component Generator.json
  • src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json
  • src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json
  • src/backend/base/langflow/initial_setup/starter_projects/Meeting Summary.json
  • src/backend/base/langflow/initial_setup/starter_projects/Memory Chatbot.json
  • src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json
  • src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json
  • src/backend/base/langflow/main.py
  • src/backend/base/langflow/services/database/models/memory_base/model.py
  • src/backend/base/langflow/services/memory_base/document_builders.py
  • src/backend/base/langflow/services/memory_base/ingestion.py
  • src/backend/base/langflow/services/memory_base/kb_path_helpers.py
  • src/backend/base/langflow/services/memory_base/service.py
  • src/backend/base/langflow/services/memory_base/task.py
  • src/backend/tests/unit/base/knowledge_bases/test_connector_endpoints.py
  • src/backend/tests/unit/components/files_and_knowledge/test_knowledge.py
  • src/backend/tests/unit/components/files_and_knowledge/test_memory_retrieval.py
  • src/backend/tests/unit/test_memory_base_task.py
  • src/backend/tests/unit/test_memory_bases.py
  • src/frontend/src/controllers/API/queries/memories/types.ts
  • src/frontend/src/locales/en.json
  • src/frontend/src/modals/createMemoryModal/__tests__/useCreateMemoryModal.test.tsx
  • src/frontend/src/modals/createMemoryModal/index.tsx
  • src/frontend/src/modals/createMemoryModal/useCreateMemoryModal.ts
  • src/lfx/src/lfx/_assets/component_index.json
  • src/lfx/src/lfx/base/knowledge_bases/backends/opensearch.py
  • src/lfx/src/lfx/components/files_and_knowledge/knowledge.py
  • src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py

@@ -8115,7 +8115,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "\"\"\"Unified Knowledge component — ingest into or retrieve from a knowledge base.\n\nThis component merges what used to live in ``ingestion.py`` and\n``retrieval.py`` into a single, mode-driven component. A ``TabInput``\n(\"📥 Ingest\" / \"🔍 Retrieve\") drives which inputs and which output are\nvisible. The merged shape gives users one node per KB instead of two\nparallel ones that always shared the same ``knowledge_base`` picker,\nembedding-model metadata, and backend registry.\n\nBoth legacy classes (``KnowledgeIngestionComponent``,\n``KnowledgeBaseComponent``) remain importable as thin subclasses of\n``KnowledgeComponent`` — see the sibling ``ingestion.py`` / ``retrieval.py``\nmodules — so saved flows continue to load unchanged.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\nimport re\nimport uuid\nfrom dataclasses import asdict, dataclass, field\nfrom datetime import datetime, timezone\nfrom typing import TYPE_CHECKING, Any\n\nimport pandas as pd\nfrom cryptography.fernet import InvalidToken\nfrom langchain_chroma import Chroma\nfrom langflow.services.auth.utils import decrypt_api_key, encrypt_api_key\n\nfrom lfx.base.knowledge_bases.backends import BackendType, BaseVectorStoreBackend, create_backend\nfrom lfx.base.knowledge_bases.ingestion_sources.base import (\n IngestionItemResult,\n IngestionItemStatus,\n IngestionRunStatus,\n IngestionSummary,\n)\nfrom lfx.base.knowledge_bases.ingestion_sources.flow_component import FlowComponentSource\nfrom lfx.base.knowledge_bases.knowledge_base_utils import get_knowledge_bases\nfrom lfx.base.models.unified_models import get_embedding_model_options, get_embeddings\nfrom lfx.base.vectorstores.chroma_security import chroma_langchain_collection_kwargs\nfrom lfx.components.files_and_knowledge._kb_paths import (\n KBKeyDecryptError,\n load_kb_metadata,\n)\nfrom lfx.components.files_and_knowledge._kb_paths import (\n get_knowledge_bases_root_path as _get_knowledge_bases_root_path,\n)\nfrom lfx.components.processing.converter import convert_to_dataframe\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DBProviderInput,\n DropdownInput,\n HandleInput,\n IntInput,\n MessageTextInput,\n ModelInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.schema.table import EditMode\nfrom lfx.services.deps import (\n get_settings_service,\n session_scope,\n)\nfrom lfx.utils.component_utils import set_current_fields, set_field_display\nfrom lfx.utils.validate_cloud import raise_error_if_astra_cloud_disable_component\n\n\ndef _inputs_for_mode(default_mode: str) -> list:\n \"\"\"Return a fresh copy of the canonical inputs list with show flags set for the given mode.\n\n Used by the legacy subclasses so a saved flow keyed on\n ``KnowledgeIngestion`` / ``KnowledgeBase`` lands on a node template\n whose default visibility already matches the pinned mode — no\n \"wait for the user to click the mode tab\" UX gap on load.\n \"\"\"\n always_visible = set(KnowledgeComponent.default_keys) | set(KnowledgeComponent.mode_config[default_mode])\n inputs_copy = []\n for inp in KnowledgeComponent.inputs:\n clone = inp.model_copy(deep=True) if hasattr(inp, \"model_copy\") else inp\n if clone.name == \"mode\":\n clone.value = default_mode\n else:\n clone.show = clone.name in always_visible\n inputs_copy.append(clone)\n return inputs_copy\n\n\nif TYPE_CHECKING:\n from pathlib import Path\n\n# Mode constants. Plain-text labels (no emoji) for consistency with the rest of\n# Langflow's TabInput palette — see ``MemoryComponent`` for the same convention.\nMODE_INGEST = \"Ingest\"\nMODE_RETRIEVE = \"Retrieve\"\n\n\ndef _is_retrieve_mode(value: Any) -> bool:\n \"\"\"Lenient mode check: treats any label containing 'Retrieve' as retrieve mode.\n\n Older saved flows may carry the emoji-prefixed labels (\"📥 Ingest\" /\n \"🔍 Retrieve\") this component used to ship with; substring matching\n keeps those loading without forcing a flow rewrite.\n \"\"\"\n return isinstance(value, str) and \"Retrieve\" in value\n\n\n# Error message used by both the ingest and retrieve paths when the user is\n# running against an Astra cloud environment that disables these flows.\nastra_error_msg = \"Knowledge ingestion and retrieval are not supported in Astra cloud environment.\"\n\n_DEFAULT_OPENSEARCH_CONFIG = {\n \"url_variable\": \"OPENSEARCH_URL\",\n \"username_variable\": \"OPENSEARCH_USERNAME\",\n \"password_variable\": \"OPENSEARCH_PASSWORD\", # pragma: allowlist secret\n \"index_name\": \"\",\n \"vector_field\": \"vector_field\",\n \"text_field\": \"text\",\n}\n\n_DEFAULT_CHROMA_CLOUD_CONFIG = {\n \"mode\": \"cloud\",\n \"tenant_variable\": \"CHROMA_TENANT\",\n \"database_variable\": \"CHROMA_DATABASE\",\n \"api_key_variable\": \"CHROMA_API_KEY\", # pragma: allowlist secret\n}\n\n\nclass KnowledgeComponent(Component):\n \"\"\"One component for both writing into and reading from a Langflow knowledge base.\n\n A ``TabInput`` switches between ingestion and retrieval. The\n ``update_build_config`` / ``update_outputs`` hooks hide the inputs\n and the output that don't apply to the current mode so the canvas\n node stays focused.\n \"\"\"\n\n display_name = \"Knowledge\"\n description = \"Ingest into or retrieve from a Langflow knowledge base.\"\n icon = \"database\"\n name = \"Knowledge\"\n\n # ------ Mode → visible-fields wiring ---------------------------------\n # ``default_keys`` are inputs always visible regardless of mode.\n # ``mode_config`` lists the inputs unique to each mode; everything outside\n # this set is hidden when its mode is not selected.\n default_keys: list[str] = [\"mode\", \"knowledge_base\"]\n mode_config: dict[str, list[str]] = {\n MODE_INGEST: [\n \"input_df\",\n \"column_config\",\n \"chunk_size\",\n \"api_key\",\n \"allow_duplicates\",\n \"metadata_json\",\n ],\n MODE_RETRIEVE: [\n \"search_query\",\n \"top_k\",\n \"include_metadata\",\n \"include_embeddings\",\n \"metadata_filter\",\n ],\n }\n\n def __init__(self, *args, **kwargs) -> None:\n super().__init__(*args, **kwargs)\n self._cached_kb_path: Path | None = None\n\n @dataclass\n class NewKnowledgeBaseInput:\n functionality: str = \"create\"\n fields: dict[str, dict] = field(\n default_factory=lambda: {\n \"data\": {\n \"node\": {\n \"name\": \"create_knowledge_base\",\n \"description\": \"Create new knowledge in Langflow.\",\n \"display_name\": \"Create new Knowledge Base\",\n \"field_order\": [\n \"01_new_kb_name\",\n \"02_embedding_model\",\n \"03_knowledge_backend\",\n ],\n \"template\": {\n \"01_new_kb_name\": StrInput(\n name=\"new_kb_name\",\n display_name=\"Knowledge Name\",\n info=\"Name of the new knowledge to create.\",\n required=True,\n ),\n \"02_embedding_model\": ModelInput(\n name=\"embedding_model\",\n display_name=\"Choose Embedding Model\",\n info=(\n \"Select the embedding model to use for this knowledge base. \"\n \"Langflow uses the configured credentials for that model provider.\"\n ),\n required=True,\n model_type=\"embedding\",\n ),\n \"03_knowledge_backend\": DBProviderInput(\n name=\"knowledge_backend\",\n display_name=\"DB Provider\",\n info=(\n \"Select where this knowledge base stores vectors. \"\n \"OpenSearch uses the global DB Providers settings.\"\n ),\n required=True,\n ),\n },\n },\n }\n }\n )\n\n # ------ Inputs --------------------------------------------------------\n # ``knowledge_base`` is kept at position 0 so legacy tests that check\n # ``component.inputs[0].dialog_inputs`` continue to work.\n inputs = [\n DropdownInput(\n name=\"knowledge_base\",\n display_name=\"Knowledge\",\n info=\"Select the knowledge to load data from.\",\n required=True,\n options=[],\n refresh_button=True,\n real_time_refresh=True,\n dialog_inputs=asdict(NewKnowledgeBaseInput()),\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[MODE_INGEST, MODE_RETRIEVE],\n value=MODE_INGEST,\n info=\"Switch between writing new data into the knowledge base and querying it.\",\n real_time_refresh=True,\n tool_mode=True,\n ),\n # --- Ingest-only inputs (default-shown; hidden when mode == Retrieve) -\n HandleInput(\n name=\"input_df\",\n display_name=\"Input\",\n info=(\n \"Table with all original columns (already chunked / processed). \"\n \"Accepts Message, Data, or DataFrame. If Message or Data is provided, \"\n \"it is converted to a DataFrame automatically.\"\n ),\n input_types=[\"Message\", \"Data\", \"JSON\", \"DataFrame\", \"Table\"],\n required=True,\n dynamic=True,\n show=True,\n ),\n TableInput(\n name=\"column_config\",\n display_name=\"Column Configuration\",\n info=\"Configure column behavior for the knowledge base.\",\n required=True,\n table_schema=[\n {\n \"name\": \"column_name\",\n \"display_name\": \"Column Name\",\n \"type\": \"str\",\n \"description\": \"Name of the column in the source DataFrame\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"vectorize\",\n \"display_name\": \"Vectorize\",\n \"type\": \"boolean\",\n \"description\": \"Create embeddings for this column\",\n \"default\": False,\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"identifier\",\n \"display_name\": \"Identifier\",\n \"type\": \"boolean\",\n \"description\": \"Use this column as unique identifier\",\n \"default\": False,\n \"edit_mode\": EditMode.INLINE,\n },\n ],\n value=[\n {\n \"column_name\": \"text\",\n \"vectorize\": True,\n \"identifier\": True,\n },\n ],\n dynamic=True,\n show=True,\n ),\n IntInput(\n name=\"chunk_size\",\n display_name=\"Chunk Size\",\n info=\"Batch size for processing embeddings\",\n advanced=True,\n value=1000,\n dynamic=True,\n show=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Embedding Provider API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n advanced=True,\n required=False,\n dynamic=True,\n show=True,\n ),\n BoolInput(\n name=\"allow_duplicates\",\n display_name=\"Allow Duplicates\",\n info=\"Allow duplicate rows in the knowledge base\",\n advanced=True,\n value=False,\n dynamic=True,\n show=True,\n ),\n StrInput(\n name=\"metadata_json\",\n display_name=\"Metadata\",\n info=(\n \"Optional JSON object of user metadata applied to every chunk produced by this \"\n 'run (e.g. {\"tag\": \"invoice\", \"year\": \"2026\"}). Same shape as the upload modal '\n \"Metadata section so chunks browser filters + Knowledge retrieval metadata_filter \"\n \"work uniformly across upload, folder, and flow-driven ingestion. Malformed JSON is \"\n \"ignored with a warning rather than failing the run.\"\n ),\n advanced=True,\n required=False,\n dynamic=True,\n show=True,\n ),\n # --- Retrieve-only inputs (default-hidden; shown when mode == Retrieve) -\n MessageTextInput(\n name=\"search_query\",\n display_name=\"Search Query\",\n info=\"Optional search query to filter knowledge base data.\",\n tool_mode=True,\n dynamic=True,\n show=False,\n ),\n IntInput(\n name=\"top_k\",\n display_name=\"Top K Results\",\n info=\"Number of top results to return from the knowledge base.\",\n value=5,\n advanced=True,\n required=False,\n dynamic=True,\n show=False,\n ),\n BoolInput(\n name=\"include_metadata\",\n display_name=\"Include Metadata\",\n info=\"Whether to include all metadata in the output. If false, only content is returned.\",\n value=True,\n advanced=False,\n dynamic=True,\n show=False,\n ),\n BoolInput(\n name=\"include_embeddings\",\n display_name=\"Include Embeddings\",\n info=\"Whether to include embeddings in the output. Only applicable if 'Include Metadata' is enabled.\",\n value=False,\n advanced=True,\n dynamic=True,\n show=False,\n ),\n MessageTextInput(\n name=\"metadata_filter\",\n display_name=\"Metadata Filter\",\n info=(\n \"Optional JSON object of user-metadata key/value pairs. Only chunks \"\n 'whose source_metadata matches every key are returned (e.g. {\"tag\": \"invoice\"} '\n 'or {\"tag\": [\"invoice\", \"audit\"]} for OR-of-values). Backends without '\n \"native filtering apply the match client-side after retrieval.\"\n ),\n advanced=True,\n dynamic=True,\n show=False,\n ),\n ]\n\n # ------ Outputs -------------------------------------------------------\n # Both outputs are declared at the class level so the runtime can\n # dispatch to either method depending on which one the saved flow has\n # wired up. ``update_outputs`` filters the canvas-visible output per\n # selected mode; see ``TypeConverterComponent`` and ``MemoryComponent``\n # for the same pattern.\n #\n # Output names match the legacy ``KnowledgeIngestionComponent``\n # (``dataframe_output``) and ``KnowledgeBaseComponent``\n # (``retrieve_data``) so saved flow edges keyed on those names resolve\n # cleanly against the merged component.\n outputs = [\n Output(\n display_name=\"Results\",\n name=\"dataframe_output\",\n method=\"build_kb_info\",\n types=[\"JSON\"],\n selected=\"JSON\",\n ),\n Output(\n display_name=\"Results\",\n name=\"retrieve_data\",\n method=\"retrieve_data\",\n info=\"Returns the data from the selected knowledge base.\",\n types=[\"Table\"],\n selected=\"Table\",\n ),\n ]\n\n # ------ Mode-driven UI updates ---------------------------------------\n async def update_frontend_node(self, new_frontend_node: dict, current_frontend_node: dict):\n \"\"\"Sync the visible output with the current ``mode`` value on canvas load.\n\n Saved flows hit this path: ``update_outputs`` is normally only triggered\n by ``real_time_refresh`` field edits, so without this re-sync the\n canvas could land with both outputs visible.\n \"\"\"\n await super().update_frontend_node(new_frontend_node, current_frontend_node)\n mode_value = new_frontend_node.get(\"template\", {}).get(\"mode\", {}).get(\"value\", MODE_INGEST)\n self.update_outputs(new_frontend_node, \"mode\", mode_value)\n return new_frontend_node\n\n def update_outputs(self, frontend_node: dict, field_name: str, field_value: Any) -> dict:\n \"\"\"Filter visible outputs to match the selected mode.\n\n Triggered by the ``mode`` ``TabInput`` ``real_time_refresh`` flag.\n The class-level ``outputs`` list always carries both entries so the\n runtime can resolve either ``build_kb_info`` or ``retrieve_data``\n regardless of canvas visibility — we just hide the unused one here.\n \"\"\"\n if field_name != \"mode\":\n return frontend_node\n if _is_retrieve_mode(field_value):\n frontend_node[\"outputs\"] = [\n Output(\n display_name=\"Results\",\n name=\"retrieve_data\",\n method=\"retrieve_data\",\n info=\"Returns the data from the selected knowledge base.\",\n types=[\"Table\"],\n selected=\"Table\",\n )\n ]\n else:\n frontend_node[\"outputs\"] = [\n Output(\n display_name=\"Results\",\n name=\"dataframe_output\",\n method=\"build_kb_info\",\n types=[\"JSON\"],\n selected=\"JSON\",\n )\n ]\n return frontend_node\n\n async def update_build_config(\n self,\n build_config,\n field_value: Any,\n field_name: str | None = None,\n ):\n \"\"\"Refresh KB options, drive the create-KB dialog, and hide off-mode fields.\"\"\"\n # Astra-cloud gate covers both ingest and retrieve paths.\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n # Always populate the create-KB dialog's embedding-model options so the\n # ModelInput renders correctly regardless of which input triggered the\n # refresh.\n try:\n dialog_template = (\n build_config[\"knowledge_base\"]\n .get(\"dialog_inputs\", {})\n .get(\"fields\", {})\n .get(\"data\", {})\n .get(\"node\", {})\n .get(\"template\", {})\n )\n if \"02_embedding_model\" in dialog_template:\n embedding_options = get_embedding_model_options(user_id=self.user_id)\n dialog_template[\"02_embedding_model\"][\"options\"] = embedding_options\n except Exception: # noqa: BLE001\n self.log(\"Failed to populate embedding model options in dialog\")\n\n # KB-picker refresh + create-new-KB flow (lifted from the legacy ingestion\n # component verbatim; relied on by both the canvas refresh button and the\n # dialog-submit path).\n if field_name == \"knowledge_base\":\n # Lazy import keeps lfx importable without langflow installed.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching knowledge base list.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n if isinstance(field_value, dict) and \"01_new_kb_name\" in field_value:\n if not self.is_valid_collection_name(field_value[\"01_new_kb_name\"]):\n msg = f\"Invalid knowledge base name: {field_value['01_new_kb_name']}\"\n raise ValueError(msg)\n\n model_selection = field_value[\"02_embedding_model\"]\n if isinstance(model_selection, dict):\n model_selection = [model_selection]\n\n backend_type, backend_config = self._normalize_backend_selection(\n field_value.get(\"03_knowledge_backend\")\n )\n\n embed_model = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n )\n\n try:\n await asyncio.wait_for(\n asyncio.to_thread(embed_model.embed_query, \"test\"),\n timeout=10,\n )\n except TimeoutError as e:\n msg = \"Embedding validation timed out. Please verify network connectivity and key.\"\n raise ValueError(msg) from e\n except Exception as e:\n msg = f\"Embedding validation failed: {e!s}\"\n raise ValueError(msg) from e\n\n kb_path = self._resolve_kb_path(\n _get_knowledge_bases_root_path(), kb_user, field_value[\"01_new_kb_name\"]\n )\n kb_path.mkdir(parents=True, exist_ok=True)\n\n build_config[\"knowledge_base\"][\"value\"] = field_value[\"01_new_kb_name\"]\n self._save_embedding_metadata(\n kb_path=kb_path,\n model_selection=model_selection,\n backend_type=backend_type,\n backend_config=backend_config,\n )\n await self._create_knowledge_base_record(\n user_id=self.user_id,\n name=field_value[\"01_new_kb_name\"],\n model_selection=model_selection,\n backend_type=backend_type,\n backend_config=backend_config,\n )\n\n build_config[\"knowledge_base\"][\"options\"] = await get_knowledge_bases(\n _get_knowledge_bases_root_path(),\n user_id=self.user_id,\n )\n if build_config[\"knowledge_base\"][\"value\"] not in build_config[\"knowledge_base\"][\"options\"]:\n build_config[\"knowledge_base\"][\"value\"] = None\n\n # Honor the current mode regardless of which field triggered the refresh.\n # Falls back to MODE_INGEST when ``mode`` is missing (legacy nodes).\n current_mode = build_config.get(\"mode\", {}).get(\"value\") if isinstance(build_config, dict) else None\n if field_name == \"mode\":\n current_mode = field_value\n # Map legacy/emoji-prefixed labels onto the current canonical values so\n # flows saved before the label change still toggle visibility correctly.\n if _is_retrieve_mode(current_mode):\n current_mode = MODE_RETRIEVE\n elif current_mode not in self.mode_config:\n current_mode = MODE_INGEST\n return set_current_fields(\n build_config=build_config if isinstance(build_config, dotdict) else dotdict(build_config),\n action_fields=self.mode_config,\n selected_action=current_mode,\n default_fields=self.default_keys,\n func=set_field_display,\n )\n\n # =====================================================================\n # INGESTION CODE PATH\n # =====================================================================\n def _get_kb_root(self) -> Path:\n \"\"\"Return the root directory for knowledge bases.\"\"\"\n return _get_knowledge_bases_root_path()\n\n @staticmethod\n def _resolve_kb_path(kb_root: Path, kb_user: str, kb_name: str) -> Path:\n \"\"\"Resolve the selected KB inside the authenticated user's directory.\"\"\"\n # Lazy import keeps lfx importable without langflow's DB services.\n from langflow.services.memory_base.kb_path_helpers import validate_kb_path\n\n user_root = kb_root / kb_user\n validate_kb_path(kb_root, user_root)\n\n kb_path = user_root / kb_name\n validate_kb_path(user_root, kb_path)\n return kb_path\n\n @staticmethod\n def _scalar_notna(value) -> bool:\n \"\"\"Check if a value is not NA, safely handling arrays and sequences.\n\n ``pd.notna`` returns an array when given an array-like input, which\n cannot be used directly in a boolean context. This helper collapses\n the result to a single scalar ``bool``.\n \"\"\"\n result = pd.notna(value)\n if hasattr(result, \"__iter__\") and not isinstance(result, str):\n import numpy as np\n\n arr = np.asarray(result)\n return arr.size > 0 and arr.all()\n return bool(result)\n\n def _validate_column_config(self, df_source: pd.DataFrame) -> list[dict[str, Any]]:\n \"\"\"Validate column configuration using Structured Output patterns.\"\"\"\n if not self.column_config:\n msg = \"Column configuration cannot be empty\"\n raise ValueError(msg)\n\n config_list = self.column_config if isinstance(self.column_config, list) else []\n\n df_columns = set(df_source.columns)\n for config in config_list:\n col_name = config.get(\"column_name\")\n if col_name not in df_columns:\n msg = f\"Column '{col_name}' not found in DataFrame. Available columns: {sorted(df_columns)}\"\n raise ValueError(msg)\n\n return config_list\n\n def _build_embedding_metadata(\n self,\n model_selection: list[dict[str, Any]],\n api_key: str | None = None,\n backend_type: str = BackendType.CHROMA.value,\n backend_config: dict[str, Any] | None = None,\n ) -> dict[str, Any]:\n \"\"\"Build embedding model metadata from a model selection dict.\"\"\"\n model_dict = model_selection[0] if isinstance(model_selection, list) else model_selection\n embedding_model = model_dict.get(\"name\", \"\")\n embedding_provider = model_dict.get(\"provider\", \"Unknown\")\n\n api_key_to_save = None\n if api_key and hasattr(api_key, \"get_secret_value\"):\n api_key_to_save = api_key.get_secret_value()\n elif isinstance(api_key, str):\n api_key_to_save = api_key\n\n encrypted_api_key = None\n if api_key_to_save:\n settings_service = get_settings_service()\n try:\n encrypted_api_key = encrypt_api_key(api_key_to_save, settings_service=settings_service)\n except (TypeError, ValueError) as e:\n self.log(f\"Could not encrypt API key: {e}\")\n\n return {\n \"embedding_provider\": embedding_provider,\n \"embedding_model\": embedding_model,\n \"model_selection\": model_dict,\n \"api_key\": encrypted_api_key,\n \"api_key_used\": bool(api_key),\n \"chunk_size\": self.chunk_size,\n \"backend_type\": backend_type,\n \"backend_config\": backend_config or {},\n \"created_at\": datetime.now(timezone.utc).isoformat(),\n }\n\n def _save_embedding_metadata(\n self,\n kb_path: Path,\n model_selection: list[dict[str, Any]],\n api_key: str | None = None,\n backend_type: str | None = None,\n backend_config: dict[str, Any] | None = None,\n ) -> None:\n \"\"\"Save embedding model metadata.\"\"\"\n metadata_path = kb_path / \"embedding_metadata.json\"\n existing_metadata: dict[str, Any] = {}\n if metadata_path.exists():\n try:\n existing_metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError):\n existing_metadata = {}\n\n embedding_metadata = self._build_embedding_metadata(\n model_selection,\n api_key,\n backend_type=backend_type or existing_metadata.get(\"backend_type\") or BackendType.CHROMA.value,\n backend_config=backend_config\n if backend_config is not None\n else existing_metadata.get(\"backend_config\") or {},\n )\n metadata_path.write_text(json.dumps(embedding_metadata, indent=2))\n\n def _update_metadata_metrics(self, kb_path: Path, chroma: Chroma) -> None:\n \"\"\"Update embedding_metadata.json with accurate chunk/word/character counts.\"\"\"\n import chromadb.errors\n from langflow.api.utils.kb_helpers import KBAnalysisHelper, KBStorageHelper\n\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n\n try:\n metadata = json.loads(metadata_path.read_text())\n KBAnalysisHelper.update_text_metrics(kb_path, metadata, chroma)\n metadata[\"size\"] = KBStorageHelper.get_directory_size(kb_path)\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError, chromadb.errors.ChromaError) as e:\n self.log(f\"Warning: Could not update metadata metrics: {e}\")\n\n @staticmethod\n def _extract_source_types_from_df(df_source: pd.DataFrame) -> set[str]:\n \"\"\"Pull file extensions out of common path/name columns on the source DataFrame.\n\n The direct-upload ingestion path stores extensions in\n ``embedding_metadata.json[source_types]`` so the KB list can render the\n correct file-type icon. When ingestion happens via a connected\n ``input_df`` (e.g. File → Knowledge) the same field stayed empty and\n the icon defaulted to a blank tile. We look at the well-known columns\n the File / S3 / cloud-storage components produce and collect any\n plausible extension so the icon is consistent across both flows.\n \"\"\"\n candidate_columns = (\"file_path\", \"file_name\", \"filename\", \"source\", \"path\", \"mimetype\")\n extensions: set[str] = set()\n for col in candidate_columns:\n if col not in df_source.columns:\n continue\n for value in df_source[col].dropna():\n ext = KnowledgeComponent._extension_from_value(value)\n # Drop anything that doesn't look like an extension (URL\n # query strings, version segments, etc.) — the icon palette\n # keys off short alphanumeric tokens like \"pdf\"/\"docx\".\n if ext:\n extensions.add(ext)\n return extensions\n\n @staticmethod\n def _extension_from_value(value: Any) -> str | None:\n \"\"\"Return a normalized file-extension token from a path / filename / MIME string.\n\n Accepts values like ``\"report.PDF\"``, ``\"/docs/notes.txt\"``, or\n ``\"application/pdf\"`` and returns ``\"pdf\"`` / ``\"txt\"``. Returns\n ``None`` if no plausible extension can be derived.\n \"\"\"\n extension_length_limit = 10\n if value is None:\n return None\n text = str(value).strip()\n if not text:\n return None\n # MIME types like ``application/pdf`` carry the canonical extension\n # in the subtype slot — preserve them so File / S3 messages keyed\n # only on ``mimetype`` still resolve to an icon.\n if \"/\" in text and \".\" not in text.rsplit(\"/\", 1)[-1]:\n subtype = text.rsplit(\"/\", 1)[-1].strip().lower()\n return subtype if subtype and len(subtype) <= extension_length_limit and subtype.isalnum() else None\n if \".\" not in text:\n return None\n ext = text.rsplit(\".\", 1)[-1].strip().lower()\n if ext and len(ext) <= extension_length_limit and ext.isalnum():\n return ext\n return None\n\n @classmethod\n def _extract_source_types_from_mapping(cls, mapping: Any) -> set[str]:\n \"\"\"Pull extensions from a Message/Data-style ``data`` dict or a plain dict.\"\"\"\n if not isinstance(mapping, dict):\n return set()\n candidate_keys = (\"file_path\", \"file_name\", \"filename\", \"source\", \"path\", \"mimetype\")\n extensions: set[str] = set()\n for key in candidate_keys:\n ext = cls._extension_from_value(mapping.get(key))\n if ext:\n extensions.add(ext)\n return extensions\n\n @classmethod\n def _extract_source_types_from_input(cls, input_value: Any) -> set[str]:\n \"\"\"Pull file extensions out of the raw component input.\n\n ``convert_to_dataframe`` strips Message/Data fields down to ``text``\n when projecting onto a DataFrame, so file metadata attached to a\n File-component \"Raw Content\" output never reaches\n ``_extract_source_types_from_df``. Looking at the raw input first\n keeps the KB icon consistent with direct upload.\n \"\"\"\n if input_value is None:\n return set()\n if isinstance(input_value, list):\n extensions: set[str] = set()\n for item in input_value:\n extensions |= cls._extract_source_types_from_input(item)\n return extensions\n if isinstance(input_value, pd.DataFrame):\n return cls._extract_source_types_from_df(input_value)\n # Message / Data / JSON all expose a ``data`` dict via the lfx schema.\n mapping = getattr(input_value, \"data\", None)\n if mapping is None and isinstance(input_value, dict):\n mapping = input_value\n return cls._extract_source_types_from_mapping(mapping)\n\n def _merge_source_types(self, kb_path: Path, extensions: set[str]) -> None:\n \"\"\"Merge newly observed extensions into the KB's ``source_types`` metadata.\n\n Mirrors the direct-upload path in ``KBIngestionHelper`` so the icon\n rendering on the Knowledge Bases list works regardless of which\n ingestion route was used.\n \"\"\"\n if not extensions:\n return\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n try:\n metadata = json.loads(metadata_path.read_text())\n existing = set(metadata.get(\"source_types\") or [])\n metadata[\"source_types\"] = sorted(existing | extensions)\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError) as e:\n self.log(f\"Warning: Could not update source_types metadata: {e}\")\n\n async def _update_backend_metadata_metrics(self, kb_path: Path, backend: BaseVectorStoreBackend) -> None:\n \"\"\"Update metadata metrics for non-Chroma backends.\"\"\"\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n\n try:\n metadata = json.loads(metadata_path.read_text())\n chunks = await backend.count()\n characters = 0\n words = 0\n async for batch in backend.iter_documents():\n for document in batch:\n characters += len(document.content)\n words += len(document.content.split())\n\n metadata[\"chunks\"] = chunks\n metadata[\"characters\"] = characters\n metadata[\"words\"] = words\n metadata[\"avg_chunk_size\"] = characters / chunks if chunks else 0.0\n metadata[\"size\"] = await backend.storage_size_bytes()\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError) as e:\n self.log(f\"Warning: Could not update backend metadata metrics: {e}\")\n\n @staticmethod\n def _normalize_backend_selection(value: Any) -> tuple[str, dict[str, Any]]:\n \"\"\"Normalize a DBProviderInput value into backend type/config.\"\"\"\n if not value:\n return BackendType.CHROMA.value, {}\n\n if isinstance(value, str):\n backend_type = value if value == BackendType.OPENSEARCH.value else BackendType.CHROMA.value\n return (\n backend_type,\n _DEFAULT_OPENSEARCH_CONFIG.copy() if backend_type == BackendType.OPENSEARCH.value else {},\n )\n\n if not isinstance(value, dict):\n return BackendType.CHROMA.value, {}\n\n backend_type = str(value.get(\"backend_type\") or value.get(\"id\") or BackendType.CHROMA.value)\n\n if backend_type == BackendType.OPENSEARCH.value:\n backend_config = value.get(\"backend_config\") or value.get(\"config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return BackendType.OPENSEARCH.value, {**_DEFAULT_OPENSEARCH_CONFIG, **backend_config}\n\n if backend_type == \"chroma_cloud\":\n backend_config = value.get(\"backend_config\") or value.get(\"config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return BackendType.CHROMA.value, {**_DEFAULT_CHROMA_CLOUD_CONFIG, **backend_config}\n\n return BackendType.CHROMA.value, {}\n\n @staticmethod\n def _get_backend_from_metadata(kb_path: Path) -> tuple[str, dict[str, Any]]:\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return BackendType.CHROMA.value, {}\n try:\n metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError):\n return BackendType.CHROMA.value, {}\n\n backend_type = str(metadata.get(\"backend_type\") or BackendType.CHROMA.value)\n backend_config = metadata.get(\"backend_config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return backend_type, backend_config\n\n async def _create_knowledge_base_record(\n self,\n *,\n user_id: Any,\n name: str,\n model_selection: list[dict[str, Any]],\n backend_type: str,\n backend_config: dict[str, Any],\n ) -> None:\n \"\"\"Persist the component-created KB in the DB when Langflow is available.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n except ImportError:\n return\n\n try:\n await knowledge_base_service.create_record(\n user_id=user_id,\n name=name,\n model_selection=model_selection,\n column_config=self.column_config if isinstance(self.column_config, list) else [],\n backend_type=backend_type,\n backend_config=backend_config,\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Warning: could not persist knowledge base record: {exc}\")\n\n def _save_kb_files(\n self,\n kb_path: Path,\n config_list: list[dict[str, Any]],\n ) -> None:\n \"\"\"Save KB files using File Component storage patterns.\"\"\"\n try:\n kb_path.mkdir(parents=True, exist_ok=True)\n\n cfg_path = kb_path / \"schema.json\"\n if not cfg_path.exists():\n cfg_path.write_text(json.dumps(config_list, indent=2))\n\n except (OSError, TypeError, ValueError) as e:\n self.log(f\"Error saving KB files: {e}\")\n\n def _build_column_metadata(self, config_list: list[dict[str, Any]], df_source: pd.DataFrame) -> dict[str, Any]:\n \"\"\"Build detailed column metadata.\"\"\"\n metadata: dict[str, Any] = {\n \"total_columns\": len(df_source.columns),\n \"mapped_columns\": len(config_list),\n \"unmapped_columns\": len(df_source.columns) - len(config_list),\n \"columns\": [],\n \"summary\": {\"vectorized_columns\": [], \"identifier_columns\": []},\n }\n\n for config in config_list:\n col_name = config.get(\"column_name\")\n vectorize = config.get(\"vectorize\") == \"True\" or config.get(\"vectorize\") is True\n identifier = config.get(\"identifier\") == \"True\" or config.get(\"identifier\") is True\n\n metadata[\"columns\"].append(\n {\n \"name\": col_name,\n \"vectorize\": vectorize,\n \"identifier\": identifier,\n }\n )\n\n if vectorize:\n metadata[\"summary\"][\"vectorized_columns\"].append(col_name)\n if identifier:\n metadata[\"summary\"][\"identifier_columns\"].append(col_name)\n\n return metadata\n\n async def _create_vector_store(\n self,\n df_source: pd.DataFrame,\n config_list: list[dict[str, Any]],\n embedding_function,\n ) -> BaseVectorStoreBackend:\n \"\"\"Create vector store using the configured DB provider.\"\"\"\n vector_store_dir = await self._kb_path()\n if not vector_store_dir:\n msg = \"Knowledge base path is not set. Please create a new knowledge base first.\"\n raise ValueError(msg)\n vector_store_dir.mkdir(parents=True, exist_ok=True)\n\n backend_type, backend_config = self._get_backend_from_metadata(vector_store_dir)\n backend = create_backend(\n backend_type,\n kb_name=self.knowledge_base,\n kb_path=vector_store_dir,\n backend_config=backend_config,\n embedding_function=embedding_function,\n user_id=self.user_id,\n )\n await backend.ensure_ready()\n\n existing_ids = None\n if backend_type != BackendType.CHROMA.value and not self.allow_duplicates:\n existing_ids = set()\n async for batch in backend.iter_documents():\n for document in batch:\n doc_id = document.metadata.get(\"_id\")\n if doc_id:\n existing_ids.add(doc_id)\n\n data_objects = await self._convert_df_to_data_objects(df_source, config_list, existing_ids=existing_ids)\n\n user_metadata_tag = self._resolve_user_metadata_tag()\n\n documents = []\n for data_obj in data_objects:\n doc = data_obj.to_lc_document()\n if user_metadata_tag:\n doc.metadata[\"source_metadata\"] = user_metadata_tag\n documents.append(doc)\n\n if documents:\n await backend.add_documents(documents)\n self.log(f\"Added {len(documents)} documents to vector store '{self.knowledge_base}'\")\n\n return backend\n\n async def _convert_df_to_data_objects(\n self,\n df_source: pd.DataFrame,\n config_list: list[dict[str, Any]],\n existing_ids: set[str] | None = None,\n ) -> list[Data]:\n \"\"\"Convert DataFrame to Data objects for vector store.\"\"\"\n data_objects: list[Data] = []\n\n if existing_ids is None:\n kb_path = await self._kb_path()\n\n chroma = Chroma(\n persist_directory=str(kb_path),\n collection_name=self.knowledge_base,\n **chroma_langchain_collection_kwargs(),\n )\n\n all_docs = chroma.get()\n\n existing_ids = {metadata.get(\"_id\") for metadata in all_docs[\"metadatas\"] if metadata.get(\"_id\")}\n\n content_cols = []\n identifier_cols = []\n\n for config in config_list:\n col_name = config.get(\"column_name\")\n vectorize = config.get(\"vectorize\") == \"True\" or config.get(\"vectorize\") is True\n identifier = config.get(\"identifier\") == \"True\" or config.get(\"identifier\") is True\n\n if vectorize:\n content_cols.append(col_name)\n if identifier:\n identifier_cols.append(col_name)\n\n for _, row in df_source.iterrows():\n identifier_parts = [str(row[col]) for col in content_cols if col in row and self._scalar_notna(row[col])]\n\n page_content = \" \".join(identifier_parts)\n\n data_dict = {\n \"text\": page_content,\n }\n\n if identifier_cols:\n identifier_parts = [\n str(row[col]) for col in identifier_cols if col in row and self._scalar_notna(row[col])\n ]\n page_content = \" \".join(identifier_parts)\n\n for col in df_source.columns:\n if col not in content_cols and col in row and self._scalar_notna(row[col]):\n value = row[col]\n data_dict[col] = str(value)\n\n page_content_hash = hashlib.sha256(page_content.encode()).hexdigest()\n data_dict[\"_id\"] = page_content_hash\n\n if not self.allow_duplicates and page_content_hash in existing_ids:\n self.log(f\"Skipping duplicate row with hash {page_content_hash}\")\n continue\n\n data_obj = Data(data=data_dict)\n data_objects.append(data_obj)\n\n return data_objects\n\n def is_valid_collection_name(self, name, min_length: int = 3, max_length: int = 63) -> bool:\n \"\"\"Validate collection name.\n\n 1. Contains 3-63 characters\n 2. Starts and ends with alphanumeric character\n 3. Contains only alphanumeric characters, underscores, or hyphens.\n \"\"\"\n if not (min_length <= len(name) <= max_length):\n return False\n\n if not (name[0].isalnum() and name[-1].isalnum()):\n return False\n\n return re.match(r\"^[a-zA-Z0-9_-]+$\", name) is not None\n\n async def _kb_path(self) -> Path | None:\n cached_path = getattr(self, \"_cached_kb_path\", None)\n if cached_path is not None:\n return cached_path\n\n # Lazy import to keep ``lfx`` importable standalone — langflow's\n # user/DB models are not always available at module load time.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching knowledge base path.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n\n kb_root = self._get_kb_root()\n\n self._cached_kb_path = self._resolve_kb_path(kb_root, kb_user, self.knowledge_base)\n\n return self._cached_kb_path\n\n def _resolve_user_metadata_tag(self) -> str:\n \"\"\"Return the JSON-encoded user metadata tag for chunk writes.\"\"\"\n raw = getattr(self, \"metadata_json\", None)\n if not raw:\n return \"\"\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return \"\"\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError) as exc:\n self.log(f\"KnowledgeComponent: metadata_json is not valid JSON ({exc}); skipping metadata stamp.\")\n return \"\"\n if not isinstance(decoded, dict):\n self.log(\"KnowledgeComponent: metadata_json must decode to a JSON object; skipping metadata stamp.\")\n return \"\"\n return json.dumps(decoded, sort_keys=True)\n\n async def build_kb_info(self) -> Data:\n \"\"\"Main ingestion routine → returns a dict with KB metadata.\n\n The annotation is intentionally narrowed to ``Data`` even though the\n cross-mode fallback below may return a ``DataFrame`` from\n ``retrieve_data``. The frontend builds React-Flow handle IDs from\n this output's type list; widening it to ``Data | DataFrame`` makes\n the API advertise ``[\"JSON\", \"Table\"]`` for the ingest output and\n breaks every saved-edge sourceHandle that was generated against\n a single-type handle (BUG-02). Python doesn't enforce return\n annotations at runtime, so the rare fallback path keeps working.\n \"\"\"\n if _is_retrieve_mode(getattr(self, \"mode\", MODE_INGEST)):\n return await self.retrieve_data()\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n run_id: uuid.UUID | None = None\n run_job_id: uuid.UUID | None = None\n run_summary: IngestionSummary | None = None\n run_status: IngestionRunStatus = IngestionRunStatus.SUCCEEDED\n run_error: str | None = None\n kb_record_id: uuid.UUID | None = None\n try:\n input_value = self.input_df[0] if isinstance(self.input_df, list) else self.input_df\n df_source: DataFrame = convert_to_dataframe(input_value, auto_parse=False)\n\n config_list = self._validate_column_config(df_source)\n column_metadata = self._build_column_metadata(config_list, df_source)\n\n kb_path = await self._kb_path()\n if not kb_path:\n msg = \"Knowledge base path is not set. Please create a new knowledge base first.\"\n raise ValueError(msg)\n metadata_path = kb_path / \"embedding_metadata.json\"\n api_key = None\n model_selection = None\n\n if metadata_path.exists():\n settings_service = get_settings_service()\n stored_metadata = json.loads(metadata_path.read_text())\n\n model_selection = stored_metadata.get(\"model_selection\")\n if model_selection:\n model_selection = [model_selection] if isinstance(model_selection, dict) else model_selection\n else:\n embedding_model_name = stored_metadata.get(\"embedding_model\")\n embedding_provider = stored_metadata.get(\"embedding_provider\", \"Unknown\")\n if embedding_model_name:\n try:\n all_options = get_embedding_model_options(user_id=self.user_id)\n match = next(\n (o for o in all_options if o.get(\"name\") == embedding_model_name),\n None,\n )\n if match:\n model_selection = [match]\n else:\n self.log(\n f\"Embedding model '{embedding_model_name}' (provider: {embedding_provider}) \"\n \"from stored metadata is no longer available in the model registry. \"\n \"Please re-create this knowledge base with a supported embedding model.\"\n )\n msg = (\n f\"Embedding model '{embedding_model_name}' is no longer recognized. \"\n \"The knowledge base was created with an older format and the model \"\n \"is not available in the current registry. \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg)\n except ValueError:\n raise\n except Exception: # noqa: BLE001\n self.log(\n f\"Failed to look up embedding model '{embedding_model_name}' in registry. \"\n \"Please re-create this knowledge base with a supported embedding model.\"\n )\n msg = (\n f\"Could not look up embedding model '{embedding_model_name}' \"\n f\"(provider: {embedding_provider}). \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg) # noqa: B904\n\n encrypted_key = stored_metadata.get(\"api_key\")\n if encrypted_key:\n try:\n api_key = decrypt_api_key(encrypted_key, settings_service)\n except (InvalidToken, TypeError, ValueError) as e:\n if not self.api_key:\n log_label = f\"knowledge base '{self.knowledge_base}'\"\n msg = (\n f\"Cannot decrypt the stored embedding API key for {log_label}. \"\n \"This usually means the server's SECRET_KEY changed after the \"\n \"key was saved. To recover, supply the embedding provider API \"\n \"key on the component's 'Embedding Provider API Key' input and \"\n \"re-run ingestion — the key will be re-encrypted with the \"\n \"current SECRET_KEY.\"\n )\n raise KBKeyDecryptError(msg) from e\n logger.warning(\"Stored API key undecryptable; using component-supplied key. Error: %s\", e)\n\n if self.api_key:\n api_key = self.api_key\n if model_selection:\n self._save_embedding_metadata(\n kb_path=kb_path,\n model_selection=model_selection,\n api_key=api_key,\n )\n\n if not model_selection:\n msg = \"No embedding model configuration found. Please create the knowledge base first.\"\n raise ValueError(msg)\n\n embedding_function = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n api_key=api_key,\n chunk_size=self.chunk_size,\n )\n\n run_id, run_job_id, run_summary, kb_record_id = await self._begin_ingestion_run(kb_path)\n if kb_record_id is not None:\n await self._record_kb_status(kb_record_id, \"ingesting\")\n\n backend = await self._create_vector_store(df_source, config_list, embedding_function=embedding_function)\n\n self._save_kb_files(kb_path, config_list)\n\n try:\n if not isinstance(backend, BaseVectorStoreBackend):\n pass\n elif backend.backend_type == BackendType.CHROMA and hasattr(backend, \"raw_langchain_store\"):\n self._update_metadata_metrics(kb_path, backend.raw_langchain_store())\n else:\n await self._update_backend_metadata_metrics(kb_path, backend)\n # Stamp the KB with the file extensions we just ingested so\n # the Knowledge Bases list renders the correct icon for\n # flow-driven ingestion (input_df), matching direct upload.\n # We look at the raw input first because ``convert_to_dataframe``\n # drops Message/Data metadata fields (file_path, mimetype, …)\n # when projecting onto the DataFrame.\n source_types = self._extract_source_types_from_input(input_value)\n source_types |= self._extract_source_types_from_df(df_source)\n self._merge_source_types(kb_path, source_types)\n finally:\n if isinstance(backend, BaseVectorStoreBackend):\n await backend.teardown()\n\n meta: dict[str, Any] = {\n \"kb_id\": str(uuid.uuid4()),\n \"kb_name\": self.knowledge_base,\n \"rows\": len(df_source),\n \"column_metadata\": column_metadata,\n \"path\": str(kb_path),\n \"config_columns\": len(config_list),\n \"timestamp\": datetime.now(tz=timezone.utc).isoformat(),\n }\n\n if run_summary is not None:\n run_summary.record_item(\n IngestionItemResult(\n item_id=self.knowledge_base,\n display_name=f\"{self.knowledge_base} ({len(df_source)} rows)\",\n status=IngestionItemStatus.SUCCEEDED,\n chunks_created=len(df_source),\n ),\n )\n\n if kb_record_id is not None:\n await self._record_kb_stats(kb_record_id, kb_path)\n await self._record_kb_status(kb_record_id, \"ready\")\n\n self.status = f\"✅ KB **{self.knowledge_base}** saved · {len(df_source)} chunks.\"\n\n return Data(data=meta)\n\n except (OSError, ValueError, RuntimeError, KeyError) as e:\n run_status = IngestionRunStatus.FAILED\n run_error = str(e) or e.__class__.__name__\n msg = f\"Error during KB ingestion: {e}\"\n raise RuntimeError(msg) from e\n except Exception as e:\n run_status = IngestionRunStatus.FAILED\n run_error = str(e) or e.__class__.__name__\n raise\n finally:\n if run_id is not None and run_summary is not None:\n await self._finalize_ingestion_run(\n run_id=run_id,\n job_id=run_job_id,\n summary=run_summary,\n status=run_status,\n error_message=run_error,\n )\n if kb_record_id is not None and run_status is IngestionRunStatus.FAILED:\n await self._record_kb_status(kb_record_id, \"failed\", failure_reason=run_error)\n\n async def _begin_ingestion_run(\n self,\n kb_path: Path,\n ) -> tuple[uuid.UUID | None, uuid.UUID | None, IngestionSummary | None, uuid.UUID | None]:\n \"\"\"Create a parent ``Job`` and seed an ingestion-run row.\"\"\"\n if not self.user_id:\n self.log(\"No user_id on component; skipping ingestion-run tracking.\")\n return None, None, None, None\n\n try:\n user_uuid = uuid.UUID(str(self.user_id))\n except (ValueError, TypeError) as exc:\n self.log(f\"Could not coerce user_id={self.user_id!r} to UUID; skipping run tracking ({exc}).\")\n return None, None, None, None\n\n try:\n from langflow.api.utils import ingestion_run_service, knowledge_base_service\n from langflow.services.database.models.jobs.model import JobStatus, JobType\n from langflow.services.deps import get_job_service\n except ImportError as exc:\n self.log(f\"Run-history wiring unavailable; ingestion will not be recorded ({exc}).\")\n return None, None, None, None\n\n try:\n kb_record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)\n kb_record_id = kb_record.id if kb_record is not None else None\n\n user_metadata = self._parse_user_metadata_dict()\n\n job_id = uuid.uuid4()\n job_service = get_job_service()\n raw_flow_id = getattr(self, \"flow_id\", None)\n flow_id_uuid = uuid.UUID(str(raw_flow_id)) if raw_flow_id else job_id\n await job_service.create_job(\n job_id=job_id,\n flow_id=flow_id_uuid,\n job_type=JobType.INGESTION,\n asset_id=kb_record_id,\n asset_type=\"knowledge_base\",\n user_id=user_uuid,\n )\n await job_service.update_job_status(job_id, JobStatus.IN_PROGRESS)\n\n source = FlowComponentSource(\n user_id=user_uuid,\n source_config={\n \"knowledge_base\": self.knowledge_base,\n \"kb_path\": str(kb_path),\n \"flow_id\": str(flow_id_uuid),\n },\n )\n\n run_id = await ingestion_run_service.create_run(\n kb_name=self.knowledge_base,\n source=source,\n job_id=job_id,\n user_id=user_uuid,\n kb_id=kb_record_id,\n user_metadata=user_metadata,\n )\n if run_id is None:\n return None, job_id, None, kb_record_id\n await ingestion_run_service.mark_running(run_id)\n\n summary = IngestionSummary(\n kb_name=self.knowledge_base,\n source_type=source.source_type.value,\n user_id=user_uuid,\n job_id=job_id,\n source_config=source.describe().get(\"config\") or {},\n user_metadata=user_metadata,\n )\n self.log(f\"Started ingestion run job_id={job_id} kb_name={self.knowledge_base} kb_id={kb_record_id}\")\n except Exception as exc: # noqa: BLE001 — telemetry must never abort ingestion\n self.log(f\"Could not begin ingestion-run tracking: {exc}\")\n return None, None, None, None\n else:\n return run_id, job_id, summary, kb_record_id\n\n async def _finalize_ingestion_run(\n self,\n *,\n run_id: uuid.UUID,\n job_id: uuid.UUID | None,\n summary: IngestionSummary,\n status: IngestionRunStatus,\n error_message: str | None,\n ) -> None:\n \"\"\"Persist the final summary and transition the parent Job.\"\"\"\n try:\n from langflow.api.utils import ingestion_run_service\n from langflow.services.database.models.jobs.model import JobStatus\n from langflow.services.deps import get_job_service\n except ImportError as exc:\n self.log(f\"Run-history wiring unavailable; ingestion-run finalize skipped ({exc}).\")\n return\n\n try:\n await ingestion_run_service.finalize_run(\n run_id,\n summary=summary,\n status=status,\n error_message=error_message,\n )\n if job_id is not None:\n terminal_status = JobStatus.COMPLETED if status is not IngestionRunStatus.FAILED else JobStatus.FAILED\n await get_job_service().update_job_status(job_id, terminal_status, finished_timestamp=True)\n except Exception as exc: # noqa: BLE001 — telemetry must never re-raise\n self.log(f\"Could not finalize ingestion-run tracking: {exc}\")\n\n async def _record_kb_status(\n self,\n kb_record_id: uuid.UUID,\n status_value: str,\n *,\n failure_reason: str | None = None,\n ) -> None:\n \"\"\"Mirror Path A's KB-row status transitions.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n from langflow.services.database.models.knowledge_base.model import KnowledgeBaseStatus\n except ImportError:\n return\n try:\n await knowledge_base_service.update_status(\n kb_record_id,\n status=KnowledgeBaseStatus(status_value),\n failure_reason=failure_reason,\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Could not update KB status to {status_value}: {exc}\")\n\n async def _record_kb_stats(self, kb_record_id: uuid.UUID, kb_path: Path) -> None:\n \"\"\"Push freshly-refreshed metrics from embedding_metadata.json onto the DB row.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n except ImportError:\n self.log(\"knowledge_base_service unavailable; KB stats will not sync to DB row.\")\n return\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n self.log(f\"No embedding_metadata.json at {metadata_path}; skipping KB stats sync.\")\n return\n try:\n metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError) as exc:\n self.log(f\"Could not read KB metadata for stats sync: {exc}\")\n return\n chunks = int(metadata.get(\"chunks\", 0) or 0)\n words = int(metadata.get(\"words\", 0) or 0)\n characters = int(metadata.get(\"characters\", 0) or 0)\n try:\n await knowledge_base_service.update_stats(\n kb_record_id,\n chunks=chunks,\n words=words,\n characters=characters,\n size_bytes=int(metadata.get(\"size\", 0) or 0),\n source_types=list(metadata.get(\"source_types\") or []),\n chunk_size=metadata.get(\"chunk_size\"),\n chunk_overlap=metadata.get(\"chunk_overlap\"),\n separator=metadata.get(\"separator\"),\n )\n self.log(f\"Synced KB stats to DB row {kb_record_id}: chunks={chunks} words={words} characters={characters}\")\n except Exception as exc: # noqa: BLE001\n self.log(f\"Could not sync KB stats to DB row {kb_record_id}: {exc}\")\n\n def _parse_user_metadata_dict(self) -> dict[str, Any]:\n \"\"\"Decode ``metadata_json`` to a dict, or ``{}`` on any error.\"\"\"\n raw = getattr(self, \"metadata_json\", None)\n if not raw:\n return {}\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return {}\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError):\n return {}\n return decoded if isinstance(decoded, dict) else {}\n\n # =====================================================================\n # RETRIEVAL CODE PATH\n # =====================================================================\n @property\n def _user_uuid(self) -> uuid.UUID | None:\n \"\"\"Return self.user_id as a UUID, converting from str if necessary.\"\"\"\n if not self.user_id:\n return None\n return self.user_id if isinstance(self.user_id, uuid.UUID) else uuid.UUID(self.user_id)\n\n def _get_kb_metadata(self, kb_path: Path, *, require_api_key: bool = False) -> dict:\n \"\"\"Load the knowledge base's embedding metadata file.\"\"\"\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n return load_kb_metadata(\n kb_path,\n log_label=f\"knowledge base '{self.knowledge_base}'\",\n require_api_key=require_api_key,\n )\n\n async def _resolve_backend(self, *, kb_user: str) -> tuple[str, dict[str, Any]]: # noqa: ARG002\n \"\"\"Return ``(backend_type, backend_config)`` for this KB.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n\n user_uuid = self._user_uuid\n if user_uuid is None:\n return BackendType.CHROMA.value, {}\n record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)\n except Exception as exc: # noqa: BLE001\n logger.debug(\"KB record lookup failed: %s\", exc)\n return BackendType.CHROMA.value, {}\n\n if record is None:\n return BackendType.CHROMA.value, {}\n return (\n record.backend_type or BackendType.CHROMA.value,\n record.backend_config or {},\n )\n\n def _resolve_model_selection(self, metadata: dict[str, Any]) -> list[dict[str, Any]]:\n \"\"\"Resolve the ``get_embeddings``-compatible model selection from metadata.\"\"\"\n model_selection = metadata.get(\"model_selection\")\n if model_selection:\n selection_list = [model_selection] if isinstance(model_selection, dict) else list(model_selection)\n return [self._hydrate_model_metadata(entry) for entry in selection_list]\n\n embedding_model_name = metadata.get(\"embedding_model\")\n embedding_provider = metadata.get(\"embedding_provider\", \"Unknown\")\n if not embedding_model_name:\n msg = (\n f\"Knowledge base '{self.knowledge_base}' has no embedding model recorded; \"\n \"re-create it with a supported embedding model.\"\n )\n raise ValueError(msg)\n\n match = self._find_catalog_entry(embedding_model_name)\n if match is None:\n msg = (\n f\"Embedding model '{embedding_model_name}' (provider '{embedding_provider}') \"\n \"recorded for this knowledge base is no longer available in the model registry. \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg)\n return [match]\n\n def _hydrate_model_metadata(self, entry: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Fill in ``metadata.embedding_class`` / ``param_mapping`` if missing.\"\"\"\n entry_metadata = entry.get(\"metadata\") or {}\n has_class = bool(entry_metadata.get(\"embedding_class\"))\n has_mapping = bool(entry_metadata.get(\"param_mapping\"))\n if has_class and has_mapping:\n return entry\n\n model_name = entry.get(\"name\")\n if not model_name:\n return entry\n\n catalog_entry = self._find_catalog_entry(model_name)\n if catalog_entry is None:\n return entry\n\n catalog_metadata = catalog_entry.get(\"metadata\") or {}\n merged_metadata = {**catalog_metadata, **entry_metadata}\n return {**entry, \"metadata\": merged_metadata}\n\n def _find_catalog_entry(self, model_name: str) -> dict[str, Any] | None:\n \"\"\"Look up an embedding model by name in the unified-models catalog.\"\"\"\n options = get_embedding_model_options(user_id=self.user_id)\n return next((o for o in options if o.get(\"name\") == model_name), None)\n\n async def retrieve_data(self) -> DataFrame:\n \"\"\"Retrieve data from the selected knowledge base.\n\n Annotation narrowed to ``DataFrame`` to keep this output's handle\n type list at ``[\"Table\"]`` only; widening to a union surfaces\n ``[\"Table\", \"JSON\"]`` on the API and breaks every starter-project\n edge whose sourceHandle was stored against a single-type handle\n (BUG-02). The cross-mode defensive fallback may still return a\n ``Data`` at runtime — Python ignores return annotations, so\n nothing breaks.\n \"\"\"\n if not _is_retrieve_mode(getattr(self, \"mode\", MODE_INGEST)):\n return await self.build_kb_info()\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n # Lazy import: langflow's user/DB models aren't part of lfx's\n # standalone install, so ``lfx run <starter>.json`` can't resolve\n # this symbol at module import time. Deferring to use keeps the\n # component importable in both environments.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching Knowledge Base data.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n kb_path = self._resolve_kb_path(_get_knowledge_bases_root_path(), kb_user, self.knowledge_base)\n\n component_api_key = self.api_key if getattr(self, \"api_key\", None) else None\n needs_stored_key = not component_api_key\n metadata = self._get_kb_metadata(kb_path, require_api_key=needs_stored_key)\n if not metadata:\n msg = f\"Metadata not found for knowledge base: {self.knowledge_base}. Ensure it has been indexed.\"\n raise ValueError(msg)\n\n api_key = component_api_key or metadata.get(\"api_key\")\n model_selection = self._resolve_model_selection(metadata)\n chunk_size = metadata.get(\"chunk_size\")\n embedding_function = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n api_key=api_key,\n chunk_size=chunk_size,\n )\n\n backend_type, backend_config = await self._resolve_backend(kb_user=kb_user)\n backend = create_backend(\n backend_type,\n kb_name=self.knowledge_base,\n kb_path=kb_path,\n backend_config=backend_config,\n embedding_function=embedding_function,\n user_id=self.user_id,\n )\n try:\n user_metadata_filter = _parse_metadata_filter(getattr(self, \"metadata_filter\", None))\n use_scores = bool(self.search_query)\n search_k = self.top_k * 4 if user_metadata_filter else self.top_k\n results = await backend.similarity_search(\n query=self.search_query or \"\",\n k=search_k,\n with_scores=use_scores,\n )\n if user_metadata_filter:\n results = [\n (doc, score) for doc, score in results if _chunk_matches_filter(doc.metadata, user_metadata_filter)\n ]\n results = results[: self.top_k]\n\n embeddings_by_key: dict[tuple[str, str], list[float]] = {}\n if self.include_embeddings and results:\n # Join each retrieved chunk to its stored embedding. We key on\n # ``_id`` when present and fall back to page content otherwise,\n # so KBs populated by direct file upload — whose chunks carry no\n # ``_id`` — still resolve. See ``_embedding_match_key``.\n wanted_keys = {_embedding_match_key(doc.page_content, doc.metadata) for doc, _score in results}\n async for batch in backend.iter_documents(include_embeddings=True):\n for entry in batch:\n if entry.embedding is None:\n continue\n key = _embedding_match_key(entry.content, entry.metadata)\n if key in wanted_keys:\n embeddings_by_key[key] = entry.embedding\n if len(embeddings_by_key) == len(wanted_keys):\n break\n\n data_list: list[Data] = []\n for doc, score in results:\n kwargs: dict[str, Any] = {\"content\": doc.page_content}\n if use_scores:\n kwargs[\"_score\"] = -1 * score\n if self.include_metadata:\n kwargs.update(doc.metadata)\n if self.include_embeddings:\n kwargs[\"_embeddings\"] = embeddings_by_key.get(_embedding_match_key(doc.page_content, doc.metadata))\n data_list.append(Data(**kwargs))\n\n return DataFrame(data=data_list)\n finally:\n await backend.teardown()\n\n\ndef _embedding_match_key(content: str, metadata: dict[str, Any] | None) -> tuple[str, str]:\n \"\"\"Build a stable key for aligning a retrieved chunk with its stored embedding.\n\n Embeddings are gathered separately (via ``iter_documents``) and then joined\n back onto the search results. Component-driven ingestion stamps a\n content-hash ``_id`` on every chunk, but direct file-upload ingestion\n (``KBIngestionHelper.perform_ingestion``) does not. Keying the join purely on\n ``_id`` therefore left every upload-populated KB with ``_embeddings: None``.\n\n We prefer ``_id`` when present (so legitimately distinct chunks that happen to\n share text aren't collapsed) and fall back to the chunk's page content\n otherwise. The content fallback is exact for the embedding use case: two\n chunks with identical text necessarily share the same embedding vector. The\n leading ``\"id\"`` / ``\"content\"`` tag namespaces the two key spaces so a mixed\n KB (some chunks with ``_id``, some without) never cross-matches.\n \"\"\"\n doc_id = metadata.get(\"_id\") if metadata else None\n if doc_id:\n return (\"id\", str(doc_id))\n return (\"content\", content or \"\")\n\n\ndef _parse_metadata_filter(raw: str | None) -> dict[str, list[str]]:\n \"\"\"Decode the ``metadata_filter`` input into a {key: [values]} map.\n\n Empty or malformed input maps to an empty filter so retrieval falls back\n to the unfiltered path. JSON errors are swallowed here rather than raised:\n surfacing component-config errors at the canvas node would break a flow\n run for what is meant to be an optional refinement.\n \"\"\"\n if not raw:\n return {}\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return {}\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError):\n logger.warning(\"KnowledgeComponent: metadata_filter is not valid JSON; ignoring filter.\")\n return {}\n if not isinstance(decoded, dict):\n logger.warning(\"KnowledgeComponent: metadata_filter must be a JSON object; ignoring filter.\")\n return {}\n result: dict[str, list[str]] = {}\n for key, value in decoded.items():\n if not isinstance(key, str):\n continue\n if isinstance(value, list):\n result[key] = [str(entry) for entry in value]\n else:\n result[key] = [str(value)]\n return result\n\n\ndef _chunk_matches_filter(metadata: dict[str, Any] | None, filt: dict[str, list[str]]) -> bool:\n \"\"\"AND across keys, OR within key values, mirroring the chunks endpoint.\"\"\"\n if not filt:\n return True\n if not metadata:\n return False\n raw = metadata.get(\"source_metadata\")\n if not raw:\n return False\n try:\n stored = json.loads(raw) if isinstance(raw, str) else raw\n except json.JSONDecodeError:\n return False\n if not isinstance(stored, dict):\n return False\n for key, expected_values in filt.items():\n actual = stored.get(key)\n if actual is None:\n return False\n actual_set = {str(entry) for entry in actual} if isinstance(actual, list) else {str(actual)}\n if not actual_set & set(expected_values):\n return False\n return True\n"
"value": "\"\"\"Unified Knowledge component — ingest into or retrieve from a knowledge base.\n\nThis component merges what used to live in ``ingestion.py`` and\n``retrieval.py`` into a single, mode-driven component. A ``TabInput``\n(\"📥 Ingest\" / \"🔍 Retrieve\") drives which inputs and which output are\nvisible. The merged shape gives users one node per KB instead of two\nparallel ones that always shared the same ``knowledge_base`` picker,\nembedding-model metadata, and backend registry.\n\nBoth legacy classes (``KnowledgeIngestionComponent``,\n``KnowledgeBaseComponent``) remain importable as thin subclasses of\n``KnowledgeComponent`` — see the sibling ``ingestion.py`` / ``retrieval.py``\nmodules — so saved flows continue to load unchanged.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport hashlib\nimport json\nimport re\nimport uuid\nfrom dataclasses import asdict, dataclass, field\nfrom datetime import datetime, timezone\nfrom typing import TYPE_CHECKING, Any\n\nimport pandas as pd\nfrom cryptography.fernet import InvalidToken\nfrom langchain_chroma import Chroma\nfrom langflow.services.auth.utils import decrypt_api_key, encrypt_api_key\n\nfrom lfx.base.knowledge_bases.backends import BackendType, BaseVectorStoreBackend, create_backend\nfrom lfx.base.knowledge_bases.ingestion_sources.base import (\n IngestionItemResult,\n IngestionItemStatus,\n IngestionRunStatus,\n IngestionSummary,\n)\nfrom lfx.base.knowledge_bases.ingestion_sources.flow_component import FlowComponentSource\nfrom lfx.base.knowledge_bases.knowledge_base_utils import get_knowledge_bases\nfrom lfx.base.models.unified_models import get_embedding_model_options, get_embeddings\nfrom lfx.base.vectorstores.chroma_security import chroma_langchain_collection_kwargs\nfrom lfx.components.files_and_knowledge._kb_paths import (\n KBKeyDecryptError,\n load_kb_metadata,\n)\nfrom lfx.components.files_and_knowledge._kb_paths import (\n get_knowledge_bases_root_path as _get_knowledge_bases_root_path,\n)\nfrom lfx.components.processing.converter import convert_to_dataframe\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DBProviderInput,\n DropdownInput,\n HandleInput,\n IntInput,\n MessageTextInput,\n ModelInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.schema.table import EditMode\nfrom lfx.services.deps import (\n get_settings_service,\n session_scope,\n)\nfrom lfx.utils.component_utils import set_current_fields, set_field_display\nfrom lfx.utils.validate_cloud import raise_error_if_astra_cloud_disable_component\n\n\ndef _inputs_for_mode(default_mode: str) -> list:\n \"\"\"Return a fresh copy of the canonical inputs list with show flags set for the given mode.\n\n Used by the legacy subclasses so a saved flow keyed on\n ``KnowledgeIngestion`` / ``KnowledgeBase`` lands on a node template\n whose default visibility already matches the pinned mode — no\n \"wait for the user to click the mode tab\" UX gap on load.\n \"\"\"\n always_visible = set(KnowledgeComponent.default_keys) | set(KnowledgeComponent.mode_config[default_mode])\n inputs_copy = []\n for inp in KnowledgeComponent.inputs:\n clone = inp.model_copy(deep=True) if hasattr(inp, \"model_copy\") else inp\n if clone.name == \"mode\":\n clone.value = default_mode\n else:\n clone.show = clone.name in always_visible\n inputs_copy.append(clone)\n return inputs_copy\n\n\nif TYPE_CHECKING:\n from pathlib import Path\n\n# Mode constants. Plain-text labels (no emoji) for consistency with the rest of\n# Langflow's TabInput palette — see ``MemoryComponent`` for the same convention.\nMODE_INGEST = \"Ingest\"\nMODE_RETRIEVE = \"Retrieve\"\n\n\ndef _is_retrieve_mode(value: Any) -> bool:\n \"\"\"Lenient mode check: treats any label containing 'Retrieve' as retrieve mode.\n\n Older saved flows may carry the emoji-prefixed labels (\"📥 Ingest\" /\n \"🔍 Retrieve\") this component used to ship with; substring matching\n keeps those loading without forcing a flow rewrite.\n \"\"\"\n return isinstance(value, str) and \"Retrieve\" in value\n\n\n# Error message used by both the ingest and retrieve paths when the user is\n# running against an Astra cloud environment that disables these flows.\nastra_error_msg = \"Knowledge ingestion and retrieval are not supported in Astra cloud environment.\"\n\n_DEFAULT_OPENSEARCH_CONFIG = {\n \"url_variable\": \"OPENSEARCH_URL\",\n \"username_variable\": \"OPENSEARCH_USERNAME\",\n \"password_variable\": \"OPENSEARCH_PASSWORD\", # pragma: allowlist secret\n \"index_name\": \"\",\n \"vector_field\": \"vector_field\",\n \"text_field\": \"text\",\n}\n\n_DEFAULT_CHROMA_CLOUD_CONFIG = {\n \"mode\": \"cloud\",\n \"tenant_variable\": \"CHROMA_TENANT\",\n \"database_variable\": \"CHROMA_DATABASE\",\n \"api_key_variable\": \"CHROMA_API_KEY\", # pragma: allowlist secret\n}\n\n\nclass KnowledgeComponent(Component):\n \"\"\"One component for both writing into and reading from a Langflow knowledge base.\n\n A ``TabInput`` switches between ingestion and retrieval. The\n ``update_build_config`` / ``update_outputs`` hooks hide the inputs\n and the output that don't apply to the current mode so the canvas\n node stays focused.\n \"\"\"\n\n display_name = \"Knowledge\"\n description = \"Ingest into or retrieve from a Langflow knowledge base.\"\n icon = \"database\"\n name = \"Knowledge\"\n\n # ------ Mode → visible-fields wiring ---------------------------------\n # ``default_keys`` are inputs always visible regardless of mode.\n # ``mode_config`` lists the inputs unique to each mode; everything outside\n # this set is hidden when its mode is not selected.\n default_keys: list[str] = [\"mode\", \"knowledge_base\"]\n mode_config: dict[str, list[str]] = {\n MODE_INGEST: [\n \"input_df\",\n \"column_config\",\n \"chunk_size\",\n \"api_key\",\n \"allow_duplicates\",\n \"metadata_json\",\n ],\n MODE_RETRIEVE: [\n \"search_query\",\n \"top_k\",\n \"include_metadata\",\n \"include_embeddings\",\n \"metadata_filter\",\n ],\n }\n\n def __init__(self, *args, **kwargs) -> None:\n super().__init__(*args, **kwargs)\n self._cached_kb_path: Path | None = None\n\n @dataclass\n class NewKnowledgeBaseInput:\n functionality: str = \"create\"\n fields: dict[str, dict] = field(\n default_factory=lambda: {\n \"data\": {\n \"node\": {\n \"name\": \"create_knowledge_base\",\n \"description\": \"Create new knowledge in Langflow.\",\n \"display_name\": \"Create new Knowledge Base\",\n \"field_order\": [\n \"01_new_kb_name\",\n \"02_embedding_model\",\n \"03_knowledge_backend\",\n ],\n \"template\": {\n \"01_new_kb_name\": StrInput(\n name=\"new_kb_name\",\n display_name=\"Knowledge Name\",\n info=\"Name of the new knowledge to create.\",\n required=True,\n ),\n \"02_embedding_model\": ModelInput(\n name=\"embedding_model\",\n display_name=\"Choose Embedding Model\",\n info=(\n \"Select the embedding model to use for this knowledge base. \"\n \"Langflow uses the configured credentials for that model provider.\"\n ),\n required=True,\n model_type=\"embedding\",\n ),\n \"03_knowledge_backend\": DBProviderInput(\n name=\"knowledge_backend\",\n display_name=\"DB Provider\",\n info=(\n \"Select where this knowledge base stores vectors. \"\n \"OpenSearch uses the global DB Providers settings.\"\n ),\n required=True,\n ),\n },\n },\n }\n }\n )\n\n # ------ Inputs --------------------------------------------------------\n # ``knowledge_base`` is kept at position 0 so legacy tests that check\n # ``component.inputs[0].dialog_inputs`` continue to work.\n inputs = [\n DropdownInput(\n name=\"knowledge_base\",\n display_name=\"Knowledge\",\n info=\"Select the knowledge to load data from.\",\n required=True,\n options=[],\n refresh_button=True,\n real_time_refresh=True,\n dialog_inputs=asdict(NewKnowledgeBaseInput()),\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[MODE_INGEST, MODE_RETRIEVE],\n value=MODE_INGEST,\n info=\"Switch between writing new data into the knowledge base and querying it.\",\n real_time_refresh=True,\n tool_mode=True,\n ),\n # --- Ingest-only inputs (default-shown; hidden when mode == Retrieve) -\n HandleInput(\n name=\"input_df\",\n display_name=\"Input\",\n info=(\n \"Table with all original columns (already chunked / processed). \"\n \"Accepts Message, Data, or DataFrame. If Message or Data is provided, \"\n \"it is converted to a DataFrame automatically.\"\n ),\n input_types=[\"Message\", \"Data\", \"JSON\", \"DataFrame\", \"Table\"],\n required=True,\n dynamic=True,\n show=True,\n ),\n TableInput(\n name=\"column_config\",\n display_name=\"Column Configuration\",\n info=\"Configure column behavior for the knowledge base.\",\n required=True,\n table_schema=[\n {\n \"name\": \"column_name\",\n \"display_name\": \"Column Name\",\n \"type\": \"str\",\n \"description\": \"Name of the column in the source DataFrame\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"vectorize\",\n \"display_name\": \"Vectorize\",\n \"type\": \"boolean\",\n \"description\": \"Create embeddings for this column\",\n \"default\": False,\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"identifier\",\n \"display_name\": \"Identifier\",\n \"type\": \"boolean\",\n \"description\": \"Use this column as unique identifier\",\n \"default\": False,\n \"edit_mode\": EditMode.INLINE,\n },\n ],\n value=[\n {\n \"column_name\": \"text\",\n \"vectorize\": True,\n \"identifier\": True,\n },\n ],\n dynamic=True,\n show=True,\n ),\n IntInput(\n name=\"chunk_size\",\n display_name=\"Chunk Size\",\n info=\"Batch size for processing embeddings\",\n advanced=True,\n value=1000,\n dynamic=True,\n show=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Embedding Provider API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n advanced=True,\n required=False,\n dynamic=True,\n show=True,\n ),\n BoolInput(\n name=\"allow_duplicates\",\n display_name=\"Allow Duplicates\",\n info=\"Allow duplicate rows in the knowledge base\",\n advanced=True,\n value=False,\n dynamic=True,\n show=True,\n ),\n StrInput(\n name=\"metadata_json\",\n display_name=\"Metadata\",\n info=(\n \"Optional JSON object of user metadata applied to every chunk produced by this \"\n 'run (e.g. {\"tag\": \"invoice\", \"year\": \"2026\"}). Same shape as the upload modal '\n \"Metadata section so chunks browser filters + Knowledge retrieval metadata_filter \"\n \"work uniformly across upload, folder, and flow-driven ingestion. Malformed JSON is \"\n \"ignored with a warning rather than failing the run.\"\n ),\n advanced=True,\n required=False,\n dynamic=True,\n show=True,\n ),\n # --- Retrieve-only inputs (default-hidden; shown when mode == Retrieve) -\n MessageTextInput(\n name=\"search_query\",\n display_name=\"Search Query\",\n info=\"Optional search query to filter knowledge base data.\",\n tool_mode=True,\n dynamic=True,\n show=False,\n ),\n IntInput(\n name=\"top_k\",\n display_name=\"Top K Results\",\n info=\"Number of top results to return from the knowledge base.\",\n value=5,\n advanced=True,\n required=False,\n dynamic=True,\n show=False,\n ),\n BoolInput(\n name=\"include_metadata\",\n display_name=\"Include Metadata\",\n info=\"Whether to include all metadata in the output. If false, only content is returned.\",\n value=True,\n advanced=False,\n dynamic=True,\n show=False,\n ),\n BoolInput(\n name=\"include_embeddings\",\n display_name=\"Include Embeddings\",\n info=\"Whether to include embeddings in the output. Only applicable if 'Include Metadata' is enabled.\",\n value=False,\n advanced=True,\n dynamic=True,\n show=False,\n ),\n MessageTextInput(\n name=\"metadata_filter\",\n display_name=\"Metadata Filter\",\n info=(\n \"Optional JSON object of user-metadata key/value pairs. Only chunks \"\n 'whose source_metadata matches every key are returned (e.g. {\"tag\": \"invoice\"} '\n 'or {\"tag\": [\"invoice\", \"audit\"]} for OR-of-values). Backends without '\n \"native filtering apply the match client-side after retrieval.\"\n ),\n advanced=True,\n dynamic=True,\n show=False,\n ),\n ]\n\n # ------ Outputs -------------------------------------------------------\n # Both outputs are declared at the class level so the runtime can\n # dispatch to either method depending on which one the saved flow has\n # wired up. ``update_outputs`` filters the canvas-visible output per\n # selected mode; see ``TypeConverterComponent`` and ``MemoryComponent``\n # for the same pattern.\n #\n # Output names match the legacy ``KnowledgeIngestionComponent``\n # (``dataframe_output``) and ``KnowledgeBaseComponent``\n # (``retrieve_data``) so saved flow edges keyed on those names resolve\n # cleanly against the merged component.\n outputs = [\n Output(\n display_name=\"Results\",\n name=\"dataframe_output\",\n method=\"build_kb_info\",\n types=[\"JSON\"],\n selected=\"JSON\",\n ),\n Output(\n display_name=\"Results\",\n name=\"retrieve_data\",\n method=\"retrieve_data\",\n info=\"Returns the data from the selected knowledge base.\",\n types=[\"Table\"],\n selected=\"Table\",\n ),\n ]\n\n # ------ Mode-driven UI updates ---------------------------------------\n async def update_frontend_node(self, new_frontend_node: dict, current_frontend_node: dict):\n \"\"\"Sync the visible output with the current ``mode`` value on canvas load.\n\n Saved flows hit this path: ``update_outputs`` is normally only triggered\n by ``real_time_refresh`` field edits, so without this re-sync the\n canvas could land with both outputs visible.\n \"\"\"\n await super().update_frontend_node(new_frontend_node, current_frontend_node)\n mode_value = new_frontend_node.get(\"template\", {}).get(\"mode\", {}).get(\"value\", MODE_INGEST)\n self.update_outputs(new_frontend_node, \"mode\", mode_value)\n return new_frontend_node\n\n def update_outputs(self, frontend_node: dict, field_name: str, field_value: Any) -> dict:\n \"\"\"Filter visible outputs to match the selected mode.\n\n Triggered by the ``mode`` ``TabInput`` ``real_time_refresh`` flag.\n The class-level ``outputs`` list always carries both entries so the\n runtime can resolve either ``build_kb_info`` or ``retrieve_data``\n regardless of canvas visibility — we just hide the unused one here.\n \"\"\"\n if field_name != \"mode\":\n return frontend_node\n if _is_retrieve_mode(field_value):\n frontend_node[\"outputs\"] = [\n Output(\n display_name=\"Results\",\n name=\"retrieve_data\",\n method=\"retrieve_data\",\n info=\"Returns the data from the selected knowledge base.\",\n types=[\"Table\"],\n selected=\"Table\",\n )\n ]\n else:\n frontend_node[\"outputs\"] = [\n Output(\n display_name=\"Results\",\n name=\"dataframe_output\",\n method=\"build_kb_info\",\n types=[\"JSON\"],\n selected=\"JSON\",\n )\n ]\n return frontend_node\n\n async def update_build_config(\n self,\n build_config,\n field_value: Any,\n field_name: str | None = None,\n ):\n \"\"\"Refresh KB options, drive the create-KB dialog, and hide off-mode fields.\"\"\"\n # Astra-cloud gate covers both ingest and retrieve paths.\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n # Always populate the create-KB dialog's embedding-model options so the\n # ModelInput renders correctly regardless of which input triggered the\n # refresh.\n try:\n dialog_template = (\n build_config[\"knowledge_base\"]\n .get(\"dialog_inputs\", {})\n .get(\"fields\", {})\n .get(\"data\", {})\n .get(\"node\", {})\n .get(\"template\", {})\n )\n if \"02_embedding_model\" in dialog_template:\n embedding_options = get_embedding_model_options(user_id=self.user_id)\n dialog_template[\"02_embedding_model\"][\"options\"] = embedding_options\n except Exception: # noqa: BLE001\n self.log(\"Failed to populate embedding model options in dialog\")\n\n # KB-picker refresh + create-new-KB flow (lifted from the legacy ingestion\n # component verbatim; relied on by both the canvas refresh button and the\n # dialog-submit path).\n if field_name == \"knowledge_base\":\n # Lazy import keeps lfx importable without langflow installed.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching knowledge base list.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n if isinstance(field_value, dict) and \"01_new_kb_name\" in field_value:\n if not self.is_valid_collection_name(field_value[\"01_new_kb_name\"]):\n msg = f\"Invalid knowledge base name: {field_value['01_new_kb_name']}\"\n raise ValueError(msg)\n\n model_selection = field_value[\"02_embedding_model\"]\n if isinstance(model_selection, dict):\n model_selection = [model_selection]\n\n backend_type, backend_config = self._normalize_backend_selection(\n field_value.get(\"03_knowledge_backend\")\n )\n\n embed_model = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n )\n\n try:\n await asyncio.wait_for(\n asyncio.to_thread(embed_model.embed_query, \"test\"),\n timeout=10,\n )\n except TimeoutError as e:\n msg = \"Embedding validation timed out. Please verify network connectivity and key.\"\n raise ValueError(msg) from e\n except Exception as e:\n msg = f\"Embedding validation failed: {e!s}\"\n raise ValueError(msg) from e\n\n kb_path = self._resolve_kb_path(\n _get_knowledge_bases_root_path(), kb_user, field_value[\"01_new_kb_name\"]\n )\n kb_path.mkdir(parents=True, exist_ok=True)\n\n build_config[\"knowledge_base\"][\"value\"] = field_value[\"01_new_kb_name\"]\n self._save_embedding_metadata(\n kb_path=kb_path,\n model_selection=model_selection,\n backend_type=backend_type,\n backend_config=backend_config,\n )\n await self._create_knowledge_base_record(\n user_id=self.user_id,\n name=field_value[\"01_new_kb_name\"],\n model_selection=model_selection,\n backend_type=backend_type,\n backend_config=backend_config,\n )\n\n build_config[\"knowledge_base\"][\"options\"] = await get_knowledge_bases(\n _get_knowledge_bases_root_path(),\n user_id=self.user_id,\n )\n if build_config[\"knowledge_base\"][\"value\"] not in build_config[\"knowledge_base\"][\"options\"]:\n build_config[\"knowledge_base\"][\"value\"] = None\n\n # Honor the current mode regardless of which field triggered the refresh.\n # Falls back to MODE_INGEST when ``mode`` is missing (legacy nodes).\n current_mode = build_config.get(\"mode\", {}).get(\"value\") if isinstance(build_config, dict) else None\n if field_name == \"mode\":\n current_mode = field_value\n # Map legacy/emoji-prefixed labels onto the current canonical values so\n # flows saved before the label change still toggle visibility correctly.\n if _is_retrieve_mode(current_mode):\n current_mode = MODE_RETRIEVE\n elif current_mode not in self.mode_config:\n current_mode = MODE_INGEST\n return set_current_fields(\n build_config=build_config if isinstance(build_config, dotdict) else dotdict(build_config),\n action_fields=self.mode_config,\n selected_action=current_mode,\n default_fields=self.default_keys,\n func=set_field_display,\n )\n\n # =====================================================================\n # INGESTION CODE PATH\n # =====================================================================\n def _get_kb_root(self) -> Path:\n \"\"\"Return the root directory for knowledge bases.\"\"\"\n return _get_knowledge_bases_root_path()\n\n @staticmethod\n def _resolve_kb_path(kb_root: Path, kb_user: str, kb_name: str) -> Path:\n \"\"\"Resolve the selected KB inside the authenticated user's directory.\"\"\"\n # Lazy import keeps lfx importable without langflow's DB services.\n from langflow.services.memory_base.kb_path_helpers import validate_kb_path\n\n user_root = kb_root / kb_user\n validate_kb_path(kb_root, user_root)\n\n kb_path = user_root / kb_name\n validate_kb_path(user_root, kb_path)\n return kb_path\n\n @staticmethod\n def _scalar_notna(value) -> bool:\n \"\"\"Check if a value is not NA, safely handling arrays and sequences.\n\n ``pd.notna`` returns an array when given an array-like input, which\n cannot be used directly in a boolean context. This helper collapses\n the result to a single scalar ``bool``.\n \"\"\"\n result = pd.notna(value)\n if hasattr(result, \"__iter__\") and not isinstance(result, str):\n import numpy as np\n\n arr = np.asarray(result)\n return arr.size > 0 and arr.all()\n return bool(result)\n\n def _validate_column_config(self, df_source: pd.DataFrame) -> list[dict[str, Any]]:\n \"\"\"Validate column configuration using Structured Output patterns.\"\"\"\n if not self.column_config:\n msg = \"Column configuration cannot be empty\"\n raise ValueError(msg)\n\n config_list = self.column_config if isinstance(self.column_config, list) else []\n\n df_columns = set(df_source.columns)\n for config in config_list:\n col_name = config.get(\"column_name\")\n if col_name not in df_columns:\n msg = f\"Column '{col_name}' not found in DataFrame. Available columns: {sorted(df_columns)}\"\n raise ValueError(msg)\n\n return config_list\n\n def _build_embedding_metadata(\n self,\n model_selection: list[dict[str, Any]],\n api_key: str | None = None,\n backend_type: str = BackendType.CHROMA.value,\n backend_config: dict[str, Any] | None = None,\n ) -> dict[str, Any]:\n \"\"\"Build embedding model metadata from a model selection dict.\"\"\"\n model_dict = model_selection[0] if isinstance(model_selection, list) else model_selection\n embedding_model = model_dict.get(\"name\", \"\")\n embedding_provider = model_dict.get(\"provider\", \"Unknown\")\n\n api_key_to_save = None\n if api_key and hasattr(api_key, \"get_secret_value\"):\n api_key_to_save = api_key.get_secret_value()\n elif isinstance(api_key, str):\n api_key_to_save = api_key\n\n encrypted_api_key = None\n if api_key_to_save:\n settings_service = get_settings_service()\n try:\n encrypted_api_key = encrypt_api_key(api_key_to_save, settings_service=settings_service)\n except (TypeError, ValueError) as e:\n self.log(f\"Could not encrypt API key: {e}\")\n\n return {\n \"embedding_provider\": embedding_provider,\n \"embedding_model\": embedding_model,\n \"model_selection\": model_dict,\n \"api_key\": encrypted_api_key,\n \"api_key_used\": bool(api_key),\n \"chunk_size\": self.chunk_size,\n \"backend_type\": backend_type,\n \"backend_config\": backend_config or {},\n \"created_at\": datetime.now(timezone.utc).isoformat(),\n }\n\n def _save_embedding_metadata(\n self,\n kb_path: Path,\n model_selection: list[dict[str, Any]],\n api_key: str | None = None,\n backend_type: str | None = None,\n backend_config: dict[str, Any] | None = None,\n ) -> None:\n \"\"\"Save embedding model metadata.\"\"\"\n metadata_path = kb_path / \"embedding_metadata.json\"\n existing_metadata: dict[str, Any] = {}\n if metadata_path.exists():\n try:\n existing_metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError):\n existing_metadata = {}\n\n embedding_metadata = self._build_embedding_metadata(\n model_selection,\n api_key,\n backend_type=backend_type or existing_metadata.get(\"backend_type\") or BackendType.CHROMA.value,\n backend_config=backend_config\n if backend_config is not None\n else existing_metadata.get(\"backend_config\") or {},\n )\n metadata_path.write_text(json.dumps(embedding_metadata, indent=2))\n\n def _update_metadata_metrics(self, kb_path: Path, chroma: Chroma) -> None:\n \"\"\"Update embedding_metadata.json with accurate chunk/word/character counts.\"\"\"\n import chromadb.errors\n from langflow.api.utils.kb_helpers import KBAnalysisHelper, KBStorageHelper\n\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n\n try:\n metadata = json.loads(metadata_path.read_text())\n KBAnalysisHelper.update_text_metrics(kb_path, metadata, chroma)\n metadata[\"size\"] = KBStorageHelper.get_directory_size(kb_path)\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError, chromadb.errors.ChromaError) as e:\n self.log(f\"Warning: Could not update metadata metrics: {e}\")\n\n @staticmethod\n def _extract_source_types_from_df(df_source: pd.DataFrame) -> set[str]:\n \"\"\"Pull file extensions out of common path/name columns on the source DataFrame.\n\n The direct-upload ingestion path stores extensions in\n ``embedding_metadata.json[source_types]`` so the KB list can render the\n correct file-type icon. When ingestion happens via a connected\n ``input_df`` (e.g. File → Knowledge) the same field stayed empty and\n the icon defaulted to a blank tile. We look at the well-known columns\n the File / S3 / cloud-storage components produce and collect any\n plausible extension so the icon is consistent across both flows.\n \"\"\"\n candidate_columns = (\"file_path\", \"file_name\", \"filename\", \"source\", \"path\", \"mimetype\")\n extensions: set[str] = set()\n for col in candidate_columns:\n if col not in df_source.columns:\n continue\n for value in df_source[col].dropna():\n ext = KnowledgeComponent._extension_from_value(value)\n # Drop anything that doesn't look like an extension (URL\n # query strings, version segments, etc.) — the icon palette\n # keys off short alphanumeric tokens like \"pdf\"/\"docx\".\n if ext:\n extensions.add(ext)\n return extensions\n\n @staticmethod\n def _extension_from_value(value: Any) -> str | None:\n \"\"\"Return a normalized file-extension token from a path / filename / MIME string.\n\n Accepts values like ``\"report.PDF\"``, ``\"/docs/notes.txt\"``, or\n ``\"application/pdf\"`` and returns ``\"pdf\"`` / ``\"txt\"``. Returns\n ``None`` if no plausible extension can be derived.\n \"\"\"\n extension_length_limit = 10\n if value is None:\n return None\n text = str(value).strip()\n if not text:\n return None\n # MIME types like ``application/pdf`` carry the canonical extension\n # in the subtype slot — preserve them so File / S3 messages keyed\n # only on ``mimetype`` still resolve to an icon.\n if \"/\" in text and \".\" not in text.rsplit(\"/\", 1)[-1]:\n subtype = text.rsplit(\"/\", 1)[-1].strip().lower()\n return subtype if subtype and len(subtype) <= extension_length_limit and subtype.isalnum() else None\n if \".\" not in text:\n return None\n ext = text.rsplit(\".\", 1)[-1].strip().lower()\n if ext and len(ext) <= extension_length_limit and ext.isalnum():\n return ext\n return None\n\n @classmethod\n def _extract_source_types_from_mapping(cls, mapping: Any) -> set[str]:\n \"\"\"Pull extensions from a Message/Data-style ``data`` dict or a plain dict.\"\"\"\n if not isinstance(mapping, dict):\n return set()\n candidate_keys = (\"file_path\", \"file_name\", \"filename\", \"source\", \"path\", \"mimetype\")\n extensions: set[str] = set()\n for key in candidate_keys:\n ext = cls._extension_from_value(mapping.get(key))\n if ext:\n extensions.add(ext)\n return extensions\n\n @classmethod\n def _extract_source_types_from_input(cls, input_value: Any) -> set[str]:\n \"\"\"Pull file extensions out of the raw component input.\n\n ``convert_to_dataframe`` strips Message/Data fields down to ``text``\n when projecting onto a DataFrame, so file metadata attached to a\n File-component \"Raw Content\" output never reaches\n ``_extract_source_types_from_df``. Looking at the raw input first\n keeps the KB icon consistent with direct upload.\n \"\"\"\n if input_value is None:\n return set()\n if isinstance(input_value, list):\n extensions: set[str] = set()\n for item in input_value:\n extensions |= cls._extract_source_types_from_input(item)\n return extensions\n if isinstance(input_value, pd.DataFrame):\n return cls._extract_source_types_from_df(input_value)\n # Message / Data / JSON all expose a ``data`` dict via the lfx schema.\n mapping = getattr(input_value, \"data\", None)\n if mapping is None and isinstance(input_value, dict):\n mapping = input_value\n return cls._extract_source_types_from_mapping(mapping)\n\n def _merge_source_types(self, kb_path: Path, extensions: set[str]) -> None:\n \"\"\"Merge newly observed extensions into the KB's ``source_types`` metadata.\n\n Mirrors the direct-upload path in ``KBIngestionHelper`` so the icon\n rendering on the Knowledge Bases list works regardless of which\n ingestion route was used.\n \"\"\"\n if not extensions:\n return\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n try:\n metadata = json.loads(metadata_path.read_text())\n existing = set(metadata.get(\"source_types\") or [])\n metadata[\"source_types\"] = sorted(existing | extensions)\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError) as e:\n self.log(f\"Warning: Could not update source_types metadata: {e}\")\n\n async def _update_backend_metadata_metrics(self, kb_path: Path, backend: BaseVectorStoreBackend) -> None:\n \"\"\"Update metadata metrics for non-Chroma backends.\"\"\"\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return\n\n try:\n metadata = json.loads(metadata_path.read_text())\n chunks = await backend.count()\n characters = 0\n words = 0\n async for batch in backend.iter_documents():\n for document in batch:\n characters += len(document.content)\n words += len(document.content.split())\n\n metadata[\"chunks\"] = chunks\n metadata[\"characters\"] = characters\n metadata[\"words\"] = words\n metadata[\"avg_chunk_size\"] = characters / chunks if chunks else 0.0\n metadata[\"size\"] = await backend.storage_size_bytes()\n metadata_path.write_text(json.dumps(metadata, indent=2))\n except (OSError, ValueError, TypeError, json.JSONDecodeError) as e:\n self.log(f\"Warning: Could not update backend metadata metrics: {e}\")\n\n @staticmethod\n def _normalize_backend_selection(value: Any) -> tuple[str, dict[str, Any]]:\n \"\"\"Normalize a DBProviderInput value into backend type/config.\"\"\"\n if not value:\n return BackendType.CHROMA.value, {}\n\n if isinstance(value, str):\n backend_type = value if value == BackendType.OPENSEARCH.value else BackendType.CHROMA.value\n return (\n backend_type,\n _DEFAULT_OPENSEARCH_CONFIG.copy() if backend_type == BackendType.OPENSEARCH.value else {},\n )\n\n if not isinstance(value, dict):\n return BackendType.CHROMA.value, {}\n\n backend_type = str(value.get(\"backend_type\") or value.get(\"id\") or BackendType.CHROMA.value)\n\n if backend_type == BackendType.OPENSEARCH.value:\n backend_config = value.get(\"backend_config\") or value.get(\"config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return BackendType.OPENSEARCH.value, {**_DEFAULT_OPENSEARCH_CONFIG, **backend_config}\n\n if backend_type == \"chroma_cloud\":\n backend_config = value.get(\"backend_config\") or value.get(\"config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return BackendType.CHROMA.value, {**_DEFAULT_CHROMA_CLOUD_CONFIG, **backend_config}\n\n return BackendType.CHROMA.value, {}\n\n @staticmethod\n def _get_backend_from_metadata(kb_path: Path) -> tuple[str, dict[str, Any]] | None:\n \"\"\"Read ``(backend_type, backend_config)`` off the on-disk sidecar.\n\n Returns ``None`` — never a Chroma default — when the sidecar is absent or\n unreadable. \"Could not resolve\" has to stay distinguishable from\n \"explicitly local\", or a replica that merely lacks the file writes a\n remote-backed KB into a local store. See ``_resolve_backend_config``.\n \"\"\"\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n return None\n try:\n metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError):\n return None\n\n backend_type = str(metadata.get(\"backend_type\") or BackendType.CHROMA.value)\n backend_config = metadata.get(\"backend_config\") or {}\n if not isinstance(backend_config, dict):\n backend_config = {}\n return backend_type, backend_config\n\n async def _create_knowledge_base_record(\n self,\n *,\n user_id: Any,\n name: str,\n model_selection: list[dict[str, Any]],\n backend_type: str,\n backend_config: dict[str, Any],\n ) -> None:\n \"\"\"Persist the component-created KB in the DB when Langflow is available.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n except ImportError:\n return\n\n try:\n await knowledge_base_service.create_record(\n user_id=user_id,\n name=name,\n model_selection=model_selection,\n column_config=self.column_config if isinstance(self.column_config, list) else [],\n backend_type=backend_type,\n backend_config=backend_config,\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Warning: could not persist knowledge base record: {exc}\")\n\n def _save_kb_files(\n self,\n kb_path: Path,\n config_list: list[dict[str, Any]],\n ) -> None:\n \"\"\"Save KB files using File Component storage patterns.\"\"\"\n try:\n kb_path.mkdir(parents=True, exist_ok=True)\n\n cfg_path = kb_path / \"schema.json\"\n if not cfg_path.exists():\n cfg_path.write_text(json.dumps(config_list, indent=2))\n\n except (OSError, TypeError, ValueError) as e:\n self.log(f\"Error saving KB files: {e}\")\n\n def _build_column_metadata(self, config_list: list[dict[str, Any]], df_source: pd.DataFrame) -> dict[str, Any]:\n \"\"\"Build detailed column metadata.\"\"\"\n metadata: dict[str, Any] = {\n \"total_columns\": len(df_source.columns),\n \"mapped_columns\": len(config_list),\n \"unmapped_columns\": len(df_source.columns) - len(config_list),\n \"columns\": [],\n \"summary\": {\"vectorized_columns\": [], \"identifier_columns\": []},\n }\n\n for config in config_list:\n col_name = config.get(\"column_name\")\n vectorize = config.get(\"vectorize\") == \"True\" or config.get(\"vectorize\") is True\n identifier = config.get(\"identifier\") == \"True\" or config.get(\"identifier\") is True\n\n metadata[\"columns\"].append(\n {\n \"name\": col_name,\n \"vectorize\": vectorize,\n \"identifier\": identifier,\n }\n )\n\n if vectorize:\n metadata[\"summary\"][\"vectorized_columns\"].append(col_name)\n if identifier:\n metadata[\"summary\"][\"identifier_columns\"].append(col_name)\n\n return metadata\n\n async def _create_vector_store(\n self,\n df_source: pd.DataFrame,\n config_list: list[dict[str, Any]],\n embedding_function,\n ) -> BaseVectorStoreBackend:\n \"\"\"Create vector store using the configured DB provider.\"\"\"\n vector_store_dir = await self._kb_path()\n if not vector_store_dir:\n msg = \"Knowledge base path is not set. Please create a new knowledge base first.\"\n raise ValueError(msg)\n vector_store_dir.mkdir(parents=True, exist_ok=True)\n\n backend_type, backend_config = await self._resolve_backend_config(vector_store_dir)\n backend = create_backend(\n backend_type,\n kb_name=self.knowledge_base,\n kb_path=vector_store_dir,\n backend_config=backend_config,\n embedding_function=embedding_function,\n user_id=self.user_id,\n )\n await backend.ensure_ready()\n\n existing_ids = None\n if backend_type != BackendType.CHROMA.value and not self.allow_duplicates:\n existing_ids = set()\n async for batch in backend.iter_documents():\n for document in batch:\n doc_id = document.metadata.get(\"_id\")\n if doc_id:\n existing_ids.add(doc_id)\n\n data_objects = await self._convert_df_to_data_objects(df_source, config_list, existing_ids=existing_ids)\n\n user_metadata_tag = self._resolve_user_metadata_tag()\n\n documents = []\n for data_obj in data_objects:\n doc = data_obj.to_lc_document()\n if user_metadata_tag:\n doc.metadata[\"source_metadata\"] = user_metadata_tag\n documents.append(doc)\n\n if documents:\n await backend.add_documents(documents)\n self.log(f\"Added {len(documents)} documents to vector store '{self.knowledge_base}'\")\n\n return backend\n\n async def _convert_df_to_data_objects(\n self,\n df_source: pd.DataFrame,\n config_list: list[dict[str, Any]],\n existing_ids: set[str] | None = None,\n ) -> list[Data]:\n \"\"\"Convert DataFrame to Data objects for vector store.\"\"\"\n data_objects: list[Data] = []\n\n if existing_ids is None:\n kb_path = await self._kb_path()\n\n chroma = Chroma(\n persist_directory=str(kb_path),\n collection_name=self.knowledge_base,\n **chroma_langchain_collection_kwargs(),\n )\n\n all_docs = chroma.get()\n\n existing_ids = {metadata.get(\"_id\") for metadata in all_docs[\"metadatas\"] if metadata.get(\"_id\")}\n\n content_cols = []\n identifier_cols = []\n\n for config in config_list:\n col_name = config.get(\"column_name\")\n vectorize = config.get(\"vectorize\") == \"True\" or config.get(\"vectorize\") is True\n identifier = config.get(\"identifier\") == \"True\" or config.get(\"identifier\") is True\n\n if vectorize:\n content_cols.append(col_name)\n if identifier:\n identifier_cols.append(col_name)\n\n for _, row in df_source.iterrows():\n identifier_parts = [str(row[col]) for col in content_cols if col in row and self._scalar_notna(row[col])]\n\n page_content = \" \".join(identifier_parts)\n\n data_dict = {\n \"text\": page_content,\n }\n\n if identifier_cols:\n identifier_parts = [\n str(row[col]) for col in identifier_cols if col in row and self._scalar_notna(row[col])\n ]\n page_content = \" \".join(identifier_parts)\n\n for col in df_source.columns:\n if col not in content_cols and col in row and self._scalar_notna(row[col]):\n value = row[col]\n data_dict[col] = str(value)\n\n page_content_hash = hashlib.sha256(page_content.encode()).hexdigest()\n data_dict[\"_id\"] = page_content_hash\n\n if not self.allow_duplicates and page_content_hash in existing_ids:\n self.log(f\"Skipping duplicate row with hash {page_content_hash}\")\n continue\n\n data_obj = Data(data=data_dict)\n data_objects.append(data_obj)\n\n return data_objects\n\n def is_valid_collection_name(self, name, min_length: int = 3, max_length: int = 63) -> bool:\n \"\"\"Validate collection name.\n\n 1. Contains 3-63 characters\n 2. Starts and ends with alphanumeric character\n 3. Contains only alphanumeric characters, underscores, or hyphens.\n \"\"\"\n if not (min_length <= len(name) <= max_length):\n return False\n\n if not (name[0].isalnum() and name[-1].isalnum()):\n return False\n\n return re.match(r\"^[a-zA-Z0-9_-]+$\", name) is not None\n\n async def _kb_path(self) -> Path | None:\n cached_path = getattr(self, \"_cached_kb_path\", None)\n if cached_path is not None:\n return cached_path\n\n # Lazy import to keep ``lfx`` importable standalone — langflow's\n # user/DB models are not always available at module load time.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching knowledge base path.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n\n kb_root = self._get_kb_root()\n\n self._cached_kb_path = self._resolve_kb_path(kb_root, kb_user, self.knowledge_base)\n\n return self._cached_kb_path\n\n def _resolve_user_metadata_tag(self) -> str:\n \"\"\"Return the JSON-encoded user metadata tag for chunk writes.\"\"\"\n raw = getattr(self, \"metadata_json\", None)\n if not raw:\n return \"\"\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return \"\"\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError) as exc:\n self.log(f\"KnowledgeComponent: metadata_json is not valid JSON ({exc}); skipping metadata stamp.\")\n return \"\"\n if not isinstance(decoded, dict):\n self.log(\"KnowledgeComponent: metadata_json must decode to a JSON object; skipping metadata stamp.\")\n return \"\"\n return json.dumps(decoded, sort_keys=True)\n\n async def build_kb_info(self) -> Data:\n \"\"\"Main ingestion routine → returns a dict with KB metadata.\n\n The annotation is intentionally narrowed to ``Data`` even though the\n cross-mode fallback below may return a ``DataFrame`` from\n ``retrieve_data``. The frontend builds React-Flow handle IDs from\n this output's type list; widening it to ``Data | DataFrame`` makes\n the API advertise ``[\"JSON\", \"Table\"]`` for the ingest output and\n breaks every saved-edge sourceHandle that was generated against\n a single-type handle (BUG-02). Python doesn't enforce return\n annotations at runtime, so the rare fallback path keeps working.\n \"\"\"\n if _is_retrieve_mode(getattr(self, \"mode\", MODE_INGEST)):\n return await self.retrieve_data()\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n run_id: uuid.UUID | None = None\n run_job_id: uuid.UUID | None = None\n run_summary: IngestionSummary | None = None\n run_status: IngestionRunStatus = IngestionRunStatus.SUCCEEDED\n run_error: str | None = None\n kb_record_id: uuid.UUID | None = None\n try:\n input_value = self.input_df[0] if isinstance(self.input_df, list) else self.input_df\n df_source: DataFrame = convert_to_dataframe(input_value, auto_parse=False)\n\n config_list = self._validate_column_config(df_source)\n column_metadata = self._build_column_metadata(config_list, df_source)\n\n kb_path = await self._kb_path()\n if not kb_path:\n msg = \"Knowledge base path is not set. Please create a new knowledge base first.\"\n raise ValueError(msg)\n metadata_path = kb_path / \"embedding_metadata.json\"\n api_key = None\n model_selection = None\n\n if metadata_path.exists():\n settings_service = get_settings_service()\n stored_metadata = json.loads(metadata_path.read_text())\n\n model_selection = stored_metadata.get(\"model_selection\")\n if model_selection:\n model_selection = [model_selection] if isinstance(model_selection, dict) else model_selection\n else:\n embedding_model_name = stored_metadata.get(\"embedding_model\")\n embedding_provider = stored_metadata.get(\"embedding_provider\", \"Unknown\")\n if embedding_model_name:\n try:\n all_options = get_embedding_model_options(user_id=self.user_id)\n match = next(\n (o for o in all_options if o.get(\"name\") == embedding_model_name),\n None,\n )\n if match:\n model_selection = [match]\n else:\n self.log(\n f\"Embedding model '{embedding_model_name}' (provider: {embedding_provider}) \"\n \"from stored metadata is no longer available in the model registry. \"\n \"Please re-create this knowledge base with a supported embedding model.\"\n )\n msg = (\n f\"Embedding model '{embedding_model_name}' is no longer recognized. \"\n \"The knowledge base was created with an older format and the model \"\n \"is not available in the current registry. \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg)\n except ValueError:\n raise\n except Exception: # noqa: BLE001\n self.log(\n f\"Failed to look up embedding model '{embedding_model_name}' in registry. \"\n \"Please re-create this knowledge base with a supported embedding model.\"\n )\n msg = (\n f\"Could not look up embedding model '{embedding_model_name}' \"\n f\"(provider: {embedding_provider}). \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg) # noqa: B904\n\n encrypted_key = stored_metadata.get(\"api_key\")\n if encrypted_key:\n try:\n api_key = decrypt_api_key(encrypted_key, settings_service)\n except (InvalidToken, TypeError, ValueError) as e:\n if not self.api_key:\n log_label = f\"knowledge base '{self.knowledge_base}'\"\n msg = (\n f\"Cannot decrypt the stored embedding API key for {log_label}. \"\n \"This usually means the server's SECRET_KEY changed after the \"\n \"key was saved. To recover, supply the embedding provider API \"\n \"key on the component's 'Embedding Provider API Key' input and \"\n \"re-run ingestion — the key will be re-encrypted with the \"\n \"current SECRET_KEY.\"\n )\n raise KBKeyDecryptError(msg) from e\n logger.warning(\"Stored API key undecryptable; using component-supplied key. Error: %s\", e)\n\n if self.api_key:\n api_key = self.api_key\n if model_selection:\n self._save_embedding_metadata(\n kb_path=kb_path,\n model_selection=model_selection,\n api_key=api_key,\n )\n\n if not model_selection:\n msg = \"No embedding model configuration found. Please create the knowledge base first.\"\n raise ValueError(msg)\n\n embedding_function = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n api_key=api_key,\n chunk_size=self.chunk_size,\n )\n\n run_id, run_job_id, run_summary, kb_record_id = await self._begin_ingestion_run(kb_path)\n if kb_record_id is not None:\n await self._record_kb_status(kb_record_id, \"ingesting\")\n\n backend = await self._create_vector_store(df_source, config_list, embedding_function=embedding_function)\n\n self._save_kb_files(kb_path, config_list)\n\n try:\n if not isinstance(backend, BaseVectorStoreBackend):\n pass\n elif backend.backend_type == BackendType.CHROMA and hasattr(backend, \"raw_langchain_store\"):\n self._update_metadata_metrics(kb_path, backend.raw_langchain_store())\n else:\n await self._update_backend_metadata_metrics(kb_path, backend)\n # Stamp the KB with the file extensions we just ingested so\n # the Knowledge Bases list renders the correct icon for\n # flow-driven ingestion (input_df), matching direct upload.\n # We look at the raw input first because ``convert_to_dataframe``\n # drops Message/Data metadata fields (file_path, mimetype, …)\n # when projecting onto the DataFrame.\n source_types = self._extract_source_types_from_input(input_value)\n source_types |= self._extract_source_types_from_df(df_source)\n self._merge_source_types(kb_path, source_types)\n finally:\n if isinstance(backend, BaseVectorStoreBackend):\n await backend.teardown()\n\n meta: dict[str, Any] = {\n \"kb_id\": str(uuid.uuid4()),\n \"kb_name\": self.knowledge_base,\n \"rows\": len(df_source),\n \"column_metadata\": column_metadata,\n \"path\": str(kb_path),\n \"config_columns\": len(config_list),\n \"timestamp\": datetime.now(tz=timezone.utc).isoformat(),\n }\n\n if run_summary is not None:\n run_summary.record_item(\n IngestionItemResult(\n item_id=self.knowledge_base,\n display_name=f\"{self.knowledge_base} ({len(df_source)} rows)\",\n status=IngestionItemStatus.SUCCEEDED,\n chunks_created=len(df_source),\n ),\n )\n\n if kb_record_id is not None:\n await self._record_kb_stats(kb_record_id, kb_path)\n await self._record_kb_status(kb_record_id, \"ready\")\n\n self.status = f\"✅ KB **{self.knowledge_base}** saved · {len(df_source)} chunks.\"\n\n return Data(data=meta)\n\n except (OSError, ValueError, RuntimeError, KeyError) as e:\n run_status = IngestionRunStatus.FAILED\n run_error = str(e) or e.__class__.__name__\n msg = f\"Error during KB ingestion: {e}\"\n raise RuntimeError(msg) from e\n except Exception as e:\n run_status = IngestionRunStatus.FAILED\n run_error = str(e) or e.__class__.__name__\n raise\n finally:\n if run_id is not None and run_summary is not None:\n await self._finalize_ingestion_run(\n run_id=run_id,\n job_id=run_job_id,\n summary=run_summary,\n status=run_status,\n error_message=run_error,\n )\n if kb_record_id is not None and run_status is IngestionRunStatus.FAILED:\n await self._record_kb_status(kb_record_id, \"failed\", failure_reason=run_error)\n\n async def _begin_ingestion_run(\n self,\n kb_path: Path,\n ) -> tuple[uuid.UUID | None, uuid.UUID | None, IngestionSummary | None, uuid.UUID | None]:\n \"\"\"Create a parent ``Job`` and seed an ingestion-run row.\"\"\"\n if not self.user_id:\n self.log(\"No user_id on component; skipping ingestion-run tracking.\")\n return None, None, None, None\n\n try:\n user_uuid = uuid.UUID(str(self.user_id))\n except (ValueError, TypeError) as exc:\n self.log(f\"Could not coerce user_id={self.user_id!r} to UUID; skipping run tracking ({exc}).\")\n return None, None, None, None\n\n try:\n from langflow.api.utils import ingestion_run_service, knowledge_base_service\n from langflow.services.database.models.jobs.model import JobStatus, JobType\n from langflow.services.deps import get_job_service\n except ImportError as exc:\n self.log(f\"Run-history wiring unavailable; ingestion will not be recorded ({exc}).\")\n return None, None, None, None\n\n try:\n kb_record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)\n kb_record_id = kb_record.id if kb_record is not None else None\n\n user_metadata = self._parse_user_metadata_dict()\n\n job_id = uuid.uuid4()\n job_service = get_job_service()\n raw_flow_id = getattr(self, \"flow_id\", None)\n flow_id_uuid = uuid.UUID(str(raw_flow_id)) if raw_flow_id else job_id\n await job_service.create_job(\n job_id=job_id,\n flow_id=flow_id_uuid,\n job_type=JobType.INGESTION,\n asset_id=kb_record_id,\n asset_type=\"knowledge_base\",\n user_id=user_uuid,\n )\n await job_service.update_job_status(job_id, JobStatus.IN_PROGRESS)\n\n source = FlowComponentSource(\n user_id=user_uuid,\n source_config={\n \"knowledge_base\": self.knowledge_base,\n \"kb_path\": str(kb_path),\n \"flow_id\": str(flow_id_uuid),\n },\n )\n\n run_id = await ingestion_run_service.create_run(\n kb_name=self.knowledge_base,\n source=source,\n job_id=job_id,\n user_id=user_uuid,\n kb_id=kb_record_id,\n user_metadata=user_metadata,\n )\n if run_id is None:\n return None, job_id, None, kb_record_id\n await ingestion_run_service.mark_running(run_id)\n\n summary = IngestionSummary(\n kb_name=self.knowledge_base,\n source_type=source.source_type.value,\n user_id=user_uuid,\n job_id=job_id,\n source_config=source.describe().get(\"config\") or {},\n user_metadata=user_metadata,\n )\n self.log(f\"Started ingestion run job_id={job_id} kb_name={self.knowledge_base} kb_id={kb_record_id}\")\n except Exception as exc: # noqa: BLE001 — telemetry must never abort ingestion\n self.log(f\"Could not begin ingestion-run tracking: {exc}\")\n return None, None, None, None\n else:\n return run_id, job_id, summary, kb_record_id\n\n async def _finalize_ingestion_run(\n self,\n *,\n run_id: uuid.UUID,\n job_id: uuid.UUID | None,\n summary: IngestionSummary,\n status: IngestionRunStatus,\n error_message: str | None,\n ) -> None:\n \"\"\"Persist the final summary and transition the parent Job.\"\"\"\n try:\n from langflow.api.utils import ingestion_run_service\n from langflow.services.database.models.jobs.model import JobStatus\n from langflow.services.deps import get_job_service\n except ImportError as exc:\n self.log(f\"Run-history wiring unavailable; ingestion-run finalize skipped ({exc}).\")\n return\n\n try:\n await ingestion_run_service.finalize_run(\n run_id,\n summary=summary,\n status=status,\n error_message=error_message,\n )\n if job_id is not None:\n terminal_status = JobStatus.COMPLETED if status is not IngestionRunStatus.FAILED else JobStatus.FAILED\n await get_job_service().update_job_status(job_id, terminal_status, finished_timestamp=True)\n except Exception as exc: # noqa: BLE001 — telemetry must never re-raise\n self.log(f\"Could not finalize ingestion-run tracking: {exc}\")\n\n async def _record_kb_status(\n self,\n kb_record_id: uuid.UUID,\n status_value: str,\n *,\n failure_reason: str | None = None,\n ) -> None:\n \"\"\"Mirror Path A's KB-row status transitions.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n from langflow.services.database.models.knowledge_base.model import KnowledgeBaseStatus\n except ImportError:\n return\n try:\n await knowledge_base_service.update_status(\n kb_record_id,\n status=KnowledgeBaseStatus(status_value),\n failure_reason=failure_reason,\n )\n except Exception as exc: # noqa: BLE001\n self.log(f\"Could not update KB status to {status_value}: {exc}\")\n\n async def _record_kb_stats(self, kb_record_id: uuid.UUID, kb_path: Path) -> None:\n \"\"\"Push freshly-refreshed metrics from embedding_metadata.json onto the DB row.\"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n except ImportError:\n self.log(\"knowledge_base_service unavailable; KB stats will not sync to DB row.\")\n return\n metadata_path = kb_path / \"embedding_metadata.json\"\n if not metadata_path.exists():\n self.log(f\"No embedding_metadata.json at {metadata_path}; skipping KB stats sync.\")\n return\n try:\n metadata = json.loads(metadata_path.read_text())\n except (OSError, json.JSONDecodeError) as exc:\n self.log(f\"Could not read KB metadata for stats sync: {exc}\")\n return\n chunks = int(metadata.get(\"chunks\", 0) or 0)\n words = int(metadata.get(\"words\", 0) or 0)\n characters = int(metadata.get(\"characters\", 0) or 0)\n try:\n await knowledge_base_service.update_stats(\n kb_record_id,\n chunks=chunks,\n words=words,\n characters=characters,\n size_bytes=int(metadata.get(\"size\", 0) or 0),\n source_types=list(metadata.get(\"source_types\") or []),\n chunk_size=metadata.get(\"chunk_size\"),\n chunk_overlap=metadata.get(\"chunk_overlap\"),\n separator=metadata.get(\"separator\"),\n )\n self.log(f\"Synced KB stats to DB row {kb_record_id}: chunks={chunks} words={words} characters={characters}\")\n except Exception as exc: # noqa: BLE001\n self.log(f\"Could not sync KB stats to DB row {kb_record_id}: {exc}\")\n\n def _parse_user_metadata_dict(self) -> dict[str, Any]:\n \"\"\"Decode ``metadata_json`` to a dict, or ``{}`` on any error.\"\"\"\n raw = getattr(self, \"metadata_json\", None)\n if not raw:\n return {}\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return {}\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError):\n return {}\n return decoded if isinstance(decoded, dict) else {}\n\n # =====================================================================\n # RETRIEVAL CODE PATH\n # =====================================================================\n @property\n def _user_uuid(self) -> uuid.UUID | None:\n \"\"\"Return self.user_id as a UUID, converting from str if necessary.\"\"\"\n if not self.user_id:\n return None\n return self.user_id if isinstance(self.user_id, uuid.UUID) else uuid.UUID(self.user_id)\n\n def _get_kb_metadata(self, kb_path: Path, *, require_api_key: bool = False) -> dict:\n \"\"\"Load the knowledge base's embedding metadata file.\"\"\"\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n return load_kb_metadata(\n kb_path,\n log_label=f\"knowledge base '{self.knowledge_base}'\",\n require_api_key=require_api_key,\n )\n\n async def _backend_from_record(self) -> tuple[str, dict[str, Any]] | None:\n \"\"\"Read ``(backend_type, backend_config)`` off the KB's database row.\n\n Returns ``None`` when there is no row to read — a legacy KB predating the\n record, or bare lfx with no langflow installed. A lookup that *fails* is a\n different situation and is allowed to propagate: guessing a backend after a\n database error is how vectors end up in a store nothing queries.\n \"\"\"\n try:\n from langflow.api.utils import knowledge_base_service\n except ImportError:\n return None\n\n user_uuid = self._user_uuid\n if user_uuid is None:\n return None\n record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)\n if record is None:\n return None\n return record.backend_type or BackendType.CHROMA.value, record.backend_config or {}\n\n async def _resolve_backend_config(self, kb_path: Path) -> tuple[str, dict[str, Any]]:\n \"\"\"Resolve this KB's backend — database row first, on-disk sidecar second.\n\n Ingestion used to read the sidecar while retrieval read the row. Across\n replicas those disagree: a pod without the sidecar fell back to local\n Chroma and wrote vectors there, while queries followed the row to the\n configured remote backend and found nothing — silently. Both paths resolve\n here now, and a backend that cannot be resolved raises rather than\n defaulting to local storage.\n \"\"\"\n resolved = await self._backend_from_record()\n if resolved is None:\n resolved = self._get_backend_from_metadata(kb_path)\n if resolved is None:\n msg = (\n f\"Cannot determine the vector-store backend for knowledge base \"\n f\"'{self.knowledge_base}': it has no database record and no readable \"\n f\"embedding metadata. Refusing to fall back to local storage, which \"\n f\"would write to a different store than queries read from.\"\n )\n raise ValueError(msg)\n return resolved\n\n def _resolve_model_selection(self, metadata: dict[str, Any]) -> list[dict[str, Any]]:\n \"\"\"Resolve the ``get_embeddings``-compatible model selection from metadata.\"\"\"\n model_selection = metadata.get(\"model_selection\")\n if model_selection:\n selection_list = [model_selection] if isinstance(model_selection, dict) else list(model_selection)\n return [self._hydrate_model_metadata(entry) for entry in selection_list]\n\n embedding_model_name = metadata.get(\"embedding_model\")\n embedding_provider = metadata.get(\"embedding_provider\", \"Unknown\")\n if not embedding_model_name:\n msg = (\n f\"Knowledge base '{self.knowledge_base}' has no embedding model recorded; \"\n \"re-create it with a supported embedding model.\"\n )\n raise ValueError(msg)\n\n match = self._find_catalog_entry(embedding_model_name)\n if match is None:\n msg = (\n f\"Embedding model '{embedding_model_name}' (provider '{embedding_provider}') \"\n \"recorded for this knowledge base is no longer available in the model registry. \"\n \"Please re-create the knowledge base with a supported embedding model.\"\n )\n raise ValueError(msg)\n return [match]\n\n def _hydrate_model_metadata(self, entry: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Fill in ``metadata.embedding_class`` / ``param_mapping`` if missing.\"\"\"\n entry_metadata = entry.get(\"metadata\") or {}\n has_class = bool(entry_metadata.get(\"embedding_class\"))\n has_mapping = bool(entry_metadata.get(\"param_mapping\"))\n if has_class and has_mapping:\n return entry\n\n model_name = entry.get(\"name\")\n if not model_name:\n return entry\n\n catalog_entry = self._find_catalog_entry(model_name)\n if catalog_entry is None:\n return entry\n\n catalog_metadata = catalog_entry.get(\"metadata\") or {}\n merged_metadata = {**catalog_metadata, **entry_metadata}\n return {**entry, \"metadata\": merged_metadata}\n\n def _find_catalog_entry(self, model_name: str) -> dict[str, Any] | None:\n \"\"\"Look up an embedding model by name in the unified-models catalog.\"\"\"\n options = get_embedding_model_options(user_id=self.user_id)\n return next((o for o in options if o.get(\"name\") == model_name), None)\n\n async def retrieve_data(self) -> DataFrame:\n \"\"\"Retrieve data from the selected knowledge base.\n\n Annotation narrowed to ``DataFrame`` to keep this output's handle\n type list at ``[\"Table\"]`` only; widening to a union surfaces\n ``[\"Table\", \"JSON\"]`` on the API and breaks every starter-project\n edge whose sourceHandle was stored against a single-type handle\n (BUG-02). The cross-mode defensive fallback may still return a\n ``Data`` at runtime — Python ignores return annotations, so\n nothing breaks.\n \"\"\"\n if not _is_retrieve_mode(getattr(self, \"mode\", MODE_INGEST)):\n return await self.build_kb_info()\n raise_error_if_astra_cloud_disable_component(astra_error_msg)\n\n # Lazy import: langflow's user/DB models aren't part of lfx's\n # standalone install, so ``lfx run <starter>.json`` can't resolve\n # this symbol at module import time. Deferring to use keeps the\n # component importable in both environments.\n from langflow.services.database.models.user.crud import get_user_by_id\n\n async with session_scope() as db:\n if not self.user_id:\n msg = \"User ID is required for fetching Knowledge Base data.\"\n raise ValueError(msg)\n current_user = await get_user_by_id(db, self.user_id)\n if not current_user:\n msg = f\"User with ID {self.user_id} not found.\"\n raise ValueError(msg)\n kb_user = current_user.username\n kb_path = self._resolve_kb_path(_get_knowledge_bases_root_path(), kb_user, self.knowledge_base)\n\n component_api_key = self.api_key if getattr(self, \"api_key\", None) else None\n needs_stored_key = not component_api_key\n metadata = self._get_kb_metadata(kb_path, require_api_key=needs_stored_key)\n if not metadata:\n msg = f\"Metadata not found for knowledge base: {self.knowledge_base}. Ensure it has been indexed.\"\n raise ValueError(msg)\n\n api_key = component_api_key or metadata.get(\"api_key\")\n model_selection = self._resolve_model_selection(metadata)\n chunk_size = metadata.get(\"chunk_size\")\n embedding_function = get_embeddings(\n model=model_selection,\n user_id=self.user_id,\n api_key=api_key,\n chunk_size=chunk_size,\n )\n\n backend_type, backend_config = await self._resolve_backend_config(kb_path)\n backend = create_backend(\n backend_type,\n kb_name=self.knowledge_base,\n kb_path=kb_path,\n backend_config=backend_config,\n embedding_function=embedding_function,\n user_id=self.user_id,\n )\n try:\n user_metadata_filter = _parse_metadata_filter(getattr(self, \"metadata_filter\", None))\n use_scores = bool(self.search_query)\n search_k = self.top_k * 4 if user_metadata_filter else self.top_k\n results = await backend.similarity_search(\n query=self.search_query or \"\",\n k=search_k,\n with_scores=use_scores,\n )\n if user_metadata_filter:\n results = [\n (doc, score) for doc, score in results if _chunk_matches_filter(doc.metadata, user_metadata_filter)\n ]\n results = results[: self.top_k]\n\n embeddings_by_key: dict[tuple[str, str], list[float]] = {}\n if self.include_embeddings and results:\n # Join each retrieved chunk to its stored embedding. We key on\n # ``_id`` when present and fall back to page content otherwise,\n # so KBs populated by direct file upload — whose chunks carry no\n # ``_id`` — still resolve. See ``_embedding_match_key``.\n wanted_keys = {_embedding_match_key(doc.page_content, doc.metadata) for doc, _score in results}\n async for batch in backend.iter_documents(include_embeddings=True):\n for entry in batch:\n if entry.embedding is None:\n continue\n key = _embedding_match_key(entry.content, entry.metadata)\n if key in wanted_keys:\n embeddings_by_key[key] = entry.embedding\n if len(embeddings_by_key) == len(wanted_keys):\n break\n\n data_list: list[Data] = []\n for doc, score in results:\n kwargs: dict[str, Any] = {\"content\": doc.page_content}\n if use_scores:\n kwargs[\"_score\"] = -1 * score\n if self.include_metadata:\n kwargs.update(doc.metadata)\n if self.include_embeddings:\n kwargs[\"_embeddings\"] = embeddings_by_key.get(_embedding_match_key(doc.page_content, doc.metadata))\n data_list.append(Data(**kwargs))\n\n return DataFrame(data=data_list)\n finally:\n await backend.teardown()\n\n\ndef _embedding_match_key(content: str, metadata: dict[str, Any] | None) -> tuple[str, str]:\n \"\"\"Build a stable key for aligning a retrieved chunk with its stored embedding.\n\n Embeddings are gathered separately (via ``iter_documents``) and then joined\n back onto the search results. Component-driven ingestion stamps a\n content-hash ``_id`` on every chunk, but direct file-upload ingestion\n (``KBIngestionHelper.perform_ingestion``) does not. Keying the join purely on\n ``_id`` therefore left every upload-populated KB with ``_embeddings: None``.\n\n We prefer ``_id`` when present (so legitimately distinct chunks that happen to\n share text aren't collapsed) and fall back to the chunk's page content\n otherwise. The content fallback is exact for the embedding use case: two\n chunks with identical text necessarily share the same embedding vector. The\n leading ``\"id\"`` / ``\"content\"`` tag namespaces the two key spaces so a mixed\n KB (some chunks with ``_id``, some without) never cross-matches.\n \"\"\"\n doc_id = metadata.get(\"_id\") if metadata else None\n if doc_id:\n return (\"id\", str(doc_id))\n return (\"content\", content or \"\")\n\n\ndef _parse_metadata_filter(raw: str | None) -> dict[str, list[str]]:\n \"\"\"Decode the ``metadata_filter`` input into a {key: [values]} map.\n\n Empty or malformed input maps to an empty filter so retrieval falls back\n to the unfiltered path. JSON errors are swallowed here rather than raised:\n surfacing component-config errors at the canvas node would break a flow\n run for what is meant to be an optional refinement.\n \"\"\"\n if not raw:\n return {}\n text = raw.strip() if isinstance(raw, str) else raw\n if not text:\n return {}\n try:\n decoded = json.loads(text)\n except (TypeError, json.JSONDecodeError):\n logger.warning(\"KnowledgeComponent: metadata_filter is not valid JSON; ignoring filter.\")\n return {}\n if not isinstance(decoded, dict):\n logger.warning(\"KnowledgeComponent: metadata_filter must be a JSON object; ignoring filter.\")\n return {}\n result: dict[str, list[str]] = {}\n for key, value in decoded.items():\n if not isinstance(key, str):\n continue\n if isinstance(value, list):\n result[key] = [str(entry) for entry in value]\n else:\n result[key] = [str(value)]\n return result\n\n\ndef _chunk_matches_filter(metadata: dict[str, Any] | None, filt: dict[str, list[str]]) -> bool:\n \"\"\"AND across keys, OR within key values, mirroring the chunks endpoint.\"\"\"\n if not filt:\n return True\n if not metadata:\n return False\n raw = metadata.get(\"source_metadata\")\n if not raw:\n return False\n try:\n stored = json.loads(raw) if isinstance(raw, str) else raw\n except json.JSONDecodeError:\n return False\n if not isinstance(stored, dict):\n return False\n for key, expected_values in filt.items():\n actual = stored.get(key)\n if actual is None:\n return False\n actual_set = {str(entry) for entry in actual} if isinstance(actual, list) else {str(actual)}\n if not actual_set & set(expected_values):\n return False\n return True\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not limit client-side metadata filtering to four times top_k.

When metadata_filter is present, retrieval fetches only top_k * 4 unfiltered results and filters them afterward. Matching documents ranked below that arbitrary window are omitted, producing incomplete or empty results despite valid matches. Pass the filter to the backend where supported; otherwise continue fetching until enough matches are found or the store is exhausted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/_assets/component_index.json` at line 8118, The retrieval
flow currently limits client-side metadata filtering to a fixed top_k
multiplier, which can omit valid lower-ranked matches. Update the retrieval
method that handles metadata_filter to pass the filter to backends supporting
native filtering; for unsupported backends, continue paginating or expanding
unfiltered fetches until top_k matches are collected or the store is exhausted.
Preserve existing result ordering and behavior when no filter is provided, using
_parse_metadata_filter and _chunk_matches_filter for client-side filtering.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid local Chroma lookups for Chroma Cloud ingestion.

"chroma_cloud" is normalized to BackendType.CHROMA.value, leaving existing_ids unset. _convert_df_to_data_objects() then constructs a local Chroma(persist_directory=...) to detect duplicates, even though writes go to Chroma Cloud. This can query the wrong store or fail on replicas. Populate duplicate IDs through the resolved backend, or explicitly distinguish local Chroma from Chroma Cloud.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/_assets/component_index.json` at line 8118, Update
_convert_df_to_data_objects so Chroma Cloud ingestion does not perform local
Chroma lookups for duplicate detection. Preserve the distinction between local
Chroma and Chroma Cloud after backend normalization, and obtain existing IDs
through the resolved backend when using Chroma Cloud. Ensure existing_ids is
populated before duplicate filtering without constructing Chroma with a local
persist_directory for cloud writes.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Resolve Knowledge embedding configuration from the database before requiring the sidecar.

retrieve_data() loads embedding_metadata.json and resolves the embedding model before calling the new database-backed backend resolver. On a replica with a database record but no local sidecar, retrieval still fails, so remote-backed Knowledge Bases are not actually replica-safe. Resolve model selection from the KB record first and retain the sidecar only as a legacy fallback or for required stored credentials.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/_assets/component_index.json` at line 8118, Update
KnowledgeComponent.retrieve_data() to resolve the embedding model and
configuration from the database-backed knowledge-base record before attempting
to load embedding_metadata.json or requiring the local sidecar. Preserve sidecar
loading only as a legacy fallback or when stored credentials are required, so
remote-backed knowledge bases retrieve successfully on replicas without local
sidecar files.

@@ -9625,7 +9617,7 @@
"show": true,
"title_case": false,
"type": "code",
"value": "\"\"\"Memory Base retrieval component.\n\nQueries the auto-provisioned Chroma KB backing a Memory Base, scoped to the\ncurrent flow's request session. Additional option to filter by session_id if the\ndeveloper wants to turn that on. The component will auto filter based on session_id then.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom typing import TYPE_CHECKING, Any\n\nimport chromadb\nimport chromadb.api.client\nimport numpy as np\nfrom langchain_chroma import Chroma\nfrom langflow.api.utils.kb_helpers import KBIngestionHelper\nfrom langflow.services.database.models.memory_base.model import MemoryBase\nfrom langflow.services.database.models.user.crud import get_user_by_id\nfrom langflow.services.memory_base.kb_path_helpers import hash_session_id, validate_kb_path\nfrom sqlmodel import select\n\nfrom lfx.base.vectorstores.chroma_security import chroma_langchain_collection_kwargs\nfrom lfx.components.files_and_knowledge._kb_paths import (\n get_knowledge_bases_root_path,\n load_kb_metadata,\n)\nfrom lfx.custom import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.services.deps import session_scope\n\nif TYPE_CHECKING:\n from pathlib import Path\n\n from langflow.services.database.models.user.model import User\n from sqlmodel.ext.asyncio.session import AsyncSession\n\n\ndef _coerce_uuid(value: Any) -> uuid.UUID | None:\n if value is None:\n return None\n if isinstance(value, uuid.UUID):\n return value\n try:\n return uuid.UUID(str(value))\n except (ValueError, TypeError):\n return None\n\n\ndef _distance_to_similarity(distance: float) -> float:\n \"\"\"Chroma returns a distance; flip the sign so larger == more similar.\"\"\"\n return -1 * distance\n\n\ndef _to_python_scalar(value: Any) -> Any:\n \"\"\"Convert numpy scalars (int64, float64, bool_, …) to Python primitives.\n\n Chroma persists integer/float metadata as numpy scalars, which break JSON\n serialization when this component is consumed as an Agent tool — LangChain's\n tool-output path calls ``vars()`` / iterates the value, both of which fail\n on numpy C-extension scalars. Coerce at the boundary so downstream stays\n primitive-only.\n \"\"\"\n if isinstance(value, np.generic):\n return value.item()\n return value\n\n\nclass MemoryBaseComponent(Component):\n display_name = \"Memory Base\"\n description = (\n \"Retrieve long-term memory from a Memory Base attached to this workflow. \"\n \"When 'Filter by Session' is off, queries run across all sessions.\"\n )\n icon = \"brain\"\n name = \"MemoryBase\"\n\n inputs = [\n DropdownInput(\n name=\"memory_base\",\n display_name=\"Memory Base\",\n info=\"Memory Base whose captured conversation history will be searched.\",\n required=True,\n options=[],\n refresh_button=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"search_query\",\n display_name=\"Search Query\",\n info=\"Query used for semantic retrieval against the memory base.\",\n tool_mode=True,\n ),\n IntInput(\n name=\"top_k\",\n display_name=\"Top K Results\",\n info=\"Number of top results to return.\",\n value=5,\n advanced=True,\n required=False,\n ),\n BoolInput(\n name=\"include_metadata\",\n display_name=\"Include Metadata\",\n info=\"Include chunk metadata (session_id, sender, timestamp, …) on each row.\",\n value=True,\n advanced=True,\n ),\n BoolInput(\n name=\"filter_by_session\",\n display_name=\"Filter by Session\",\n info=(\n \"If enabled, only memories from the current session will be retrieved. \"\n \"Disable to allow retrieval across every session ingested into this \"\n \"Memory Base (useful for cross-conversation recall).\"\n ),\n value=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(\n name=\"retrieve_data\",\n display_name=\"Results\",\n method=\"retrieve_memory\",\n info=(\n \"Returns matching memory chunks. Scoped to the current session by \"\n \"default; turn 'Filter by Session' off to retrieve across sessions.\"\n ),\n ),\n ]\n\n def _build_where_clause(self, *, session_id: str | None = None) -> dict | None:\n \"\"\"Compose the Chroma ``where`` clause based on opt-in filters and manual params.\n\n Uses the canonical ``$eq`` operator form rather than the implicit\n ``{\"key\": \"value\"}`` shorthand. Both are accepted by chromadb, but the\n explicit form is unambiguous across versions and tooling.\n \"\"\"\n predicates: list[dict] = []\n # Defensive bool() — BoolInput coerces strings, but if this attribute is\n # ever overridden externally with a non-bool value, ``\"false\"`` would be\n # truthy and silently disable the toggle.\n if bool(self.filter_by_session) and session_id:\n predicates.append({\"session_id\": {\"$eq\": str(session_id)}})\n\n if not predicates:\n return None\n if len(predicates) == 1:\n return predicates[0]\n return {\"$and\": predicates}\n\n async def update_build_config(self, build_config, field_value, field_name=None): # noqa: ARG002\n if field_name != \"memory_base\":\n return build_config\n\n flow_id = _coerce_uuid(self._get_runtime_or_frontend_node_attr(\"flow_id\"))\n user_uuid = _coerce_uuid(self.user_id)\n if flow_id is None or user_uuid is None:\n build_config[\"memory_base\"][\"options\"] = []\n build_config[\"memory_base\"][\"value\"] = None\n return build_config\n\n # At design time self.user_id == the flow developer == MB owner, so this\n # filters to the same set a Flow-lookup would return but without relying\n # on the Flow row being persisted yet.\n async with session_scope() as db:\n stmt = select(MemoryBase).where(\n MemoryBase.flow_id == flow_id,\n MemoryBase.user_id == user_uuid,\n )\n mbs = list((await db.exec(stmt)).all())\n\n options = sorted(mb.name for mb in mbs)\n build_config[\"memory_base\"][\"options\"] = options\n if build_config[\"memory_base\"].get(\"value\") not in options:\n build_config[\"memory_base\"][\"value\"] = None\n return build_config\n\n async def _resolve_attached_mb(\n self,\n db: AsyncSession,\n selected: str,\n flow_id: uuid.UUID,\n ) -> tuple[MemoryBase, User]:\n \"\"\"Look up the MB row scoped to the current flow and resolve its owner.\"\"\"\n mb = (\n await db.exec(\n select(MemoryBase).where(\n MemoryBase.name == selected,\n MemoryBase.flow_id == flow_id,\n )\n )\n ).first()\n if mb is None:\n msg = f\"Memory Base '{selected}' is not attached to this flow.\"\n raise ValueError(msg)\n\n owner = await get_user_by_id(db, mb.user_id)\n if owner is None:\n msg = \"Memory Base owner account could not be resolved.\"\n raise ValueError(msg)\n return mb, owner\n\n def _resolve_kb_location(self, owner_username: str, kb_name: str) -> Path:\n \"\"\"Build and validate the on-disk KB path for the given owner.\"\"\"\n kb_root = get_knowledge_bases_root_path()\n kb_path = kb_root / owner_username / kb_name\n try:\n validate_kb_path(kb_root, kb_path)\n except ValueError as exc:\n msg = \"Memory Base path is not accessible.\"\n raise ValueError(msg) from exc\n return kb_path\n\n async def _build_chroma(\n self,\n kb_path: Path,\n owner: User,\n metadata: dict,\n kb_name: str,\n ) -> Chroma:\n \"\"\"Construct a Chroma client wired to the KB's embedding function.\"\"\"\n provider = metadata.get(\"embedding_provider\")\n model = metadata.get(\"embedding_model\")\n embedding_function = await KBIngestionHelper.build_embeddings(provider, model, owner)\n\n chromadb.api.client.SharedSystemClient.clear_system_cache()\n return Chroma(\n persist_directory=str(kb_path),\n embedding_function=embedding_function,\n collection_name=kb_name,\n **chroma_langchain_collection_kwargs(),\n )\n\n def _format_results(self, results: list[tuple]) -> DataFrame:\n \"\"\"Convert Chroma (doc, score) tuples into the component's DataFrame output.\n\n Metadata values are coerced from numpy scalars to Python primitives so the\n resulting DataFrame is JSON-serializable when the component is invoked as\n an Agent tool.\n \"\"\"\n data_list: list[Data] = []\n for doc, score in results:\n kwargs: dict = {\"content\": doc.page_content}\n if self.search_query:\n kwargs[\"_score\"] = _to_python_scalar(_distance_to_similarity(score))\n if self.include_metadata:\n for key, value in (doc.metadata or {}).items():\n kwargs[key] = _to_python_scalar(value)\n data_list.append(Data(**kwargs))\n return DataFrame(data=data_list)\n\n async def retrieve_memory(self) -> DataFrame:\n \"\"\"Retrieve chunks from the selected Memory Base.\n\n Scoped to the current ``session_id`` when ``filter_by_session`` is true; when\n false, every chunk in the Memory Base is queryable so the agent can recall\n context from prior conversations across all sessions.\n \"\"\"\n session_id = getattr(self.graph, \"session_id\", None)\n if bool(self.filter_by_session) and not session_id:\n # Only required when filtering is on, since the value gates the where clause.\n msg = (\n \"A session_id is required on the flow request when 'Filter by Session' \"\n \"is enabled — disable the toggle to allow cross-session retrieval.\"\n )\n raise ValueError(msg)\n\n flow_id = _coerce_uuid(getattr(self.graph, \"flow_id\", None))\n if flow_id is None:\n msg = \"flow_id is not available on the graph context; Memory Base retrieval is unavailable.\"\n raise ValueError(msg)\n\n selected = self.memory_base\n if not selected:\n msg = \"No Memory Base is selected.\"\n raise ValueError(msg)\n\n async with session_scope() as db:\n mb, owner = await self._resolve_attached_mb(db, selected, flow_id)\n owner_username = owner.username\n kb_name = mb.kb_name\n\n kb_path = self._resolve_kb_location(owner_username, kb_name)\n metadata = load_kb_metadata(kb_path, log_label=f\"memory base '{selected}'\")\n if not metadata:\n msg = f\"Memory Base '{selected}' has no embedding metadata on disk.\"\n raise ValueError(msg)\n\n chroma = await self._build_chroma(kb_path, owner, metadata, kb_name)\n where = self._build_where_clause(session_id=session_id)\n\n logger.debug(\n \"MemoryBase retrieval mb=%s session_hash=%s where=%s top_k=%s\",\n selected,\n hash_session_id(session_id) if session_id else \"<none>\",\n where,\n self.top_k,\n )\n\n if not self.search_query:\n # Embedding providers may reject empty input; skip the round-trip entirely.\n return DataFrame(data=[])\n\n results = chroma.similarity_search_with_score(\n query=self.search_query,\n k=self.top_k,\n filter=where,\n )\n return self._format_results(results)\n"
"value": "\"\"\"Memory Base retrieval component.\n\nQueries the auto-provisioned Chroma KB backing a Memory Base, scoped to the\ncurrent flow's request session. Additional option to filter by session_id if the\ndeveloper wants to turn that on. The component will auto filter based on session_id then.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom typing import TYPE_CHECKING, Any\n\nimport numpy as np\nfrom langflow.api.utils.kb_helpers import KBIngestionHelper, resolve_backend_selection, resolve_embedding_selection\nfrom langflow.services.database.models.memory_base.model import MemoryBase\nfrom langflow.services.database.models.user.crud import get_user_by_id\nfrom langflow.services.memory_base.kb_path_helpers import hash_session_id, validate_kb_path\nfrom sqlmodel import select\n\nfrom lfx.base.knowledge_bases.backends import create_backend\nfrom lfx.components.files_and_knowledge._kb_paths import (\n get_knowledge_bases_root_path,\n)\nfrom lfx.custom import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageTextInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.services.deps import session_scope\n\nif TYPE_CHECKING:\n from pathlib import Path\n\n from langflow.services.database.models.user.model import User\n from sqlmodel.ext.asyncio.session import AsyncSession\n\n from lfx.base.knowledge_bases.backends.base import BaseVectorStoreBackend\n\n\ndef _coerce_uuid(value: Any) -> uuid.UUID | None:\n if value is None:\n return None\n if isinstance(value, uuid.UUID):\n return value\n try:\n return uuid.UUID(str(value))\n except (ValueError, TypeError):\n return None\n\n\ndef _distance_to_similarity(distance: float) -> float:\n \"\"\"Chroma returns a distance; flip the sign so larger == more similar.\"\"\"\n return -1 * distance\n\n\ndef _to_python_scalar(value: Any) -> Any:\n \"\"\"Convert numpy scalars (int64, float64, bool_, …) to Python primitives.\n\n Chroma persists integer/float metadata as numpy scalars, which break JSON\n serialization when this component is consumed as an Agent tool — LangChain's\n tool-output path calls ``vars()`` / iterates the value, both of which fail\n on numpy C-extension scalars. Coerce at the boundary so downstream stays\n primitive-only.\n \"\"\"\n if isinstance(value, np.generic):\n return value.item()\n return value\n\n\nclass MemoryBaseComponent(Component):\n display_name = \"Memory Base\"\n description = (\n \"Retrieve long-term memory from a Memory Base attached to this workflow. \"\n \"When 'Filter by Session' is off, queries run across all sessions.\"\n )\n icon = \"brain\"\n name = \"MemoryBase\"\n\n inputs = [\n DropdownInput(\n name=\"memory_base\",\n display_name=\"Memory Base\",\n info=\"Memory Base whose captured conversation history will be searched.\",\n required=True,\n options=[],\n refresh_button=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"search_query\",\n display_name=\"Search Query\",\n info=\"Query used for semantic retrieval against the memory base.\",\n tool_mode=True,\n ),\n IntInput(\n name=\"top_k\",\n display_name=\"Top K Results\",\n info=\"Number of top results to return.\",\n value=5,\n advanced=True,\n required=False,\n ),\n BoolInput(\n name=\"include_metadata\",\n display_name=\"Include Metadata\",\n info=\"Include chunk metadata (session_id, sender, timestamp, …) on each row.\",\n value=True,\n advanced=True,\n ),\n BoolInput(\n name=\"filter_by_session\",\n display_name=\"Filter by Session\",\n info=(\n \"If enabled, only memories from the current session will be retrieved. \"\n \"Disable to allow retrieval across every session ingested into this \"\n \"Memory Base (useful for cross-conversation recall).\"\n ),\n value=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(\n name=\"retrieve_data\",\n display_name=\"Results\",\n method=\"retrieve_memory\",\n info=(\n \"Returns matching memory chunks. Scoped to the current session by \"\n \"default; turn 'Filter by Session' off to retrieve across sessions.\"\n ),\n ),\n ]\n\n def _build_where_clause(self, *, session_id: str | None = None) -> dict | None:\n \"\"\"Compose the metadata filter based on opt-in filters and manual params.\n\n Emits a flat ``{key: value}`` dict, which is the contract every\n ``BaseVectorStoreBackend`` accepts: Chroma reads it as ``$eq`` shorthand\n and OpenSearch translates it into a bool query. Chroma's explicit\n ``{\"$eq\": ...}`` operator form is NOT portable — a remote backend would\n treat the operator dict as a literal value and silently match nothing.\n \"\"\"\n predicates: dict = {}\n # Defensive bool() — BoolInput coerces strings, but if this attribute is\n # ever overridden externally with a non-bool value, ``\"false\"`` would be\n # truthy and silently disable the toggle.\n if bool(self.filter_by_session) and session_id:\n predicates[\"session_id\"] = str(session_id)\n\n return predicates or None\n\n async def update_build_config(self, build_config, field_value, field_name=None): # noqa: ARG002\n if field_name != \"memory_base\":\n return build_config\n\n flow_id = _coerce_uuid(self._get_runtime_or_frontend_node_attr(\"flow_id\"))\n user_uuid = _coerce_uuid(self.user_id)\n if flow_id is None or user_uuid is None:\n build_config[\"memory_base\"][\"options\"] = []\n build_config[\"memory_base\"][\"value\"] = None\n return build_config\n\n # At design time self.user_id == the flow developer == MB owner, so this\n # filters to the same set a Flow-lookup would return but without relying\n # on the Flow row being persisted yet.\n async with session_scope() as db:\n stmt = select(MemoryBase).where(\n MemoryBase.flow_id == flow_id,\n MemoryBase.user_id == user_uuid,\n )\n mbs = list((await db.exec(stmt)).all())\n\n options = sorted(mb.name for mb in mbs)\n build_config[\"memory_base\"][\"options\"] = options\n if build_config[\"memory_base\"].get(\"value\") not in options:\n build_config[\"memory_base\"][\"value\"] = None\n return build_config\n\n async def _resolve_attached_mb(\n self,\n db: AsyncSession,\n selected: str,\n flow_id: uuid.UUID,\n ) -> tuple[MemoryBase, User]:\n \"\"\"Look up the MB row scoped to the current flow and resolve its owner.\"\"\"\n mb = (\n await db.exec(\n select(MemoryBase).where(\n MemoryBase.name == selected,\n MemoryBase.flow_id == flow_id,\n )\n )\n ).first()\n if mb is None:\n msg = f\"Memory Base '{selected}' is not attached to this flow.\"\n raise ValueError(msg)\n\n owner = await get_user_by_id(db, mb.user_id)\n if owner is None:\n msg = \"Memory Base owner account could not be resolved.\"\n raise ValueError(msg)\n return mb, owner\n\n def _resolve_kb_location(self, owner_username: str, kb_name: str) -> Path:\n \"\"\"Build and validate the on-disk KB path for the given owner.\"\"\"\n kb_root = get_knowledge_bases_root_path()\n kb_path = kb_root / owner_username / kb_name\n try:\n validate_kb_path(kb_root, kb_path)\n except ValueError as exc:\n msg = \"Memory Base path is not accessible.\"\n raise ValueError(msg) from exc\n return kb_path\n\n async def _build_backend(\n self,\n kb_path: Path,\n owner: User,\n kb_name: str,\n ) -> BaseVectorStoreBackend:\n \"\"\"Construct the KB's configured backend, wired to its embedding function.\n\n Both the embedding config and the backend are resolved from the\n ``knowledge_base`` row (sidecar only as a legacy fallback), so a Memory\n Base provisioned on OpenSearch or Chroma Cloud is queried there — and with\n the right embedding model — even on a replica whose local disk never held\n the KB directory or its ``embedding_metadata.json``.\n \"\"\"\n provider, model = await resolve_embedding_selection(user_id=owner.id, kb_name=kb_name, kb_path=kb_path)\n embedding_function = await KBIngestionHelper.build_embeddings(provider, model, owner)\n\n backend_type, backend_config = await resolve_backend_selection(\n user_id=owner.id, kb_name=kb_name, kb_path=kb_path\n )\n backend = create_backend(\n backend_type,\n kb_name=kb_name,\n kb_path=kb_path,\n backend_config=backend_config,\n embedding_function=embedding_function,\n user_id=owner.id,\n )\n await backend.ensure_ready()\n return backend\n\n def _format_results(self, results: list[tuple]) -> DataFrame:\n \"\"\"Convert Chroma (doc, score) tuples into the component's DataFrame output.\n\n Metadata values are coerced from numpy scalars to Python primitives so the\n resulting DataFrame is JSON-serializable when the component is invoked as\n an Agent tool.\n \"\"\"\n data_list: list[Data] = []\n for doc, score in results:\n kwargs: dict = {\"content\": doc.page_content}\n if self.search_query:\n kwargs[\"_score\"] = _to_python_scalar(_distance_to_similarity(score))\n if self.include_metadata:\n for key, value in (doc.metadata or {}).items():\n kwargs[key] = _to_python_scalar(value)\n data_list.append(Data(**kwargs))\n return DataFrame(data=data_list)\n\n async def retrieve_memory(self) -> DataFrame:\n \"\"\"Retrieve chunks from the selected Memory Base.\n\n Scoped to the current ``session_id`` when ``filter_by_session`` is true; when\n false, every chunk in the Memory Base is queryable so the agent can recall\n context from prior conversations across all sessions.\n \"\"\"\n session_id = getattr(self.graph, \"session_id\", None)\n if bool(self.filter_by_session) and not session_id:\n # Only required when filtering is on, since the value gates the where clause.\n msg = (\n \"A session_id is required on the flow request when 'Filter by Session' \"\n \"is enabled — disable the toggle to allow cross-session retrieval.\"\n )\n raise ValueError(msg)\n\n flow_id = _coerce_uuid(getattr(self.graph, \"flow_id\", None))\n if flow_id is None:\n msg = \"flow_id is not available on the graph context; Memory Base retrieval is unavailable.\"\n raise ValueError(msg)\n\n selected = self.memory_base\n if not selected:\n msg = \"No Memory Base is selected.\"\n raise ValueError(msg)\n\n async with session_scope() as db:\n mb, owner = await self._resolve_attached_mb(db, selected, flow_id)\n owner_username = owner.username\n kb_name = mb.kb_name\n\n # ``kb_path`` is still needed for a local-Chroma backend (that's where its\n # vectors live), but embedding + backend now resolve from the DB row, so a\n # missing on-disk ``embedding_metadata.json`` is no longer fatal — a\n # remote-backed Memory Base is fully queryable on a replica with no local\n # KB directory.\n kb_path = self._resolve_kb_location(owner_username, kb_name)\n\n where = self._build_where_clause(session_id=session_id)\n\n logger.debug(\n \"MemoryBase retrieval mb=%s session_hash=%s where=%s top_k=%s\",\n selected,\n hash_session_id(session_id) if session_id else \"<none>\",\n where,\n self.top_k,\n )\n\n if not self.search_query:\n # Embedding providers may reject empty input; skip the round-trip entirely.\n return DataFrame(data=[])\n\n backend = await self._build_backend(kb_path, owner, kb_name)\n try:\n results = await backend.similarity_search(\n query=self.search_query,\n k=self.top_k,\n filter=where,\n with_scores=True,\n )\n finally:\n await backend.teardown()\n return self._format_results(results)\n"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize boolean input before applying the session filter.

bool("false") evaluates to True, so serialized or externally overridden values can keep session filtering enabled when the user selected false. Normalize string values explicitly before both _build_where_clause() and retrieve_memory() evaluate filter_by_session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/_assets/component_index.json` at line 9620, Normalize
filter_by_session through a shared boolean conversion that recognizes string
values such as "false" as false, then use the normalized result in both
_build_where_clause() and retrieve_memory(). Ensure session_id validation and
where-clause construction follow the actual user-selected boolean state rather
than Python truthiness.

Comment on lines +1565 to +1607
async def _backend_from_record(self) -> tuple[str, dict[str, Any]] | None:
"""Read ``(backend_type, backend_config)`` off the KB's database row.

Returns ``None`` when there is no row to read — a legacy KB predating the
record, or bare lfx with no langflow installed. A lookup that *fails* is a
different situation and is allowed to propagate: guessing a backend after a
database error is how vectors end up in a store nothing queries.
"""
try:
from langflow.api.utils import knowledge_base_service
except ImportError:
return None

user_uuid = self._user_uuid
if user_uuid is None:
return BackendType.CHROMA.value, {}
record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)
except Exception as exc: # noqa: BLE001
logger.debug("KB record lookup failed: %s", exc)
return BackendType.CHROMA.value, {}

user_uuid = self._user_uuid
if user_uuid is None:
return None
record = await knowledge_base_service.get_by_user_and_name(user_uuid, self.knowledge_base)
if record is None:
return BackendType.CHROMA.value, {}
return (
record.backend_type or BackendType.CHROMA.value,
record.backend_config or {},
)
return None
return record.backend_type or BackendType.CHROMA.value, record.backend_config or {}

async def _resolve_backend_config(self, kb_path: Path) -> tuple[str, dict[str, Any]]:
"""Resolve this KB's backend — database row first, on-disk sidecar second.

Ingestion used to read the sidecar while retrieval read the row. Across
replicas those disagree: a pod without the sidecar fell back to local
Chroma and wrote vectors there, while queries followed the row to the
configured remote backend and found nothing — silently. Both paths resolve
here now, and a backend that cannot be resolved raises rather than
defaulting to local storage.
"""
resolved = await self._backend_from_record()
if resolved is None:
resolved = self._get_backend_from_metadata(kb_path)
if resolved is None:
msg = (
f"Cannot determine the vector-store backend for knowledge base "
f"'{self.knowledge_base}': it has no database record and no readable "
f"embedding metadata. Refusing to fall back to local storage, which "
f"would write to a different store than queries read from."
)
raise ValueError(msg)
return resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd _kb_paths.py src/lfx --exec cat -n {}

Repository: langflow-ai/langflow

Length of output: 5098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== knowledge.py outline matches =="
ast-grep outline src/lfx/src/lfx/components/files_and_knowledge/knowledge.py --match KnowledgeBase --view expanded || true

echo "== relevant knowledge.py sections =="
sed -n '1480,1730p' src/lfx/src/lfx/components/files_and_knowledge/knowledge.py

echo "== _get_kb_metadata definitions/usages =="
rg -n "def _get_kb_metadata|async def _get_kb_metadata|load_kb_metadata|_get_kb_metadata\\(" src/lfx/src/lfx/components/files_and_knowledge -S

echo "== backend_type/config in KB model/DB =="
rg -n "backend_type|backend_config|embedding_metadata|embedding_metadata\\.json|embed_model|embedding_model|model" src/lfx/src/lfx/components/files_and_knowledge -S

Repository: langflow-ai/langflow

Length of output: 38163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate KnowledgeBase model/schema files =="
fd -i 'knowledge_base' src/lfx/langflow src --type f -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh {}

echo "== inspect relevant memory_retrieval helpers =="
sed -n '180,260p' src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py

echo "== inspect model selection helpers in knowledge.py ingestion =="
sed -n '1160,1285p' src/lfx/src/lfx/components/files_and_knowledge/knowledge.py

echo "== check DB migration/stat fields around embedding/model/backend =="
rg -n "KnowledgeBase|knowledge_base|backend_type|backend_config|model_selection|embedding_model|embedding_metadata" src/lfx -S --glob '!**/__pycache__/**' | sed -n '1,220p'

Repository: langflow-ai/langflow

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== exact KnowledgeBase model files =="
git ls-files | rg 'services/database/models/knowledge_base|knowledge_base.*model|knowledge_base.*\.py' | grep -E 'src/lfx/(langflow|src)/langflow' | sed -n '1,80p'

echo "== KnowledgeBase model and update_stats fields =="
for f in $(git ls-files | rg 'src/lfx/langflow/services/database/models/knowledge_base/model|src/lfx/langflow/services/database/models/knowledge_base.*\.py'); do
  echo "--- ${f#"$PWD"/}"
  rg -n 'class KnowledgeBase|backend_type|backend_config|model_selection|embedding_model|embedding_metadata|chunks|update_stats|stats|model_config|metadata' "$f" -A 10 -B 5
done

echo "== focused knowledge.py model selection ingestion =="
sed -n '1185,1285p' src/lfx/src/lfx/components/files_and_knowledge/knowledge.py

Repository: langflow-ai/langflow

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository knowledge base files =="
git ls-files | rg 'knowledge_base' | sed -n '1,120p'

echo "== KnowledgeBase model and update_stats fields =="
while IFS= read -r f; do
  echo "--- ${f}"
  rg -n 'class KnowledgeBase|backend_type|backend_config|model_selection|embedding_model|embedding_metadata|chunks|update_stats|stats|model_config|metadata' "$f" -A 10 -B 5 || true
done < <(git ls-files | rg 'knowledge_base')

Repository: langflow-ai/langflow

Length of output: 3217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== knowledge base model/schema/service fields =="
for f in \
  src/backend/base/langflow/services/database/models/knowledge_base/model.py \
  src/backend/base/langflow/schema/knowledge_base.py
do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n 'backend_type|backend_config|model_selection|embedding_model|embedding_metadata|chunks|words|characters|size_bytes|update_stats|Metadata|Pydantic|SQLModel|Table|BaseModel' "$f" -A 12 -B 6 || true
  fi
done

echo "== knowledge_base_service relevant definitions =="
if [ -f src/backend/base/langflow/api/utils/knowledge_base_service.py ]; then
  echo "--- src/backend/base/langflow/api/utils/knowledge_base_service.py"
  rg -n 'get_by_user_and_name|update_stats|KnowledgeBase|backend_type|backend_config|chunks|words|characters|size_bytes|model_selection|embedding_model|metadata|JSON' src/backend/base/langflow/api/utils/knowledge_base_service.py -A 20 -B 8 || true
fi

echo "== lfx component paths relevant imports for backend model =="
rg -n 'class Backend|backend_type|backend_config|BackendType|create_backend' src/lfx/src/lfx/base/knowledge_bases -S -A 8 -B 6 || true

Repository: langflow-ai/langflow

Length of output: 50377


Make embedding-model resolution DB-first like backend resolution.

load_kb_metadata only reads embedding_metadata.json from the KB directory, so retrieve_data can fail on a replica that has the KB row but no local sidecar. Use read_metadata/get_by_user_and_name for model_selection, chunk_size, chunk_overlap, and separator, falling back to disk only for legacy rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/components/files_and_knowledge/knowledge.py` around lines
1565 - 1607, Update retrieve_data’s embedding-model metadata resolution to query
the database record via knowledge_base_service.get_by_user_and_name first,
reusing read_metadata where appropriate for model_selection, chunk_size,
chunk_overlap, and separator. If no record exists, preserve the existing
embedding_metadata.json sidecar fallback for legacy knowledge bases, but do not
require local metadata when a database row is available.

Comment on lines +216 to +245
async def _build_backend(
self,
kb_path: Path,
owner: User,
metadata: dict,
kb_name: str,
) -> Chroma:
"""Construct a Chroma client wired to the KB's embedding function."""
provider = metadata.get("embedding_provider")
model = metadata.get("embedding_model")
) -> BaseVectorStoreBackend:
"""Construct the KB's configured backend, wired to its embedding function.

Both the embedding config and the backend are resolved from the
``knowledge_base`` row (sidecar only as a legacy fallback), so a Memory
Base provisioned on OpenSearch or Chroma Cloud is queried there — and with
the right embedding model — even on a replica whose local disk never held
the KB directory or its ``embedding_metadata.json``.
"""
provider, model = await resolve_embedding_selection(user_id=owner.id, kb_name=kb_name, kb_path=kb_path)
embedding_function = await KBIngestionHelper.build_embeddings(provider, model, owner)

chromadb.api.client.SharedSystemClient.clear_system_cache()
return Chroma(
persist_directory=str(kb_path),
backend_type, backend_config = await resolve_backend_selection(
user_id=owner.id, kb_name=kb_name, kb_path=kb_path
)
backend = create_backend(
backend_type,
kb_name=kb_name,
kb_path=kb_path,
backend_config=backend_config,
embedding_function=embedding_function,
collection_name=kb_name,
**chroma_langchain_collection_kwargs(),
user_id=owner.id,
)
await backend.ensure_ready()
return backend

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Backend can leak if ensure_ready() fails after construction.

create_backend(...) followed by await backend.ensure_ready() isn't wrapped in try/except; if ensure_ready() raises (e.g. a failed connection to a remote backend), the constructed backend is never returned to retrieve_memory, so backend.teardown() (called only in the caller's finally, at Line 326) is never reached for this partially-initialized instance. This mirrors the same pattern in the embedded copies of this component and in KnowledgeComponent._create_vector_store — see the consolidated comment.

async def similarity_search(...): await self.ensure_ready() ... confirms ensure_ready() can perform real connection setup that should be torn down on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py` around
lines 216 - 245, Update _build_backend to clean up the constructed backend when
await backend.ensure_ready() raises. Wrap the readiness call and return path
with exception handling that invokes backend.teardown() on failure, then
re-raises the original exception so retrieve_memory’s existing finally behavior
remains unchanged for successfully returned backends.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Backend construction/setup can leak resources on failure across multiple sites. All four sites create a vector-store backend and call its setup (ensure_ready(), and in the ingestion case iter_documents/add_documents) outside of any protection that guarantees teardown() runs if that step fails — the shared root cause is a missing try/except-with-teardown (or try/finally scoped around construction) at each site.

  • src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py#L216-L245: wrap create_backend(...) + await backend.ensure_ready() in _build_backend so a failure during ensure_ready() tears the partially-built backend down before re-raising.
  • src/backend/base/langflow/initial_setup/starter_projects/Memory Chatbot.json#L1628-L1628: this JSON embeds the identical _build_backend source; regenerate/re-sync this starter project's embedded component code once the source fix lands.
  • src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json#L2288-L2288: same embedded copy; regenerate once the source fix lands.
  • src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json#L1314-L1314: in the embedded KnowledgeComponent._create_vector_store, wrap create_backend/ensure_ready/iter_documents/add_documents in try/except that calls backend.teardown() on failure before re-raising, since build_kb_info's outer try/finally teardown only starts after this method already returns successfully.
📍 Affects 4 files
  • src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py#L216-L245 (this comment)
  • src/backend/base/langflow/initial_setup/starter_projects/Memory Chatbot.json#L1628-L1628
  • src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json#L2288-L2288
  • src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json#L1314-L1314
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py` around
lines 216 - 245, Backend setup failures can leave partially initialized vector
stores undisposed. In
src/lfx/src/lfx/components/files_and_knowledge/memory_retrieval.py#L216-L245,
update _build_backend to tear down the created backend if ensure_ready() fails,
then re-raise; regenerate or re-sync the identical embedded _build_backend
source in src/backend/base/langflow/initial_setup/starter_projects/Memory
Chatbot.json#L1628-L1628 and Price Deal Finder.json#L2288-L2288. In
src/backend/base/langflow/initial_setup/starter_projects/Vector Store
RAG.json#L1314-L1314, update KnowledgeComponent._create_vector_store to tear
down the backend on failure across create_backend, ensure_ready, iter_documents,
or add_documents before re-raising.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 47%
47.25% (67840/143552) 70.18% (9590/13664) 46.1% (1575/3416)

Unit Test Results

Tests Skipped Failures Errors Time
5397 0 💤 0 ❌ 0 🔥 23m 10s ⏱️

@dkaushik94 dkaushik94 changed the title (feat) Unified KBs and MBs to use common vector db backends (feat): Unified KBs and MBs to use common vector db backends Jul 24, 2026
@dkaushik94 dkaushik94 changed the title (feat): Unified KBs and MBs to use common vector db backends feat: Unified KBs and MBs to use common vector db backends Jul 24, 2026
@github-actions github-actions Bot added the enhancement New feature or request label Jul 24, 2026
@dkaushik94
dkaushik94 force-pushed the mb-refactor-with-kb branch from b06bf74 to c5a65c7 Compare July 27, 2026 15:57
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 27, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 27, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 27, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 27, 2026
@erichare
erichare self-requested a review July 28, 2026 14:39

@erichare erichare left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@dkaushik94 this is awesome. A few issues to address, could you take a look?

  • [P1] Isolate OpenSearch storage per Memory Base. Creation [copies the global backend configuration unchanged](

    // `chroma_cloud` collapses to `chroma` for the API; the server
    // discriminates local vs cloud via `backend_config.mode`.
    backend_type: toAPIBackendType(backendType),
    backend_config: backendConfig,
    ), including the single global [OPENSEARCH_INDEX_NAME](https://github.com/langflow-ai/langflow/blob/3829f4ac0f3df21cef7169f88afd60e1647c9b10/src/frontend/src/constants/dbProviderConstants.ts#L313-L318). The backend then [uses that index directly](
    self._os_index = index_name
    self._os_vector_field = vector_field
    self._os_text_field = text_field
    return OpenSearchVectorSearch(
    opensearch_url=url,
    index_name=index_name,
    embedding_function=self.embedding_function,
    ) without a kb_name namespace. Multiple Memory Bases therefore share vectors, allowing cross-MB retrieval and collection-level deletion. Derive a unique index per KB or enforce a KB namespace on every operation.

  • [P1] Translate Memory Retrieval filters for OpenSearch. _build_where_clause() emits {"session_id": ...}, but OpenSearchBackend forwards it unchanged as OpenSearch query DSL. LangChain expects a clause such as {"bool":{"must":[{"term":{"metadata.session_id":"..."}}]}}. Consequently, the default session-filtered retrieval path fails for OpenSearch-backed Memory Bases.

  • [P1] Delete remote vectors before dropping their configuration row. [MemoryBaseService.delete()](https://github.com/langflow-ai/langflow/blob/3829f4ac0f3df21cef7169f88afd60e1647c9b10/src/backend/base/langflow/services/memory_base/service.py#L299-L331) deletes the Memory Base and authoritative KB row, then only removes local disk storage. OpenSearch indexes and Chroma Cloud collections remain indefinitely, and the configuration needed to clean them up is gone. Resolve the backend and call delete_collection() before removing the rows.

  • [P1] Use the database Memory Base marker in bulk deletion. The PR explicitly [stops writing the sidecar](

    """Provision a Memory Base's vector-store collection.
    Memory Bases are DB-driven: their identity, embedding config, backend, and
    cached stats all live on the ``knowledge_base`` row (written by the caller via
    ``_create_kb_record_for_memory_base``). This function only touches the vector
    store — it no longer writes an on-disk ``embedding_metadata.json`` sidecar, so
    a Memory Base provisioned on a remote backend needs no local disk at all and
    works identically across replicas.
    ), but the bulk endpoint still [checks sidecar metadata](
    for kb_name in request.kb_names:
    kb_guard = kb_guards[kb_name]
    try:
    kb_path = _resolve_kb_path(kb_name, kb_guard.owner_user)
    except HTTPException as exc:
    if exc.status_code == HTTPStatus.NOT_FOUND:
    # Try the orphan-row cleanup before declaring the
    # KB not found — a remote-backed KB (Astra /
    # Mongo / Postgres / OpenSearch) whose local dir
    # is missing must still be deletable so the UI
    # stops showing it. Owner-scoped: the orphan row
    # belongs to the KB owner, not the actor.
    handled, orphan_warning = await _cleanup_orphan_db_row(
    kb_name=kb_name,
    current_user=kb_guard.owner_user,
    )
    if handled:
    deleted_count += 1
    if orphan_warning:
    remote_warnings.append(orphan_warning)
    else:
    not_found_kbs.append(kb_name)
    continue
    raise # Re-raise 403 (traversal) and 500 errors
    # Mirror the per-KB Memory-Base guard the single-delete /
    # ingest / chunks routes apply via dependency. Memory-Base
    # KBs are managed through the Memory Base APIs and must
    # not be deletable through the generic bulk endpoint.
    kb_metadata = KBAnalysisHelper.get_metadata(kb_path, fast=True)
    if _is_memory_base_associated(kb_metadata):
    memory_base_kbs.append(kb_name)
    continue
    ). A direct bulk-delete request can therefore remove a Memory Base’s KB row/storage while leaving the Memory Base record dangling. Run the DB-backed _assert_kb_not_memory_base guard before path resolution and orphan cleanup.

@Jkavia

Jkavia commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator
  1. Non-atomic create can orphan a knowledge_base row + provisioned collection

create() performs three independent operations with no overall transaction, and the duplicate-name check runs last:

# 1. provision the vector-store collection
await initialize_kb(kb_name=kb_name, ...)
# 2. INSERT the knowledge_base row (its own session)
await _create_kb_record_for_memory_base(kb_name=kb_name, ...)
# 3. uniqueness check + INSERT memory_base row (separate session_scope)
existing = ... # ValueError if name taken
mb = MemoryBase(...); db.add(mb); await db.commit()  # IntegrityError possible

Assumption: steps 1–2 only run for a create that will succeed.
Why it's wrong: the uniqueness guard is in step 3, so a duplicate MB name (or any IntegrityError on the memory_base insert) fails after the collection and the knowledge_base row are already created.
What breaks:

  • Every duplicate-name attempt leaks an orphaned knowledge_base row (with source_types=["memory"]) plus a provisioned vector collection. Nothing rolls them back.
  • The orphan row keeps _assert_kb_not_memory_base returning 403 for that kb_name indefinitely (low blast radius today only because kb_name carries a random 8-hex suffix — but the leak is real, and remote-backed collections leak too).

Fix options:
a. Move the duplicate-name check (step 3) before initialize_kb / _create_kb_record_for_memory_base, so nothing is provisioned for a name that will be rejected; and/or
b. Wrap 1–3 so a failure after provisioning issues compensating cleanup (delete_by_user_and_name + collection teardown) in a finally/except.

Given the DB row is now the source of truth, an orphaned row is worse than the old disk-only sidecar — worth closing before merge.

@Jkavia

Jkavia commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator
  1. Invalid remote backend config is swallowed at create → a "successfully created" Memory Base that's broken forever

initialize_kb is fully best-effort — it swallows every exception from provisioning:

try:
    await backend.ensure_ready()
    _ = backend.vector_store
except Exception as exc:  # noqa: BLE001 — provisioning is best-effort
    await logger.awarning("Initial %s setup for %s failed: %s", backend_type, kb_name, exc)

Assumption: "every backend creates the collection lazily on first write anyway, so a provisioning failure must not block creation." That holds for local Chroma.
Why it's wrong now: this PR adds a DB-provider selector, so backend_type/backend_config can be OpenSearch / Chroma Cloud / Mongo / Postgres / Astra with a bad URL or wrong credentials. ensure_ready() then fails, the warning is swallowed, and create() proceeds to write the knowledge_base row (with the bad config) and the memory_base row.
What breaks: the user sees a successfully created Memory Base. There is no create-time validation, so the misconfiguration only surfaces later — on every ingestion and every retrieval — with no obvious link back to the create step.

Fix: when the selected backend is non-Chroma, surface an ensure_ready() failure at create time instead of swallowing it (fail the create, or run an explicit connectivity check), so a bad remote config is rejected up front rather than producing a silently dead Memory Base. Keep the best-effort/lazy behavior for the local-Chroma default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request scaling-up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants