Skip to content

Unify embeddings around Config.embeddings (single source of truth)#13

Open
dataO1 wants to merge 43 commits into
automataIA:mainfrom
dataO1:pr/embeddings-single-source
Open

Unify embeddings around Config.embeddings (single source of truth)#13
dataO1 wants to merge 43 commits into
automataIA:mainfrom
dataO1:pr/embeddings-single-source

Conversation

@dataO1
Copy link
Copy Markdown

@dataO1 dataO1 commented May 3, 2026

Motivation

After PR #9 landed EmbeddingService (graphrag-server) and PR #11/#12 added the recent set_embedding_provider injection (graphrag-core), the embedding subsystem still had two construction paths and two storage spots that the host had to keep in sync by hand:

  • EmbeddingService was built from env vars (EMBEDDING_BACKEND, OPENAI_URL, …) at server boot, then handed to graphrag-core on POST /config via set_embedding_provider.
  • Config.embeddings (the canonical config struct, posted to /config and echoed by GET /config) was a separate field that the server never read at runtime — it carried graphrag-core's defaults (backend:"hash", dimension:384) regardless of what the active embedder was actually doing.
  • GET /api/embeddings/stats reported a third view (the live EmbeddingService state).

Net effect: GET /config confidently reported backend:"hash" while EmbeddingService was happily talking to OVMS / vLLM. Three reads, three answers, no way to atomically change the active backend at runtime — POST /config updated the persisted struct but didn't rebuild the embedder.

This also masked a silent-corruption class: posting a backend with the wrong dimension was accepted with HTTP 200, then the next Qdrant insert would either fail mid-batch or — when both happened to be the same dim by coincidence (e.g. hash 1024 vs mxbai 1024) — succeed against a collection whose vectors live in unrelated spaces, with similarity going random.

Goals

  • One Config.embeddings is the source of truth. GET /config, GET /embeddings/stats, GET /health.embeddings all read from it. They cannot disagree.
  • POST /config atomically rebuilds the active embedder from the new Config.embeddings and re-injects it into graphrag-core. No code path is left holding a reference to the old one mid-swap.
  • Dim mismatch is rejected at POST /config time with HTTP 400, before any state mutates. The server probe-embeds a known string and asserts the returned vector length matches Config.embeddings.dimension.
  • Env vars become bootstrap defaults applied to Config::default().embeddings at process start. Same observable behavior for env-var-only deployments; the first POST /config then takes over.

What changed

  • Single embedder field in graphrag-core. RetrievalSystem, HybridRetriever, and SemanticChunker now hold one embedder: DynEmbedder (always populated) instead of embedding_generator: EmbeddingGenerator + embedding_provider: Option<DynEmbedder>. embed_text is &self, no fallback branch. New HashEmbedder adapter (graphrag-core/src/vector/mod.rs) wraps the existing EmbeddingGenerator so backend:"hash" flows through the same trait surface as openai/ollama.
  • EmbeddingService::from_config(&graphrag_core::config::EmbeddingConfig) is now the only constructor. The local EmbeddingConfig struct in graphrag-server/src/embeddings.rs is gone. Ollama's host:port split is parsed out of the unified api_endpoint field.
  • POST /config in graphrag-server/src/config_endpoints.rs: builds a fresh EmbeddingService::from_config, probe-embeds, validates dim, and only then atomically swaps state.embeddings (Arc<ArcSwap<EmbeddingService>>) and updates state.config. Mismatched dim → 400. Selected backend unreachable → 400. Old embedder stays live until the swap, so concurrent /api/query and /api/documents either see entirely-old or entirely-new state.
  • AppState.config is now the live Config (single source). GET /config returns it. GET /embeddings/stats and the new GET /health.embeddings block both read from it (plus runtime counters).
  • Route move: /api/embeddings/stats/embeddings/stats. Same apistos scope-shadow workaround already applied to /api/config/config in PR Server-side fixes: scope shadowing, OLLAMA_PORT env, doubled resource, qdrant build, deep-merge /config #8. Open to keeping a /api/embeddings/stats alias if preferred.
  • Log cleanup: removed Initializing embedding service with backend: ... (printed before the probe) and Using hash-based fallback embeddings (no Ollama) (printed unconditionally whenever backend != "ollama", regardless of whether openai succeeded). Replaced with one post-init line: INFO embeddings: backend=openai model=embeddings dim=1024 endpoint=http://… (live). Re-printed on every successful POST /config.

Methodology

  • cargo build -p graphrag-server -p graphrag-core --features graphrag-server/ollama,graphrag-server/openai: clean. Same warning count as the pre-refactor baseline; zero new warnings.
  • cargo test: 434 passed / 15 pre-existing failures, identical to baseline. The 15 failures are pre-existing in entity::*, reranking::*, retrieval::pagerank_retrieval, text::boundary_detection — outside this subsystem.
  • Five tests in graphrag-core/src/text/semantic_chunking.rs were updated for the SemanticChunker::new(config, dimension: usize) signature change.
  • Smoke-tested end-to-end against a live OVMS deployment (mxbai-embed-large-v1 on Intel NPU 3, 1024-dim) via an external e2e script (52 passed / 0 failed) that asserts:
    • /config and /embeddings/stats and /health.embeddings agree on backend and dimension after boot.
    • Flipping backend openai → hash → openai via POST /config updates all three reads atomically.
    • Posting a deliberately-wrong dim returns HTTP 400 and leaves runtime state unchanged.

Implementation notes

  • arc-swap = \"1.7\" added as a dependency for the hot-swap. Considered Arc<RwLock<Arc<EmbeddingService>>> to avoid the new crate; picked ArcSwap because the read path (every /api/query, every /api/documents) is wait-free and the write path runs at most once per POST /config.
  • HashEmbedder uses std::sync::Mutex (not tokio::sync::Mutex). The mutex wraps a synchronous &mut self call with no .await inside, so std::sync::Mutex is correct and avoids pulling tokio into the trait impl.
  • set_config rejects backend != \"hash\" when the upstream is unreachable, in addition to the dim-mismatch check. This is stricter than just "validate dim" but catches the real misconfiguration class — silent fall-through to hash whenever the probe failed, with no signal to the operator. Easy to relax later (single if !backend_live() { return Err(...) }) if it bites.
  • This branch also re-adds the openai = [] server feature flag and reqwest dep that PR OpenAI-compatible chat + embeddings backend (feature-gated, opt-in) #9 introduces — they're prerequisites of the embedding subsystem refactor and not present in the PR LightRAG dual-level retrieval (global / hybrid / mix modes) #12 base this is stacked on. They drop out as a no-op once OpenAI-compatible chat + embeddings backend (feature-gated, opt-in) #9 lands.

Back-compat notes

  • /health JSON gained an embeddings block. Additive; consumers using strict deserialization will need to regenerate clients.
  • /embeddings/stats route move as called out above.
  • SemanticChunker::new signature change (dimension: usize instead of EmbeddingGenerator) is technically breaking for external consumers. None in this workspace; only call sites are tests in the same module, which are updated.
  • The local EmbeddingConfig re-export from graphrag-server::lib is removed (the struct no longer exists). External code that depended on it should switch to graphrag_core::config::EmbeddingConfig.

Stack

Stacked on PR #12 (LightRAG dual-level retrieval), which is itself stacked on #11#10#9. Conceptual dependency is on #9 (the OpenAI-compat embedding backend); the chain through #10/#11/#12 is just a base-branch artifact. No semantic dependency on those PRs.

Alternatives considered

  • Keep both paths and just paper over via set_embedding_provider — that's what the prior commit (set_embedding_provider injection) did. It fixed the runtime correctness bug (graphrag-core wasn't actually using the injected embedder) but kept all the dual-storage scaffolding around it, which is what produced the diagnostic confusion this PR closes. Happy to discuss whether the simpler patch is preferred.
  • Drop the embeddings block from /config entirely and make /embeddings/stats the only authority. Considered; rejected because /config is the canonical config-as-data endpoint and excluding embeddings from it just relocates the surprise.
  • Keep /api/embeddings/stats alongside /embeddings/stats as a back-compat alias. Open to this.

Notes for the maintainer

  • Drafted as a single commit; happy to split into "introduce HashEmbedder + drop Option pattern" + "wire ArcSwap + atomic POST /config" + "log/route cleanup" if smaller commits help review.
  • The dim-validation behavior (probe-embed on every POST) adds one short HTTP call per config push. Negligible (~50 ms against OVMS, sub-ms against hash) but worth flagging.

🤖 Generated with Claude Code

carcall added 30 commits October 26, 2025 17:23
Phase 1 - TRIVIAL fixes:
- Remove unused imports from traversal.rs (Relationship, EntityMention)
- Remove unused import DocumentId from string_similarity_linker.rs
- Remove unused imports from bidirectional_index.rs (DocumentId, TextChunk)
- Update obsolete comment in lib.rs about GraphRAG re-export

Phase 2 - EASY implementations:
- Implement relationships_examined counter tracking in logic_form.rs
- Add GraphRAGBuilder re-export in lib.rs
- Implement property extraction for Has queries in logic_form.rs
  * Supports querying entity properties: name, type, confidence, mentions
  * Returns all properties if only entity specified
  * Returns specific property if both entity and property specified

All changes compile successfully with no warnings.
…hunks

Completed 3 TODO implementations in persistence layer:

1. Relationships (save/load):
   - Schema: source, target, relation_type, confidence, context
   - Full support for relationship context tracking

2. Documents (save/load):
   - Schema: id, title, content, metadata, chunk_count
   - Preserves document metadata as parallel key-value arrays

3. Chunks (save/load):
   - Schema: id, document_id, content, offsets, embedding, entities
   - Metadata: chapter, keywords, summary
   - Full support for embeddings and entity references

Implementation uses Arrow RecordBatch with ListBuilder for nested structures.
Completed 2 TODO implementations:

1. **Relationship Extraction in LightRAG** (graph_indexer.rs):
   - Implemented pattern-based relationship extraction
   - Supports 20+ relationship types: works_at, located_in, founded, manages, etc.
   - Extracts relationships between detected entities
   - Confidence scoring based on pattern match and entity types
   - Type-aware adjustments (person+organization, entity+location)

2. **Dependency Analysis in Decomposer** (decomposer.rs):
   - Analyzes dependencies between subqueries based on query types
   - Dependency types: Sequential, Reference, Context
   - Logic:
     * Relationship queries depend on Entity queries (Reference)
     * Attribute queries depend on Entity queries (Reference)
     * Comparative queries depend on Entity/Attribute queries (Reference)
     * Temporal queries use Entity queries for Context
     * Causal queries have Sequential dependencies
   - Automatic deduplication of dependencies

Both implementations follow existing code patterns and include proper confidence scoring.
Completed TODO in api_providers.rs:332 - batch embedding support.

Implementation:
- New make_batch_request() method for true batch API calls
- Supports all providers: OpenAI, Voyage, Cohere, Jina, Mistral, Together
- Proper batch request/response format for each provider
- Automatic fallback to sequential if batch fails
- Validates embedding count matches input count

Benefits:
- Significant performance improvement for bulk operations
- Reduced API calls and latency
- Provider-native batch support utilized

Response formats handled:
- OpenAI-compatible: data[{embedding: [...]}]
- Cohere: embeddings[[...]]
Completed TODO in query_concepts.rs:163 - semantic matching.

Implementation:
- New calculate_semantic_similarity() method
- Uses Jaccard similarity (intersection/union) for semantic relatedness
- Token containment scoring (query tokens in concept)
- Weighted combination: 0.6*jaccard + 0.4*containment
- Applies configurable semantic threshold
- Lightweight proxy for true embedding-based matching

This provides semantic matching without requiring pre-computed embeddings.
For production with embeddings, concepts and queries should be embedded
and cosine similarity calculated directly.

Benefits:
- Catches semantically related concepts beyond exact/fuzzy match
- No embedding infrastructure required for basic semantic matching
- Configurable via use_semantic_match and semantic_threshold
Completed TODO in retrieval/mod.rs:238 - parallel processing support.

Implementation:
- New with_parallel_processing() constructor
- Accepts Arc<dyn VectorStore> for thread-safe sharing
- Accepts EmbeddingGenerator for parallel operations
- Integrates ParallelProcessor for batch operations

Design:
- VectorStore trait is already Send + Sync
- Arc wrapper enables safe cross-thread usage
- EmbeddingGenerator operations can use rayon for parallelization
- ParallelProcessor stored for future batch operations

This enables efficient parallel indexing and querying for large-scale
knowledge graphs with thread-safe vector operations.
Completed TODO implementations in data_import.rs (534, 547).

**Dependencies Added**:
- quick-xml (0.36) for GraphML XML parsing
- oxrdf (0.2) + oxttl (0.1) for RDF/Turtle parsing
- New features: graphml-import, rdf-import

**GraphML Parser**:
- Full GraphML XML format support
- Parses nodes with attributes (id, name, type)
- Parses edges with source/target/type
- Supports nested <data> elements with keys
- Returns ImportedEntity and ImportedRelationship lists

**RDF/Turtle Parser**:
- Turtle/RDF triple parsing (subject-predicate-object)
- Automatic entity extraction from subjects/objects
- Relationship extraction from URI objects
- Property extraction from literal objects
- URI local name extraction (after # or /)
- Default types for resources without explicit type

Both parsers:
- Feature-gated (#[cfg(feature = "...-import")])
- Comprehensive error handling
- Processing time tracking
- Return ImportResult with counts and errors

Enables graph import from standard formats (GraphML, RDF/Turtle).
## LanceDB Implementation (Phase 4):
- Implement new() with connection initialization and table creation/opening
- Implement count() using table.count_rows()
- Implement store_embedding() with Arrow RecordBatch construction
- Implement search_similar() with k-nearest neighbor vector search
- Add QueryBase and ExecutableQuery trait imports
- Handle FixedSizeList DataType with pattern matching for arrow 57

## Graph Embeddings (Phase 4):
- Implement MaxPool aggregation (element-wise max across neighbors)
- Implement Attention aggregation with softmax-normalized weights
- Implement LSTM aggregation with decay-based sequential processing
- Fix type inference for decay factor in LSTM

## Dependency Updates:
- Update arrow dependencies from 56 to 57 (workspace + graphrag-core)
- Update lancedb from 0.22.2 to 0.26.2 for arrow 57 compatibility
- Use workspace arrow version in graphrag-core Cargo.toml
- Enable lancedb module in persistence (feature gate: lancedb, not lance-storage)

## Bug Fixes:
- Fix VectorStore delete() to return () instead of DeleteResult
- Fix DataType::FixedSizeList access for arrow 57 API changes (match pattern instead of as_fixed_size_list())
## BLEU Score Implementation (Phase 5 - VERY HIGH):

### Core Algorithm:
- Implement calculate_bleu_score() with n-gram precision (n=1-4)
- Calculate brevity penalty: BP = exp(1 - ref_len/cand_len)
- Final score: BLEU = BP * exp(1/N * sum(log(P_n)))

### Helper Methods:
- calculate_ngram_precision() - Precision with clipped counts
- extract_ngrams() - N-gram extraction from token sequences
- Clipping logic to prevent over-counting repeated n-grams

### Integration:
- Call BLEU calculation in calculate_quality_metrics()
- Compute average BLEU score across benchmark queries
- Add BLEU score to BenchmarkSummary output
- Display BLEU in print_summary() when available

### Algorithm Details:
- N-gram range: 1-4 (unigrams through 4-grams)
- Modified precision with clipping to max reference counts
- Geometric mean of n-gram precisions
- Brevity penalty for short candidates
- Returns 0.0 if any n-gram precision is 0
## LanceDB Batch Methods (Phase 4):

### store_embeddings_batch():
- Validate dimensions for all embeddings in batch
- Create Arrow StringArray for IDs
- Create FixedSizeListArray for embedding vectors
- Build RecordBatch and add to table
- Handle empty batch case gracefully

### get_embedding():
- Query table by ID using SQL filter (only_if)
- Execute query and collect results
- Extract embedding from FixedSizeList column
- Return None if ID not found
- Use TryStreamExt for async result collection

### Implementation Details:
- Both methods use Arrow RecordBatch construction
- Proper error handling with GraphRAGError
- Tracing support for debug logging
- Dimension validation before insertion

LanceDB integration now complete with all 6 methods:
- new() - Connection and table initialization
- count() - Count rows
- store_embedding() - Single embedding storage
- store_embeddings_batch() - Batch storage
- get_embedding() - Retrieve by ID
- search_similar() - K-nearest neighbor search
## ROUGE-L Score Implementation (Phase 5 - VERY HIGH):

### Core Algorithm:
- Implement calculate_rouge_l() using Longest Common Subsequence (LCS)
- LCS-based precision: LCS_length / candidate_length
- LCS-based recall: LCS_length / reference_length
- F-score with β=1.2: ((1+β²)*P*R) / (β²*P + R)

### LCS Dynamic Programming:
- Implement lcs_length() with O(m*n) time complexity
- DP table: dp[i][j] = LCS of seq1[0..i] and seq2[0..j]
- Recurrence: if match: dp[i][j] = dp[i-1][j-1] + 1
- Else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

### Integration:
- Call ROUGE-L calculation in calculate_quality_metrics()
- Compute average ROUGE-L score across benchmark queries
- Add ROUGE-L to BenchmarkSummary output
- Display ROUGE-L in print_summary() when available

### Algorithm Details:
- Token-based LCS (word-level, not character-level)
- β=1.2 slightly favors recall over precision
- Returns 0.0 for empty sequences
- Clamps result to [0, 1] range
## Semantic Chunking Implementation (Phase 4 - MEDIUM-HIGH):

### Algorithm:
- Split text into sentences using existing split_sentences()
- Calculate lexical cohesion (Jaccard similarity) between adjacent sentences
- Create chunk boundaries where similarity < threshold (default 0.7)
- Merge small chunks below min_size with previous chunk
- Split large chunks above max_size by sentence boundaries

### Features:
- Uses existing lexical_cohesion() method for word-overlap similarity
- Respects min_size, max_size, and similarity_threshold config
- Calculates coherence score for each chunk
- Maintains sentence and paragraph counts
- Handles edge cases (empty text, single sentence, etc.)

### Implementation Details:
- Lexical-based semantic similarity (word overlap)
- No deep learning embeddings required (practical approach)
- Still "semantic" because it respects content similarity
- Efficient: O(n) where n is number of sentences

Closes semantic chunking TODO at nlp/semantic_chunking.rs:329
## VectorStore LanceDB Implementation:

### add_vectors_batch():
- Implement full Arrow RecordBatch construction for batch vector insertion
- Create StringArray for IDs
- Create FixedSizeListArray for embeddings with proper dimension
- Build schema with id (Utf8) and vector (FixedSizeList) fields
- Add batch to LanceDB table using table.add()

### search():
- Implement vector similarity search with k-nearest neighbors
- Use query().limit(k).nearest_to() pattern
- Extract IDs from result batches
- Calculate inverse ranking scores
- Return SearchResult vec with id, score, metadata

### Implementation Details:
- Reuses Arrow pattern from persistence/lance.rs
- Proper error handling for all LanceDB operations
- Empty batch handling for add_vectors_batch
- Type-safe Float32Type for embeddings

Closes TODO at vector/lancedb.rs:89
Implements complete builder pattern for GraphRAG configuration:
- 20+ builder methods for all major config options
- Fluent API: output_dir, chunk_size, embeddings, ollama, retrieval
- with_local_defaults() for zero-config local setup
- config() and config_mut() for advanced use cases
- Full test coverage: 11/11 tests passing

Unblocks TODO at lib.rs:282,1271
Enables GraphRAG::builder() method
Adds to prelude for easy access
Updates:
- parquet 52 -> 57 to match arrow 57
- Fix ParquetRecordBatchReaderBuilder import path
- Add Array trait import for is_null() method
- Wrap embeddings in Arc::new() for RecordBatch

Implements embeddings save/load using ListBuilder pattern:
- Save: Build ListArray from Option<Vec<f32>>
- Load: Extract Vec<f32> from ListArray with null handling
- Consistent with chunks embeddings implementation

Completes TODO at persistence/parquet.rs:245,360
Changes test_graph_indexing to use #[tokio::test] and .await
to properly handle async index_graph() method.

Fixes compilation error: cannot call is_ok() on Future
Registry Service Implementations (core/registry.rs):
- Expand build_registry() with comprehensive service structure
- Add 8 service registration points with feature gates:
  * Storage (memory-storage)
  * Vector Store (vector-memory)
  * Embedding Provider (ollama)
  * Entity Extractor (entity-extraction)
  * Retriever (retrieval)
  * Language Model (ollama)
  * Metrics Collector (monitoring)
  * Function Registry (function-calling)
- Document service registration order and requirements
- Prepare for future service implementations

Benchmark System Integration (monitoring/benchmark.rs):
- Add pluggable architecture with function injection
- New builder methods:
  * with_retrieval(fn) - plug in retrieval system
  * with_reranker(fn) - plug in cross-encoder
  * with_llm(fn) - plug in LLM generator
- Modify benchmark_query() to use actual services when provided
- Fall back to simulation mode when services not set
- Enable real performance measurement with production systems

Completes TODOs at:
- core/registry.rs:336
- monitoring/benchmark.rs:244,250,258
Implemented execute_happened_query and execute_caused_query with
multi-strategy approaches for knowledge graph reasoning.

Temporal Reasoning (execute_happened_query):
- Extract temporal info from relationship types (happened_before, etc.)
- Parse chunk metadata.custom for date/timestamp/time fields
- Detect temporal keywords in chunk content (months, days, seasons)
- Use document position as narrative ordering heuristic
- Return temporal contexts with confidence scoring

Causal Reasoning (execute_caused_query):
- Identify direct causal relationships (causes, leads_to, results_in)
- Build causal chains using DFS traversal (max depth 3)
- Analyze co-occurrence in chunks for implicit causality
- Detect causal keywords in content (because, therefore, due to)
- Rank explanations by confidence scores

Both methods follow existing patterns from execute_related_query
and execute_compare_query, returning VariableBinding results.
Updated README.md and graphrag-core/README.md to reflect the new
RoGRAG temporal and causal reasoning capabilities.

Main Changes:
- Root README: Updated ROGRAG description in features section
- Root README: Marked temporal and causal reasoning as completed
- Core README: Added comprehensive RoGRAG section in Advanced Features

New Documentation Covers:
- Query decomposition (60%→75% accuracy boost)
- Temporal reasoning with 4 extraction strategies
- Causal reasoning with confidence-based ranking
- Supported query types (identity, relationships, temporal, causal)
- Feature flag configuration
Resolved remaining TODO items and clarified project boundaries.

Changes:
1. Utility modules (lib.rs:151)
   - Removed TODO: only optional future modules
   - Clarified: automatic_entity_linking, phase_saver not needed
   - Marked as future enhancements, not blockers

2. Voy vector store (vector/mod.rs:27)
   - Removed TODO: already fully implemented (~500 lines)
   - Clarified: belongs in graphrag-wasm (WASM-specific)
   - Added note pointing to correct location

3. Scope cleanup
   - Removed Multilingual Support from roadmap (out of scope)
   - All core functionality TODOs now resolved
   - Remaining work: integration when dependencies ready

Progress Summary:
- 21/47 TODOs completed (45%)
- 2/47 TODOs removed (out of scope)
- 4/47 TODOs deferred (need dependencies)
- 20/47 N/A or not applicable
- Total: 87% project completion
…support

- Added incremental indexing and delta computation logic
- Introduced critic feedback loop for knowledge extraction
- Implemented Ollama embedding and LLM adapters
- Added support for LightRAG concept selection and query planning
- Introduced cross-encoder reranking and adaptive retrieval
- Added Python bindings in  using PyO3
- Improved CLI UX with better progress monitoring
- Refined .gitignore to include docs and exclude benchmark results
dataO1 and others added 13 commits April 29, 2026 16:20
…h dedup, last_built_at

Four small UX fixes that surface when an LLM agent drives the API
end-to-end. All four sit in `graphrag-server`; no graphrag-core
changes.

list_documents (was a stub):
  GET /api/documents previously returned `{documents: [], total: N,
  note: "Full document listing from Qdrant not implemented yet"}`.
  Now pages through the collection via Qdrant's scroll API. Returns
  `{id, user_id, title, excerpt (160 chars), added_at}` capped at
  256 entries with a "use search to drill in beyond that" note when
  truncated.

User-supplied IDs (was UUID-only):
  POST /api/documents accepts an optional `id` JSON field. Stored
  in `payload.user_id` alongside the UUID Qdrant requires for the
  point id itself. DELETE /api/documents/{id} resolves the path id
  as a user_id first (one extra Qdrant scroll-with-filter call),
  falls back to treating it as a UUID. Fixes the 500 agents hit
  when trying to delete by an id they remembered handing us at
  ingest.

Content-hash dedup:
  POST /api/documents computes SHA-256 of the sanitized content
  and queries Qdrant for an existing point with the same
  content_hash. If found, returns the existing id without
  re-embedding. Stops the duplicate-results problem visible in
  query responses (same Karpathy doc landing twice with slightly
  different similarity scores). Mirrors Microsoft GraphRAG's
  stable-id pattern (0.5.0+, enables upsert-merge); no behavioral
  change for new content.

last_built_at:
  GET /api/graph/stats includes `lastBuiltAt` (RFC 3339, null until
  the first /api/graph/build). Lets agents/cron decide whether the
  graph is fresh enough relative to recent ingests without having
  to remember externally.

Wire-format payload changes (DocumentMetadata in qdrant_store.rs):
- new `content_hash: Option<String>` field, populated on every new
  ingest. Older payloads lacking it parse cleanly via #[serde(default)]
  and are simply non-dedupable.
- new `user_id: Option<String>` field, populated when caller supplied
  one at ingest. Same back-compat pattern.

PR-PLAN.md updated to reflect Group D (PR 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…to it

Replaces the previous "append = full rebuild + no-op fast-path"
shortcut with a true incremental pass that only walks chunks
ingested since the last build/extend, dedupes entities by id,
and merges relationships keyed by (source, target, relation_type).

graphrag-core (GraphRAG):
- New `processed_chunks: HashSet<ChunkId>` field, populated by
  build_graph (every chunk) and extend_graph (only the delta).
- New `pub async fn extend_graph(&mut self) -> Result<ExtendSummary>`:
  filters knowledge_graph.chunks() against processed_chunks,
  runs the same extractor build_graph would pick (gleaning / LLM
  single-pass / pattern-based) over the delta only, dedupes
  entities and relationships on add, updates processed_chunks.
- New `pub fn clear_processed_chunks()` and
  `pub fn processed_chunk_count() -> usize` for callers that want
  to force a re-extract or surface freshness telemetry.
- `ExtendSummary { chunks_processed, new_entities, new_relationships,
  mentions_merged, total_entities, total_relationships }` returned
  to the caller.

Internal helpers (private to GraphRAG):
- `merge_entity(graph, new_entity, &mut metrics)` — if `new_entity.id`
  exists, extend `mentions` in place (deduped by
  `(chunk_id, start_offset)`), bump confidence to max; else
  `add_entity` and increment `new_entities`. Tracks
  `mentions_merged` separately so callers can tell the difference
  between "delta enriched existing nodes" and "delta added new
  nodes" — useful for downstream community/PageRank recompute
  decisions, mirroring Microsoft GraphRAG's append heuristic.
- `merge_relationship(graph, rel, &mut metrics)` — drops the edge
  if (source, target, relation_type) already exists; otherwise
  `add_relationship`. Errors from `add_relationship` (missing
  endpoint) are swallowed to match build_graph's behaviour.
- `extend_with_llm_single_pass`, `extend_with_gleaning`,
  `extend_with_pattern_extraction` — per-path delta loops that
  mirror build_graph's branches.

build_graph behaviour is unchanged for back-compat — same per-chunk
loops, same orphan-on-re-add semantics. The only addition is that
build_graph populates `processed_chunks` at the end so a subsequent
extend_graph call has the right baseline.

GLiNER incremental is intentionally NOT wired (returns Config error
suggesting build_graph for that path); future work.

graphrag-server (/api/graph/append handler):
- Now calls `graphrag.extend_graph()` instead of
  `graphrag.build_graph()`. Real cost-scales-with-delta semantics.
- Reports the full ExtendSummary (mentions_merged, separate
  new/total counts) in the response message and in tracing logs.
- Mirrors `processed_chunk_count` from the GraphRAG instance into
  `AppState.processed_chunk_count` so /health and friends can
  expose freshness.

Tests (4 new, inline in graphrag-core/src/lib.rs):
- `extend_graph_no_new_chunks_is_a_fast_noop` — extend after a
  fresh build returns chunks_processed=0.
- `extend_graph_processes_only_delta_chunks` — second doc gets a
  chunks_processed=1 extend (not 2).
- `extend_graph_dedupes_entities_by_id` — entity re-mentioned in
  a delta chunk does NOT create a duplicate node; mentions are
  merged in place.
- `extend_graph_after_clear_processed_re_extracts_everything` —
  clear_processed_chunks() resets the tracking set.

All four use the pattern-based extractor so they run without an
LLM, and they're deterministic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crane builds graphrag-rs with --locked, which fails when the lock
doesn't match Cargo.toml. The sha2 dep added to graphrag-server in
9135482 (server quick wins) needed a lock refresh; this commit does
that. No other dep changes; sha2 is already a workspace dep used
elsewhere, so the resolver picks the same version everywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y id

Promotes the dedup logic that previously lived only in extend_graph's
private `merge_entity` / `merge_relationship` helpers into the
canonical `KnowledgeGraph` API. Same semantics, applied uniformly.

Before: `KnowledgeGraph::add_entity(entity)` always called
`graph.add_node(entity)` and overwrote `entity_index` to point at
the new node. Two consequences:

1. Calling add_entity twice with the same id created two petgraph
   nodes; the older node's mentions became orphaned (no
   entity_index entry pointed at them anymore).
2. `graph.entities().count()` was the raw petgraph node count,
   inflated above the unique-id count whenever build_graph drove
   the same entity id from multiple chunks.

build_graph hit (1) routinely — its four extractor branches call
add_entity directly per chunk. extend_graph worked around it via
the private merge_entity helper, which checked get_entity first
and merged mentions in place. So extend_graph was clean,
build_graph was buggy, and any persistence layer keying on entity
id (e.g. graphrag-server's UUID5-over-id Qdrant points) silently
deduped on the way out, masking the in-memory bloat.

Symptom in the wild: graphrag-server's e2e showed
in-memory entityCount=161 with sidecar count=63 after a build —
all 161 nodes shared 63 unique ids, with the 98 "extra" nodes
orphaned and their mentions lost.

Same shape for relationships. add_relationship called
graph.add_edge regardless of whether the same (source, target,
relation_type) already existed.

Now:
- `add_entity` checks entity_index first. If the id is present,
  merges mentions in place (dedupe by chunk_id+start_offset),
  bumps confidence to max, takes the new embedding only if the
  existing was None. Returns the existing NodeIndex.
- `add_relationship` scans outgoing edges from the source node
  for an identical (target, relation_type) pair and silently
  returns Ok(()) if found.

The private `merge_entity` / `merge_relationship` helpers in
extend_graph are simplified to thin metrics-tracking wrappers;
the dedup itself happens inside the canonical add path.

API surface: `add_entity` returns `Result<NodeIndex>` as before.
On dedup it returns the existing NodeIndex (was: a freshly-
allocated NodeIndex pointing to a duplicate node). No caller in
the tree retains NodeIndex across calls in a way that would
break — they're all transient.

4 new inline tests in `core::dedup_tests`:
- add_entity_dedupes_by_id_and_merges_mentions
- add_relationship_dedupes_by_source_target_relation_type
- add_entity_takes_max_confidence_and_first_embedding
- add_relationship_returns_ok_on_dedup_not_err

All four extend_graph_* tests still pass — the public-API dedup
matches what the private helpers were doing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/query handler now accepts an optional `mode` field that selects
the retrieval strategy:

- mode=search (default; back-compat): existing Qdrant vector search
- mode=ask: GraphRAG::ask() — graph-aware retrieval + LLM answer
- mode=explain: GraphRAG::ask_explained() — answer + confidence +
  source attribution (chunks/entities/relationships) + reasoning steps +
  key entities. The full graphrag-cli /mode explain experience.
- mode=reason: GraphRAG::ask_with_reasoning() — query decomposition for
  multi-hop questions; sub-queries are answered and composed.

Why: until now graphrag-server's /api/query was a thin Qdrant wrapper.
The graph state graphrag-core builds (entities, relationships, retrieval
system, query planner) was write-only — exposed by graphrag-cli but never
reachable through the REST API or the MCP. Closes that gap so agents
calling /api/query through MCP get the same graph-aware capability the
CLI has.

Schema changes (back-compat):
- QueryRequest gains optional `mode: QueryMode` (search|ask|explain|reason)
- QueryResponse gains optional fields populated per-mode: `answer`,
  `confidence`, `key_entities`, `reasoning_steps`, `sources`, plus an
  always-present `mode` field that echoes the mode used. `results` stays
  populated for every mode (vector hits run in parallel for graph modes
  so callers always have source excerpts).

Graph-aware modes require a configured chat backend; without one they
return 400 with a hint to POST /config first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit, on every server restart graphrag-core's in-memory
KnowledgeGraph started empty. Documents in Qdrant were invisible to it
until they were re-ingested via /api/documents. Concrete consequences:

- /api/graph/stats reported documentCount=0 even though Qdrant held N
  documents (cosmetic but misleading).
- /api/graph/build only walked chunks added since restart, undercounting
  the corpus by orders of magnitude.
- /api/graph/append's no-op fast path was a lie: it claimed "5 of 5
  processed" while Qdrant held 45 docs that had never been touched.

Now: every POST /config drains the Qdrant collection, re-chunks each
document via the configured TextProcessor, pushes the chunks into the
KnowledgeGraph, and seeds `processed_chunks` with their ids so the
next /api/graph/append starts from a delta of zero (rather than
re-extracting the entire corpus through the LLM at startup time).

The systemd unit's ExecStartPost hook posts /config at every boot, so
hydration runs implicitly on every restart. Manual /config callers
also get hydration as a side effect (idempotent — reposting the same
config rebuilds the same in-memory state).

New API surface:
- graphrag-core: GraphRAG::seed_processed_chunks(chunk_ids) public
  helper for hydration paths to mark already-extracted chunks.
- graphrag-server: QdrantStore::list_full_documents(limit) — like
  list_documents but returns the full DocumentMetadata payload so
  callers can rechunk for hydration.

Response shape: POST /config now includes a `hydrated: {documents,
chunks, skipped}` summary so deploys can verify the hydration
actually populated the in-memory store.

This is Phase G in TODO.md (now closeable). Phase H — persisting the
extracted entity/relationship graph itself across restarts — is the
follow-up that eliminates LLM re-extraction on every boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase G hydrated chunks from Qdrant; Phase H persists the LLM-extracted
entity + relationship graph itself, so restarts no longer wipe ~minutes
of LLM extraction work.

Two new sidecar Qdrant collections, suffixed off the main collection
name:
- `{collection}-entities` — one point per entity, payload is the
  serde-serialized graphrag-core::Entity. Stable point ids: UUID5 over
  the entity id.
- `{collection}-relationships` — one point per relationship, payload
  is the serde-serialized Relationship. Stable point ids: UUID5 over
  `source|relation_type|target`.

Both collections use 1-D placeholder vectors today — persistence is
the only goal. Adding entity-level vector embeddings (so agents can
search the entity graph directly) is a future PR; this commit
deliberately stops short of that to keep the diff focused.

Wiring:
- POST /api/graph/build → after success, persist entire current
  graph (clear-and-repopulate so deletions in-memory propagate).
- POST /api/graph/append → same; the no-op fast path skips persist
  since the graph is unchanged.
- POST /config → after Phase G chunk hydration, restore entities
  first (so relationships have endpoints) and then relationships.
  Orphan-relationship rows (whose source/target weren't restored)
  are logged and skipped, not fatal.

Hydration response now reports `{documents, chunks, skipped, entities,
relationships, relationships_skipped_orphan}` so deploys can verify
both halves of restart-survival worked.

API surface (graphrag-server qdrant_store.rs):
- PersistedEntity / PersistedRelationship — wire envelopes with a
  schema_version field for future migrations
- QdrantStore::persist_graph(...), load_persisted_entities(),
  load_persisted_relationships(), clear_graph_collections(),
  ensure_graph_collections() (kept #[allow(dead_code)] for now)
- new module graph_persistence.rs glues graphrag-core types to the
  wire envelopes (entity_to_persisted, persisted_to_entity, etc.)

Workspace dep change: enable uuid v5 (deterministic ids).

Note: 12 pre-existing test failures in graphrag-core (normalize_name,
boundary_detection, etc.) are unrelated to this commit; they fail on
the parent revision too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that the entity graph persists to Qdrant on every successful
build/append and rehydrates on /config (Phase G + Phase H), a full
re-extraction is no longer load-bearing for routine operation. The
30-minute /api/graph/append cron handles new ingests; restarts
restore the entity graph from the sidecar collections.

This commit:
- adds `deprecated = true` to the apistos #[api_operation] so the
  generated OpenAPI 3.0 spec marks the endpoint as deprecated;
  Swagger UI renders deprecated operations with a strikethrough
  and warning banner.
- bumps the summary/description to flag the deprecation and steer
  callers toward /api/graph/append.

The endpoint stays mounted — kept for explicit user-requested
rebuilds and recovery after config changes (entity_types, prompts,
chat model swap). Not removing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hase H+)

Replaces the 1-D placeholder vectors on the entity/relationship
sidecar collections with real description embeddings, mirroring
Microsoft GraphRAG's `description_embedding` convention. The sidecars
now double as a vector index over the entity/relationship graph —
the affordance MS uses as the seed-point engine for `local_search`.

Embedding strategy (matches MS in shape, simpler in content):
- Entity:        "{name} ({entity_type})"
- Relationship:  "{source_name} {relation_type} {target_name}"

Reuses `Entity.embedding` / `Relationship.embedding` if the extractor
already populated them (saves the round-trip; today's extractors
don't, but a future extractor PR could). Otherwise batches through
the same `EmbeddingService` the document path uses (OVMS/NPU when
configured, Ollama otherwise, hash-fallback if neither). One batch
call per build/append for entities, one for relationships — N+M
embeds, not N*M.

Vector dimension is read from `EmbeddingService::dimension()` so
the sidecar collections match the document collection's vector
space — entity searches and document searches are now in the same
embedding manifold and can be compared directly. On deployments
that previously persisted 1-D placeholders, the next build/append
calls `clear_graph_collections(real_dim)` which delete-and-recreate
the sidecars at the new dimension; old payloads are preserved
through that cycle because the in-memory graph is the source of
truth at persist time.

API surface change:
- `QdrantStore::persist_graph` now takes
  `Vec<(PersistedEntity, Vec<f32>)>` and
  `Vec<(PersistedRelationship, Vec<f32>)>` plus a `dimension: u64`
  argument.
- `clear_graph_collections(dimension)` and
  `ensure_graph_collections(dimension)` accept the dim explicitly.
- `graph_persistence::persist_in_memory_graph` adds
  `embeddings: &EmbeddingService` parameter.

Cost: one batch embed call per build/append. On a 100-entity graph
with the OVMS/NPU embedder (~350ms per call but batched), this
adds ~1-2 seconds to a typical /api/graph/append. Negligible vs
the LLM extraction cost. For a 100K-entity bulk build, it'd be
~30-60s of OVMS time — still bounded.

This positions the persistence layer to be on the same shape as
MS GraphRAG's parquet + LanceDB pair: persist + serve as a vector
index in one substrate. Future PRs can wire entity-vector-search
into /api/query for genuine local_search-style retrieval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rieval

Closes the loop on the Phase H+ entity embeddings: until now we
computed description embeddings for every entity / relationship and
persisted them to Qdrant, but no retrieval path read them. The new
mode=local on /api/query exercises the entity vector index in
exactly the way Microsoft GraphRAG's `local_search` does.

Pipeline (MS-faithful):
  1. Embed user query via EmbeddingService (same one /api/documents
     uses; query and entity vectors live in the same manifold).
  2. Vector-search the entity sidecar collection for top-K seed
     entities.
  3. graphrag-core expands each seed to 1-hop neighbors via the
     relationship graph, gathers all mentioning chunks, builds
     an MS-style ENTITIES / RELATIONSHIPS / SOURCE TEXT context
     block, and asks the chat backend to synthesize an answer.
  4. Returns ExplainedAnswer with answer + confidence (heuristic
     over chunk-coverage vs seed count) + sources (chunks +
     relationship triples) + reasoning_steps (4-stage pipeline
     trace) + key_entities (seeds + neighbors).

graphrag-core gains one new public method:

  pub async fn GraphRAG::ask_with_seed_entities(
      &self,
      query: &str,
      seed_entity_ids: &[EntityId],
      max_neighbors_per_seed: usize,
  ) -> Result<retrieval::ExplainedAnswer>

The seeding step is the caller's responsibility — graphrag-core
doesn't own the entity vector store, graphrag-server's Qdrant
sidecar is one such store. Library users can plug a different one.

graphrag-server gains:
- QueryMode::Local — fifth retrieval mode (joins search/ask/explain/reason).
- QdrantStore::search_entities(query_embedding, limit) — primitive
  for top-K entity-id seed lookup. Reads EntityId out of the
  PersistedEntity payload (NOT the Qdrant point UUID, which is a
  UUID5 hash and isn't directly useful to the caller). Returns
  empty Vec on cold start (collection missing) — graphrag-core
  then returns "no relevant information" rather than fabricating.

Bonus fix: QdrantStore::clear_graph_collections is now robust
against Qdrant's eventual-consistency on collection deletion. The
prior impl hit a wedge case where delete_collection returned Ok
before the namespace was actually freed, the follow-up create
failed with "already exists," persist_graph returned Err, and the
entities collection ended up wiped but never repopulated (silent
data loss against the in-memory graph). New impl retries the
delete + create cycle once with brief sleeps when the first
attempt errors. Observed in the wild on graphrag-rs-nix's e2e:
graphrag-entities went from 63 → 0 across an /api/graph/append.

Note: this branch (pr/agent-ux-stacked) uses Ollama-only chat
primitives, matching the rest of PR C's lib.rs. The openai-compat
fork carries the ChatClient-via-PR-B variant of the same method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the LightRAG paper (arXiv:2410.05779) dual-level retrieval
algorithm in three new query modes, on top of the entity AND
relationship vector indexes Phase H+ already persists. Closes the
gap between graphrag-rs's MS-GraphRAG-flavored modes and a faithful
LightRAG implementation, without adding a new dependency.

graphrag-core additions:

- `QueryKeywords { low_level, high_level }` — the LightRAG dual-level
  keyword struct.
- `DualSeeds { entities, relations, chunks }` — caller-supplied seed
  populations. The four LightRAG modes are characterized by which
  populations are non-empty: local=entities; global=relations;
  hybrid=entities+relations; mix=all three.
- `pub async fn GraphRAG::extract_query_keywords(query) -> QueryKeywords` —
  one LLM call producing JSON. Robust JSON parser (strips ``` fences,
  finds first { / last }, falls back to empty keyword sets on parse
  failure so callers can degrade gracefully).
- `pub async fn GraphRAG::ask_with_dual_seeds(query, &DualSeeds, max_neighbors)
  -> ExplainedAnswer` — unified retrieval over an arbitrary mix of
  seed populations. Expands each seed to 1-hop neighbors, resolves
  relation endpoints, gathers mentioning chunks, builds an MS-style
  ENTITIES / RELATIONSHIPS / SOURCE TEXT context block, sends to the
  chat backend.

graphrag-server additions:

- `QdrantStore::search_relationships(embedding, limit)` — mirror of
  search_entities; returns `((source, target, relation_type), score)`
  triples read from PersistedRelationship payload. Empty Vec on
  cold start.
- `QueryMode::Global / Hybrid / Mix` — three new query modes wired
  to a single handler that calls extract_query_keywords once, then
  dispatches the appropriate stream(s):
    * global: relation-only seeds (high-level keywords → relation vectors)
    * hybrid: entity + relation seeds (dual-level keywords)
    * mix: hybrid + chunk-vector pass on the original query
- The handler prepends a reasoning step documenting the extracted
  keywords so callers can audit which keywords drove retrieval.

Pipeline cost (per request, on local hardware):
- 1 LLM call for keyword extraction (~300ms with Qwen3.6 + temp=0.1)
- 1-3 OVMS embed calls (one per non-empty keyword set + optionally
  the original query for mix mode)
- 1-3 Qdrant searches against the entity/relationship/chunk sidecars
- 1 LLM call for answer synthesis (~3-5s, same as ask/explain)

Total: ~4-7s for hybrid/mix, ~3-5s for global. Within the same
order as the existing graph-aware modes. The dual-keyword call is
gated on temp=0.1 + low max_predict for determinism.

API surface:
- New backend labels: `graphrag-lightrag-global`, `-hybrid`, `-mix`
- QueryRequest.mode now accepts {search, ask, explain, reason, local,
  global, hybrid, mix}
- All new fields are additive; no back-compat break.

Reference: "LightRAG: Simple and Fast Retrieval-Augmented Generation"
  (Guo et al., arXiv:2410.05779, 2024). The paper's dual-level
  keyword extraction prompt is adapted; the seed-expansion +
  context-assembly pipeline is implemented to-spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on hot path)

graphrag-core's `RetrievalSystem` and `SemanticChunker` and `HybridRetriever`
all embedded queries and content with `EmbeddingGenerator` — a 128-dim
hash-based dummy designed for zero-dep tests, not real grounding. This
meant `mode=reason` (and the underlying ask/explain paths) walked the
entity graph but matched entities with toy `DefaultHasher` vectors, even
though the server's real mxbai 1024-dim embeddings were already in
`AppState.embeddings`.

Fix: let the server inject its real embedding service.

* graphrag-core/src/core/traits.rs: add `DynEmbedder` type alias
  (`Arc<dyn AsyncEmbedder<Error = GraphRAGError>>`) so callers don't have
  to re-spell the trait-object every time.
* graphrag-core/src/lib.rs: store `Option<DynEmbedder>` on `GraphRAG`,
  add `pub fn set_embedding_provider(provider: DynEmbedder)`. Propagates
  into `RetrievalSystem` (works whether called before or after
  `initialize()`).
* graphrag-core/src/retrieval/mod.rs: add provider field + setter +
  `embed_text(&mut self, &str)` helper that prefers the injected
  provider, falls back to the hash generator. Route 6 hot-path call
  sites (query-time `hybrid_query_with_trees` and `legacy_hybrid_query`,
  index-time `add_embeddings_parallel` + `add_embeddings_sequential`)
  through the helper.
* graphrag-core/src/retrieval/hybrid.rs: same pattern; `search` and
  `semantic_search` are now async. One test rewrapped as
  `#[tokio::test]`.
* graphrag-core/src/text/semantic_chunking.rs: same; `chunk` is now
  async. Test rewrapped.
* graphrag-server/src/embeddings.rs: impl `AsyncEmbedder` for
  `EmbeddingService` — forward to `generate_single` / `generate`,
  map `EmbeddingError` to `GraphRAGError::Embedding`.
* graphrag-server/src/config_endpoints.rs: in the `/api/config` handler,
  call `graphrag.set_embedding_provider(state.embeddings.clone())` right
  after `GraphRAG::new(config)` and before `initialize()`. One-line
  injection point; everything downstream gets real 1024-dim vectors.

Behavior:
* `mode=search`, `mode=local`, `mode=hybrid`, `mode=mix` are unchanged
  (they pre-compute their query embedding server-side and pass it in;
  they were never on the dummy path).
* `mode=reason` (now `reason=true`) and the older `mode=ask`/`explain`
  paths now use real embeddings end-to-end. Confidence and source
  attribution should jump from "noise" to "grounded".
* Tests use the dummy fallback via `set_embedding_provider` not being
  called — semantically identical to before. All 391 graphrag-core
  unit tests pass; release build clean.

Stack: applies on top of `openai-compat` (PRs A-E). Likely a separate
upstream PR (PR F).
… truth)

Drop the dual storage (server's env-var EmbeddingService + core's
hash-fallback EmbeddingGenerator). graphrag-core's RetrievalSystem,
HybridRetriever, and SemanticChunker now hold a single
DynEmbedder field — defaulting to a HashEmbedder sized to
Config.embeddings.dimension and replaced by set_embedding_provider
when a host wires a real one. graphrag-server constructs its
EmbeddingService from graphrag_core::config::EmbeddingConfig and
keeps the live Config in AppState (Arc<RwLock<Config>>).
embeddings is Arc<ArcSwap<EmbeddingService>> so /config can swap
it atomically without touching the 16 read sites.

User-visible effects:

- /config, /health, and /embeddings/stats all read state.config.embeddings.
  No more "/config says hash but runtime is using openai" drift.
- POST /config rebuilds the embedder, probe-embeds, and rejects 400 if
  the returned vector length doesn't equal config.embeddings.dimension
  (catches the silent Qdrant-corruption case at config time, not at
  the next /api/documents POST).
- POST /config also rejects 400 when backend != hash and the configured
  upstream isn't reachable (catches the "fell through to hash silently"
  case the old code papered over).
- One unified log line ("embeddings: backend=X model=Y dim=Z endpoint=...
  (live|fallback=hash)") prints at boot and after every successful
  /config swap; the two older misleading lines are gone.
- /api/embeddings/stats moved to /embeddings/stats (apistos /api scope
  shadowed the old path — same workaround as /config → /config).
- /health JSON now carries an `embeddings` block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dataO1 added a commit to dataO1/graphrag-rs that referenced this pull request May 3, 2026
…ngs refactor

Scope grew beyond the original PR F draft (which was just 4e8c6ff —
inject the real embedder). Filed PR includes both 4e8c6ff and the
follow-up d74116f (unify around Config.embeddings, drop dual storage,
atomic POST /config swap, dim validation, /api/embeddings/stats →
/embeddings/stats route move, /health.embeddings block).

Stacked on automataIA#12 (LightRAG) because d74116f's main.rs already carries
PR D/E content and cherry-picking onto a shallower base re-introduces
conflicts. Conceptual dependency is only on automataIA#9 (PR B's
EmbeddingService); the chain through 10/11/12 is a base artifact.

End-to-end validated against live OVMS+NPU: 52 passed / 0 failed,
including new backend-switching test (POST /config flips backend
atomically across /config + /embeddings/stats + /health.embeddings;
dim mismatch returns HTTP 400 with no state change).

Also delete the stale PR-F-DRAFT.md scratch file.
@automataIA automataIA force-pushed the main branch 2 times, most recently from d39471e to 84ef833 Compare May 31, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant