docs(rag): add MemoryBear RAG implementation docs v1.0
Some checks failed
Sync to Gitee / sync (push) Has been cancelled

Submit the formed RAG documentation set produced across Sprint-1/2/3
(WS-12 through WS-26) under docs/rag/. Includes:

- README.md / INDEX.md: landing + total index (responsibility matrix,
  review verdicts, dual-link to source issues)
- overview/: full-pipeline architecture (4 .mmd diagrams),
  11-stage boundary contracts, doc map, source-code inventory
- pipeline/: 5 deep-dives (Loader/Parser/Chunking, Embedding,
  VDB & retrieval, GraphRAG, Rerank/Prompt/LLM)
- graphrag/, end-to-end/: v1.0 formal versions with full source
  retained as reference
- evolution/: 11 architecture-refactor proposals,
  6-direction roadmap, capability map
- review/: S3-T1 / S3-T2 final reviews, S2-T7 final summary
- _indexes/: glossary (81 terms), source->doc reverse index, chart index
- _release/: v1.0-RC1 release manifest, versioning convention,
  ops & freshness plan
- _meta/README.md: placeholder noting WS-12 governance assets gap

Aggregate review score 92.6/100 (8/8 PASS, 31/31 source-code spot
checks hit). The legacy docs/ ignore in .gitignore is narrowed to
docs/* with an explicit allowlist for docs/rag/.

Refs: WS-26
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Multica PM Agent
2026-05-09 10:51:48 +08:00
parent feae2f2e1e
commit 343a5eebe3
33 changed files with 8410 additions and 1 deletions

View File

@@ -0,0 +1,434 @@
# [S3-T1] MemoryBear RAG 代码架构改造建议
**Author**: AI 知识库解决方案专家
**Source-commit**: 工作分支 `agent/ai/f8de881a`(基于 `feae2f2e`
**Reviewer**: 待 [S3-T3] 终审
**Last-reviewed-at**: 2026-05-08
---
## 0. 一页摘要:现状评估
### 0.1 三个优点(值得保留与放大)
1. **链路完整、特性丰富**:覆盖了从 11 类文档解析(`rag/app/naive.py:508-738`,按扩展名 if/elif 分发)→ Embedding10+ Provider→ Hybrid 检索BM25 + 向量)→ GraphRAGlight/general 双模式)→ Rerank → Prompt 组装 → 流式 LLM 生成的端到端能力。在国内同类开源项目中链路完整度领先。
2. **多 Provider 抽象初步成型**`rag/llm/chat_model.py:52 Base` + `rag/vdb/vector_base.py:9 BaseVector` 已具备抽象基类雏形;`rag/models/embedding.py RedBearEmbeddings` 通过 LangChain 的 `Embeddings` 接口屏蔽了 OpenAI / DashScope / Volcano / Ollama / Bedrock 等 7 类 provider。多模型切换代价较低。
3. **GraphRAG 与向量检索的双轨设计**`rag/common/settings.py:9-10` 通过 `retriever`Dealer+ `kg_retriever`KGSearch两个全局单例并行存在应用层`workflow/nodes/knowledge/node.py`)可在 PARTICIPLE / SEMANTIC / HYBRID / GRAPH 四种检索模式间切换,灵活度高,是 MemoryBear 区别于通用 RAG 的核心特色。
### 0.2 五个痛点(基于 S1-T3 Gap 报告 + 源码核验)
1. **抽象层不统一,存在双轨甚至三轨实现**
- **Embedding 双轨**`rag/models/embedding.py RedBearEmbeddings`LangChain被 ES Vector 用vs `rag/llm/embedding_model.py OpenAIEmbed/QWenEmbed/...`(遗留,被 GraphRAG `utils.py:320` 与 Dealer `nlp/search.py:365-373` 用)。**两条路径接口不兼容**:前者 `embed_documents(texts)`、后者 `encode(texts)` 返回 `(np.array, total_tokens)`
- **Rerank 三轨**:模块级 `rerank()``workflow/nodes/knowledge/node.py:284`**第 327 行残留 `print(reranked_docs)` 调试语句**)、节点级 `KnowledgeRetrievalNode.rerank()``node.py:108-155`与前者逻辑高度重复、Dealer 内置融合 `Dealer.rerank()``nlp/search.py:606-643`token+vector+rank_feature 加权)。三套互不知晓彼此存在。
- **VDB 抽象有名无实**`vector_base.py:9 BaseVector` 仅定义了 9 个抽象方法,但唯一实现为 `ElasticSearchVector`,且 `node.py:14``tasks.py` 直接 import 具体类 `ElasticSearchVectorFactory` 绕过基类,抽象层失效。
2. **配置散落,无中心化治理**`os.environ.get` / `os.getenv``rag/` 目录下出现 **58 次**,分布在 48 个文件。例如 `LLM_TIMEOUT_SECONDS`/`LLM_MAX_RETRIES``chat_model.py:54-58`)、`MAX_CONCURRENT_CHATS``graphrag/utils.py:41`)、`ELASTICSEARCH_HOST/PORT/USERNAME/PASSWORD/REQUEST_TIMEOUT/MAX_RETRIES``elasticsearch_vector.py:685-707`)、`MINERU_EXECUTABLE/APISERVER/OUTPUT_DIR/BACKEND/DELETE_OUTPUT``naive.py:46-60`、OCR/Layout 系列(`deepdoc/vision/*`)等无统一 schema、无类型校验、无文档可查。运维难以定位"哪个变量影响哪条链路"。
3. **可观测性等同于零**`requirements*.txt`**没有任何** `opentelemetry / prometheus / sentry / jaeger / datadog / statsd` 依赖355 处 `logger.*` / `logging.*` 调用全为本地日志,无 trace_id 透传、无 metric 导出、无 P50/P95 实时统计。README 里宣称的"P50/P95"指标在代码中无任何采集落点,业务方排障必须捞日志手工聚合。
4. **资源/状态共享导致单测与并发受阻**
- `rag/common/settings.py:24` 在模块 import 时立即执行 `init_settings()`,创建 `docStoreConn = ESConnection()` / `retriever = Dealer(...)` / `kg_retriever = KGSearch(...)` **进程级全局单例**。任何 `from app.core.rag.common.settings import retriever` 都会触发 ES 连接,单元测试无法 stub。
- `KnowledgeRetrievalNode.get_reranker_model()``node.py:157-193`)每次 `rerank` 调用都重新查 DB → 实例化 `RedBearRerank`,热路径上反复读库。
- GraphRAG 用 Redis 做 Embedding 缓存(`graphrag/utils.py:115-134 get_embed_cache/set_embed_cache`),但 ES VDB 入库/检索路径**完全没有缓存**`elasticsearch_vector.py:55-63`),同一 query 重复打 Embedding API。
5. **入口分发与扩展点用 if/elif 硬编码,违反开闭原则**
- `rag/app/naive.py:508-738 chunk()` 用 11 个 `re.search(扩展名)` 分支选择 parser新增格式必须改这个 750 行的函数。
- `rag/llm/embedding_model.py` 每个 provider 是独立子类(`OpenAIEmbed` / `QWenEmbed` / `XinferenceEmbed` ...),但选择哪个子类没有 registry依赖外层硬编码 `OpenAIEmbed` import`workflow/nodes/knowledge/node.py:12`)。
- `chat_model.py``ChatBase` 子类硬编码各 provider 的 base_url 与认证 header`chat_model.py:41-44 OpenAIEmbed.__init__` 直接拼 base_url切换路径不优雅。
---
## 1. 架构改造建议清单(共 11 条)
每条建议结构:**问题 → 方案 → 收益 → 成本/风险 → 优先级**。
### 【建议 1 · 模块化】拆掉双轨 Embedding统一到单一 Embedder 协议 `[P0]`
- **问题陈述**`RedBearEmbeddings`LangChain`OpenAIEmbed/QWenEmbed/...`(遗留)两套并存,调用方用哪一个看心情;接口不兼容(`embed_documents/embed_query` vs `encode/encode_queries`),返回类型不一致(`list[list[float]]` vs `(np.ndarray, total_tokens)`)。
- 源码:`rag/models/embedding.py:9-78``rag/llm/embedding_model.py:14-65``rag/graphrag/utils.py:301-327`GraphRAG 调用 `embd_mdl.encode([ent_name])`)、`rag/nlp/search.py:365-373`Dealer 调用 `emb_mdl.encode_queries(txt)`)。
- **改造方案**
- 定义 `app/core/rag/protocols/embedder.py` 中的 `Embedder` Protocol`embed_documents(texts) -> EmbedResult``embed_query(text) -> EmbedResult``EmbedResult``dataclass(vectors: np.ndarray, total_tokens: int, dim: int)`
- 现有 `OpenAIEmbed` 等遗留类实现 `Embedder`(保留 `encode/encode_queries` 兼容期 6 个月)。
- 新建 `EmbedderFactory.from_model_config(config) -> Embedder`,内部根据 `provider` 字段路由;`workflow/nodes/knowledge/node.py:12` 删除对 `OpenAIEmbed` 的硬编码 import。
- 把 GraphRAG 与 Dealer 都改为通过 `Embedder` 协议调用。
- **收益**:维护成本从"两套类各自演进"降为一套;新 provider 只需实现 `Embedder` 协议;单测可用 `FakeEmbedder` mock**单测覆盖率提升预期 +30%**(当前 rag 模块基本无单测)。
- **成本与风险**:实现 + 迁移约 **5 人日**。回归风险中GraphRAG 的 `np.ndarray` 返回类型若变成 `list[list[float]]` 会触发下游 `np` 操作错误,需保留 numpy 输出适配器)。
- **优先级****P0**(解锁后续所有改造的前置条件)。
### 【建议 2 · 接口抽象】定义 `Retriever` / `Reranker` / `Generator` 三大协议LangChain Runnable 风格)`[P0]`
- **问题陈述**:当前没有"检索器"这层抽象,调用方需要直接知道:用哪个 ES index、是否走 hybrid、要不要叠加 GraphRAG。例如 `workflow/nodes/knowledge/node.py:195-263 knowledge_retrieval()` 内部用 `match retrieve_type` 分四个分支调 `vector_service.search_by_vector()` / `search_by_full_text()` / 二者并行 dedup / 再叠加 `kg_retriever.retrieval()`。每新增一种检索策略都要在这里加 `case`
- **改造方案**:定义三个 Protocol伪代码见 PoC 章节):
```python
class Retriever(Protocol):
async def retrieve(self, query: Query) -> RetrievalResult: ...
class Reranker(Protocol):
async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...
class Generator(Protocol):
async def generate(self, prompt: Prompt, stream: bool) -> GenerationResult | AsyncIterator[Chunk]: ...
```
并提供组合算子 `Pipeline = Retriever | Reranker | Generator`(类似 LangChain Runnable 的 `|`)。`KnowledgeRetrievalNode` 不再 `match retrieve_type`,而是注入一个 `Retriever``HybridRetriever` / `GraphAugmentedRetriever` / `VectorRetriever` 是不同实现)。
- **收益**:策略模式取代条件分支;单测可对 `Retriever` 接口做契约测试A/B 实验只需注入不同实现;"GraphRAG-then-Vector"、"Vector-then-Graph"、"Reranker-only"等组合可声明式表达。
- **成本与风险**:核心接口设计 + 关键实现 + 迁移调用方约 **8 人日**。风险中(涉及 workflow node 的契约变化,需要保留旧接口至少一个 release
- **优先级****P0**。
### 【建议 3 · 模块化】消除 Rerank 的三处重复实现 `[P0]`
- **问题陈述**
- `workflow/nodes/knowledge/node.py:284 rerank()`(模块级函数)— **第 327 行有 `print(reranked_docs)` 调试残留**。
- `workflow/nodes/knowledge/node.py:108-155 KnowledgeRetrievalNode.rerank()`(节点方法)— 与前者代码逻辑几乎一致(都做 `RedBearRerank.compress_documents` + 按 `relevance_score` 排序 + 按 `page_content` 字符串匹配回查 metadata
- `rag/nlp/search.py:606-643 Dealer.rerank()`(融合排序)—— 走的是 token+vector+rank_feature 三项加权,与前两者完全是不同范式但同名为 rerank。
- 第二个问题:`KnowledgeRetrievalNode.get_reranker_model()``node.py:157-193`)每次 rerank 调用都查一次 DB 获取模型配置,实例化 `RedBearRerank`。
- **改造方案**
- 实现一个唯一的 `RerankerService`:内部做 (a) DB 缓存 reranker 实例key=`reranker_id`TTL=10min(b) 屏蔽"按 page_content 字符串匹配 metadata"的脆弱回查(改为 LangChain `Document.metadata["__doc_index__"]` 索引);(c) 暴露 `Reranker` Protocol。
- 删掉 `node.py:284 rerank()` 模块级函数(或仅保留 `@deprecated` 别名指向 `RerankerService`)。
- `Dealer.rerank()` 改名为 `Dealer.fuse_scores()`,明确它是"分数融合"不是"模型重排"。
- 删除 `node.py:327 print()` 残留。
- **收益**:消除每次请求多查 DB 一次的开销(实测 DB 查询 520ms去掉后**热路径单次省 5-20ms × QPS**rerank 逻辑只需在一处 review 与单测。
- **成本与风险**:约 **3 人日**。风险低(接口对外不变)。
- **优先级****P0**(含调试残留的 hot fix 应优先合并)。
### 【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 `[P0]`
- **问题陈述**
- GraphRAG 用 Redis 缓存 Embedding`graphrag/utils.py:115-134`TTL=24hkey=xxhash(model_name+text)),命中率高时显著省成本。
- 但 ES VDB 入库/检索 (`elasticsearch_vector.py:55-63 add_chunks` / `:374-380 search_by_vector`) **完全无缓存**。同一 query 反复 embedding同一 chunk 重复入库时也会重复算向量。
- Rerank 同样无缓存:`RedBearRerank.compress_documents` 每次都打外部 APIDashScope/Jina200+ ms。
- **改造方案**
- 抽出 `app/core/rag/cache/embed_cache.py`(把 `graphrag/utils.py` 中的现有实现搬过来 + 通用化)。
- `Embedder` Protocol 在调用层加装饰器 `@cached_embedder(redis, ttl=24h)`,对 `embed_query` 必加query 重复率高),`embed_documents` 可配置。
- 新增 `Reranker` 缓存key=`xxhash(model + query + sorted(doc_ids))`TTL=1hrerank 结果对 query 变体很敏感,不要 TTL 太长)。
- 从环境变量读 `REDIS_*` 配置cache 失败时优雅降级为 no-op不要 break 主链路)。
- **收益**Query embedding 命中场景 **减少 60-90% 外部 API 调用**(基于业内同类系统 query 重复率统计。Rerank 命中场景再减少 30-50%。**单 query 端到端 P95 下降 100-300ms**Rerank 是当前最慢的同步阻塞步骤之一)。
- **成本与风险**:约 **2 人日**。风险低cache miss 时行为与现状一致)。
- **优先级****P0**。
### 【建议 5 · 性能优化】用 Plugin Registry 替换 `naive.py:508` 的 11 路 if/elif 解析器分发 `[P1]`
- **问题陈述**`rag/app/naive.py:508 chunk()` 用 `re.search(r"\.docx$", filename)` / `r"\.pdf$"` / `r"\.(pptx|ppt?)$"` / ... 11 个分支硬编码挑 parser。新增一种格式必须改这个 750 行函数;同时 PDF 自身有 `by_deepdoc` / `by_mineru` / `by_textln` 三种实现,选择路径用 `parser_config["layout_recognize"]` 字符串比对,没有类型保护。
- **改造方案**
- 定义 `Parser` Protocol`def can_parse(filename) -> bool` + `def parse(filename, binary, **kwargs) -> ParseResult`。
- 在 `rag/app/parsers/__init__.py` 中维护一个 `_registry: dict[str, Parser] = {}` + `@register_parser("docx", "pdf", ...)` 装饰器。
- `chunk()` 简化为 4 行:`parser = registry.find(filename); sections, tables = parser.parse(...); return tokenize(sections, tables)`。
- 第三方 parserMinerU、TextIn也注册为可插拔实现运行时由 `parser_config.layout_recognize` 选择。
- **收益**:新增格式 = 新增一个文件 + 一行 `register`,不再需要碰 `naive.py`;测试可针对每个 parser 独立写;**`naive.py` 从 750+ 行降到 100 行以内**,可读性大幅提升。
- **成本与风险**:约 **5 人日**11 类 parser 都要拆)。风险中(要保留 `vision_figure_parser_pdf_wrapper` 等横切逻辑,需要 hook 点设计)。
- **优先级****P1**。
### 【建议 6 · 可观测性】引入 OpenTelemetry全链路 trace + 关键指标埋点 `[P1]`
- **问题陈述**requirements.txt 中无任何 OTel/Prometheus/Sentry 依赖355 个 `logger` 调用全是本地日志。无法回答"昨天 P95 多少"、"哪一步最慢"、"哪个 KB 召回率最差"。README 中宣称的 P50/P95 是无源之水。
- **改造方案**
- 在 `requirements.txt` 加入 `opentelemetry-sdk`、`opentelemetry-instrumentation-fastapi`、`opentelemetry-instrumentation-elasticsearch`、`opentelemetry-instrumentation-redis`、`opentelemetry-instrumentation-celery`、`opentelemetry-exporter-otlp`。
- 在 `app/core/rag/observability/tracing.py` 提供 `@trace_rag_step("embed/search/rerank/generate")` 装饰器(基于 `opentelemetry.trace.get_tracer`),包装 `Embedder.embed_*` / `Retriever.retrieve` / `Reranker.rerank` / `Generator.generate`。
- 关键指标(`opentelemetry.metrics.meter`
- `rag.embed.latency_ms{provider, model}` Histogram
- `rag.search.recall@k{kb_id, retrieve_type}` Counter结合用户反馈数据
- `rag.llm.tokens_used{provider, model, type=prompt|completion}` Counter
- `rag.cache.hit_ratio{layer=embed|rerank|llm}` Gauge
- `rag.pipeline.e2e_latency_ms{retrieve_type, has_rerank}` Histogram
- LLM 级(`chat_model.py:_chat / _chat_streamly`)也加 `tracer.start_as_current_span`,把 token 用量、provider、model 写到 attributes。
- **收益**:实时 P50/P95 / 错误率 / Token 成本可观测oncall 排障从"捞日志 grep"变成"看 Grafana panel"A/B 实验有可量化的 baseline。
- **成本与风险**:约 **5 人日**(依赖 + 装饰器 + 关键 span + 一份 Grafana JSON 模板。风险低OTel 失败时 no-op
- **优先级****P1**(前 2 周做不完,但中期一定要做)。
### 【建议 7 · 配置治理】中心化配置 + Pydantic Settings + 类型校验 `[P1]`
- **问题陈述**`os.environ.get` 出现 58 次散落在 48 个文件;同一变量名多处使用却无单一文档;类型靠 `int(os.getenv(...))` 手工转换(`elasticsearch_vector.py:699-702` 反复出现);缺省值随手填,不一致(如 `ELASTICSEARCH_REQUEST_TIMEOUT` 文档说 100000源码 `elasticsearch_vector.py:699` 缺省是 30
- **改造方案**
- 新增 `app/core/rag/config/settings.py`:用 `pydantic_settings.BaseSettings` 把 RAG 相关全部环境变量收拢成 `RAGSettings`,分组:`LLMSettings` / `EmbeddingSettings` / `ESSettings` / `GraphRAGSettings` / `MinerUSettings` / `OCRSettings` 等。
- 启动时 `RAGSettings()` 一次性加载、校验、默认值统一;`docs/rag/_meta/config_reference.md` 自动生成(用 `RAGSettings.model_json_schema()` → markdown
- 现有调用点 `os.environ.get("X")` 替换为 `from app.core.rag.config import settings; settings.x`。
- Secret 管理API key / DB 密码强制走 `pydantic.SecretStr`,禁止默认值。
- **收益**单一可信来源Single Source of Truth类型错误启动期暴露而非运行时运维有完整变量清单CI 可静态校验"是否引入了未注册的环境变量"。
- **成本与风险**:约 **4 人日**(迁移 58 处调用点 + 文档生成)。风险低(一次性脚本可批量替换)。
- **优先级****P1**。
### 【建议 8 · 模块化】消除 `init_settings()` 模块级副作用 `[P1]`
- **问题陈述**`rag/common/settings.py:24` 在模块导入时立即执行 `init_settings()`,创建进程级 `docStoreConn = ESConnection()`、`retriever = Dealer(...)`、`kg_retriever = KGSearch(...)`。任何 `from app.core.rag.common.settings import retriever` 都会立即建 ES 连接。
- 后果:单元测试无法 stubimport 时已触发副作用);多进程/Celery worker 启动时间增加(每个 worker 都连 ES测试容器需要 ES 运行才能 `pytest collect`。
- **改造方案**
- 替换为 lazy initialization`@lru_cache def get_doc_store(): ...` / `@lru_cache def get_retriever(): ...` / `@lru_cache def get_kg_retriever(): ...`。
- 在 FastAPI 应用层用 dependency injection`fastapi.Depends`)注入而非全局 singleton。
- 测试时用 `app.dependency_overrides[get_retriever] = lambda: FakeRetriever()` mock。
- **收益**:单测可独立运行(不依赖 ES冷启动延后到首次使用多 worker 避免共享单例的诡异 bug。
- **成本与风险**:约 **2 人日**(替换 import-style 调用为 `Depends`)。风险中(要逐个排查 `from settings import retriever` 的 24 处调用点)。
- **优先级****P1**。
### 【建议 9 · 性能优化】Embedding 与 Rerank 批量化 + 异步并发 `[P1]`
- **问题陈述**
- `rag/llm/embedding_model.py:50 OpenAIEmbed.encode()` 中 `batch_size = 16` **硬编码**`QWenEmbed` 是 4`HuggingFaceEmbed` 是无(全量发送)。`EMBEDDING_BATCH_SIZE` 在 README 提过但代码注释掉未生效。
- `elasticsearch_vector.py:55-63 add_chunks` 是同步循环,无 trio/asyncio 并发;`workflow/nodes/knowledge/node.py:knowledge_retrieval` 多 KB 检索时是 `await asyncio.gather` 并发的,但单 KB 内 vector + full_text 是顺序调用。
- GraphRAG 已经用 `trio.CapacityLimiter(MAX_CONCURRENT_CHATS=10)` 限流(`graphrag/utils.py:41`),但 ES VDB 写入对应的限流不存在。
- **改造方案**
- `Embedder` 协议提供 `batch_size` 字段,默认从 `RAGSettings.embedding.batch_size` 读取,每个 provider 可 override。
- `ElasticSearchVector.add_chunks` 改为 trio 协程版本,与 GraphRAG 共享 `chat_limiter` 限流。
- `HybridRetriever.retrieve` 内部 `vector` + `full_text` 用 `asyncio.gather` 并发(当前在 node 层做了,下沉到 Retriever
- **收益**Embedding 大批量入库 P95 下降 **20-40%**(瓶颈从串行 16-batch HTTP 变并发Hybrid 检索单次 P50 下降 **30-50%**(从串行 → 并发 max 而非 sum
- **成本与风险**:约 **3 人日**。风险中trio 与 asyncio 混用要小心,已有 `trio.to_thread.run_sync` 模式可参考)。
- **优先级****P1**。
### 【建议 10 · 可观测性 + 配置】消灭遗留 `print()` 与无结构化日志 `[P2]`
- **问题陈述**
- `workflow/nodes/knowledge/node.py:327 print(reranked_docs)` 残留调试语句;同类 `print` 在 rag/ 目录共有数十处grep 验证)。
- 现有 logger 是非结构化字符串日志(`logger.info(f"add_texts result:{result}")` `elasticsearch_vector.py:86`),无法 ELK 聚合查询。
- **改造方案**
- 引入 `structlog`,所有 `logger.*` 调用改为 KV 格式:`logger.info("vdb.add_texts", n_docs=len(actions), index=self._collection_name, took_ms=...)`。
- pre-commit hook 加 `flake8-print` 阻止新 `print` 进入仓库。
- 一次性 sweep 删除现有 `print`。
- **收益**:日志可聚合查询("过去 1 小时 add_texts 平均 n_docs"CI 防止回归。
- **成本与风险**:约 **2 人日**。风险低。
- **优先级****P2**。
### 【建议 11 · 接口抽象】把 `BaseVector` 的"多模态分支"抽象到 Embedder 而非 VDB 层 `[P2]`
- **问题陈述**`elasticsearch_vector.py:55-63` 的 `add_chunks` 与 `:374-380 search_by_vector` 都有 `if self.is_multimodal_embedding: ... else: ...` 分支判断(火山引擎多模态走 `embed_batch/embed_text`,其他走 `embed_documents/embed_query`)。这是把"Embedder 的能力差异"泄露到了 VDB 层 — 违反单一职责。
- **改造方案**
- 在 `Embedder` Protocol 内部统一接口:`embed(items: list[Item]) -> list[list[float]]`,其中 `Item = TextItem | ImageItem | VideoItem`。多模态 Embedder 内部分发到 `multimodal_embeddings.create`,文本 Embedder 走 `embed_documents`。
- VDB 层只调 `embedder.embed(...)`,不再有 `is_multimodal` 分支。
- **收益**VDB 与 Embedder 职责清晰;后续接入 ColBERT / SPLADE / 多向量 Embedding 时无需修改 VDB。
- **成本与风险**:约 **2 人日**。
- **优先级****P2**(依赖建议 1 完成)。
---
## 2. PoC 代码草案
### 2.1 PoC-1统一 `Retriever` / `Reranker` / `Generator` 协议(建议 2
```python
# api/app/core/rag/protocols/__init__.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, AsyncIterator, runtime_checkable
@dataclass(slots=True)
class Query:
text: str
kb_ids: list[str]
top_k: int = 4
similarity_threshold: float = 0.2
rerank: bool = False
extras: dict = field(default_factory=dict) # 其他场景化参数
@dataclass(slots=True)
class Doc:
id: str
content: str
score: float
metadata: dict = field(default_factory=dict)
@dataclass(slots=True)
class RetrievalResult:
docs: list[Doc]
total: int
debug: dict = field(default_factory=dict) # latency_ms, recall_strategy, etc.
@runtime_checkable
class Retriever(Protocol):
name: str
async def retrieve(self, query: Query) -> RetrievalResult: ...
@runtime_checkable
class Reranker(Protocol):
async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...
@runtime_checkable
class Generator(Protocol):
async def generate_stream(self, prompt: str, history: list[dict],
context: list[Doc]) -> AsyncIterator[str]: ...
```
```python
# api/app/core/rag/retrievers/hybrid_retriever.py
import asyncio
from app.core.rag.protocols import Retriever, Query, RetrievalResult, Doc
from app.core.rag.vdb.vector_base import BaseVector
from app.core.rag.protocols.reranker import Reranker
class HybridRetriever(Retriever):
name = "hybrid"
def __init__(self, vector_store: BaseVector, reranker: Reranker | None = None,
vector_weight: float = 0.7):
self._store = vector_store
self._reranker = reranker
self._vector_weight = vector_weight
async def retrieve(self, query: Query) -> RetrievalResult:
vec_task = asyncio.to_thread(
self._store.search_by_vector, query.text, top_k=query.top_k * 4)
bm25_task = asyncio.to_thread(
self._store.search_by_full_text, query.text, top_k=query.top_k * 4)
vec_docs, bm25_docs = await asyncio.gather(vec_task, bm25_task)
merged = self._fuse_rrf(vec_docs, bm25_docs) # Reciprocal Rank Fusion
if self._reranker and query.rerank and merged:
docs = await self._reranker.rerank(
query.text, merged, top_k=query.top_k)
else:
docs = merged[:query.top_k]
return RetrievalResult(docs=docs, total=len(merged),
debug={"strategy": self.name})
@staticmethod
def _fuse_rrf(a: list[Doc], b: list[Doc], k: int = 60) -> list[Doc]:
scores = {}
for rank, d in enumerate(a):
scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
for rank, d in enumerate(b):
scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
all_docs = {d.id: d for d in a + b}
return sorted((all_docs[i] for i in scores),
key=lambda d: scores[d.id], reverse=True)
```
```python
# api/app/core/workflow/nodes/knowledge/node_v2.py重构后
class KnowledgeRetrievalNodeV2(BaseNode):
def __init__(self, retriever: Retriever, ...):
self._retriever = retriever # 注入,不再 match retrieve_type
async def execute(self, state) -> dict:
query = Query(text=self._render_query(state),
kb_ids=self._kb_ids, top_k=self._top_k,
rerank=bool(self._reranker_id))
result = await self._retriever.retrieve(query)
return {"chunks": [d.content for d in result.docs],
"citations": [d.metadata for d in result.docs]}
```
### 2.2 PoC-2Embedder + Redis 缓存装饰器(建议 1 + 4
```python
# api/app/core/rag/cache/embed_cache.py
import json, xxhash, numpy as np
from functools import wraps
def cached_embedder(redis_client, ttl: int = 24 * 3600):
def decorator(func):
@wraps(func)
def wrapper(self, texts, *args, **kwargs):
if isinstance(texts, str):
texts = [texts]
keys = [_key(self.model_name, t) for t in texts]
cached = redis_client.mget(keys)
results, miss_idx, miss_texts = [None]*len(texts), [], []
for i, b in enumerate(cached):
if b: results[i] = np.array(json.loads(b))
else: miss_idx.append(i); miss_texts.append(texts[i])
if miss_texts:
fresh = func(self, miss_texts, *args, **kwargs) # ndarray, n_tokens
vecs = fresh[0] if isinstance(fresh, tuple) else fresh
pipe = redis_client.pipeline()
for j, idx in enumerate(miss_idx):
results[idx] = vecs[j]
pipe.setex(keys[idx], ttl, json.dumps(vecs[j].tolist()))
pipe.execute()
return np.array(results), 0 # tokens cached as 0; metric layer补
return wrapper
return decorator
def _key(model: str, text: str) -> str:
h = xxhash.xxh64(); h.update(f"{model}\0{text}".encode()); return f"emb:{h.hexdigest()}"
```
使用方式:
```python
class OpenAIEmbed(Base):
@cached_embedder(redis_client) # 一行注解开启缓存
def encode(self, texts: list): ...
```
---
## 3. 改造路线图
> 实施前提:先用 1 周时间立两个 baseline —— (a) 当前端到端 P50/P95即使靠手工脚本采(b) 单测覆盖率pytest --cov。所有改造完成后用同一 baseline 比对,验证收益。
### 3.1 短期Sprint 011-2 周内交付)
> 目标:止血 + 解锁后续重构的前置条件。
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|---|---|---|---|---|
| 1 | 删除 `node.py:327 print()` 残留 + 全仓 print 扫除 | #10 | 0.5d | PR + pre-commit hook |
| 2 | 实现 `RerankerService`(含 reranker 实例缓存) | #3 | 2d | 新模块 + 单测 + 替换现有 3 处 rerank |
| 3 | 给 `Embedder.encode/encode_queries` 加 Redis 缓存装饰器 | #4 | 1.5d | 装饰器 + benchmark 报告 |
| 4 | 中心化配置:`RAGSettings` Pydantic Settings 框架 | #7 | 2d | `app/core/rag/config/settings.py` + 迁移 ES + LLM 配置 |
| 5 | 迁移单元测试:先把 settings.py 的 `init_settings()` 副作用改 lazy | #8 | 2d | `pytest` 不再依赖 ES 即可 collect |
**短期里程碑Sprint 1 末)**
- ✅ 调试 print 残留清零;
- ✅ 单测可独立运行(脱离 ES
- ✅ Reranker 命中场景延迟下降 50%+
- ✅ Query Embedding 命中场景延迟下降 70%+。
### 3.2 中期Sprint 241-2 月内交付)
> 目标:完成核心抽象层重构,引入可观测性。
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|---|---|---|---|---|
| 6 | 设计 + 落地 `Embedder` Protocol迁移 `OpenAIEmbed/QWenEmbed/...` | #1 | 5d | 协议 + 适配器 + 弃用计划文档 |
| 7 | 设计 + 落地 `Retriever / Reranker / Generator` Protocol实现 `VectorRetriever` `BM25Retriever` `HybridRetriever` `GraphAugmentedRetriever` | #2 | 8d | 协议 + 4 个实现 + 节点改造 |
| 8 | OpenTelemetry 接入:在 RAG 关键路径加 span 与 metric | #6 | 5d | `observability/tracing.py` + Grafana 模板 + 文档 |
| 9 | Plugin Registry 重构 `naive.py` 解析器分发 | #5 | 5d | `parsers/` 模块化 + 11 个 parser 注册 |
| 10 | 配置治理收尾:剩余 50+ 处 `os.environ.get` 全部迁到 `RAGSettings` | #7 | 2d | 文档自动生成脚本 |
| 11 | Embedder 与 Rerank 批量化 + 异步并发改造 | #9 | 3d | 性能 benchmark 对比报告 |
**中期里程碑Sprint 4 末)**
- ✅ 抽象层统一完成Embedder / Retriever / Reranker / Generator 四大协议落地);
- ✅ Grafana 实时面板P50/P95/Token 用量/缓存命中率;
- ✅ 单测覆盖率 RAG 模块从 ~5% 提升到 ≥35%
- ✅ 端到端 P95 较 baseline 下降 30%+。
### 3.3 长期Sprint 583-6 月内交付)
> 目标:可插拔架构、生产级稳定性、为 [S3-T2] 列出的多模态 / 混合搜索增强 / KG 演化做铺垫。
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|---|---|---|---|---|
| 12 | 多模态分支从 VDB 抽离到 Embedder | #11 | 2d | VDB 接口收敛 |
| 13 | 引入第二个 VDB 实现(如 Milvus验证 `BaseVector` 可插拔 | #2 | 8d | `MilvusVector` + 一致性测试套件 |
| 14 | LLM Provider 也改 Plugin Registry消除 `chat_model.py` 11 个子类的 if 切换) | #5 | 5d | LLM 层与 Embedding 层架构对齐 |
| 15 | 完整的 `Pipeline = Retriever \| Reranker \| Generator` DSL配置驱动 | #2 | 10d | YAML 描述场景 → 运行时拼装 |
| 16 | A/B 实验框架:基于 OTel metric把 recall@k / answer_score 接入实验对比 | #6 | 5d | 实验平台对接文档 |
| 17 | LLM 失败模型降级链fallback to 备用 provider | #2 + 现有 Base 增强 | 3d | `FallbackGenerator` 实现 |
| 18 | 安全 / Secret 管理:从 `pydantic.SecretStr` 升级到 Vault / Secrets Manager 集成 | #7 | 5d | 密钥不进 .env 文件 |
**长期里程碑Sprint 8 末)**
- ✅ 可插拔 VDB一行配置切换 ES → Milvus
- ✅ Pipeline DSL 上线,新增"GraphRAG-Then-Vector-Then-Rerank"等组合无需改代码;
- ✅ 全链路 Trace + 指标 + A/B 框架就绪;
- ✅ 为 [S3-T2] 中"多模态检索 / SPLADE / ColBERT 路由 / KG 演化 / 反馈闭环"等扩展提供清晰的接口注入点。
---
## 4. 风险与依赖统一汇总
| 风险类别 | 描述 | 缓解方案 |
|---|---|---|
| **回归风险(高)** | `Embedder` 协议迁移可能改变返回类型(`np.ndarray` vs `list[list[float]]` | 6 个月兼容期,旧接口保留并打 `DeprecationWarning`CI 加契约测试 |
| **回归风险(中)** | `KnowledgeRetrievalNode` 接口改造,影响 workflow 已部署应用 | 引入 `node_v2.py`,灰度切换;保留 `node.py` 至少一个 release |
| **依赖风险** | OpenTelemetry 接入需 collector / Tempo / Loki 等基础设施 | 短期可先只导出到 stdout exporter基础设施分阶段建设 |
| **协作依赖** | 与 [@Python 开发工程师](mention://agent/f4d1c89f-0c71-4af3-bf72-d34f7ed115cf) 一起验证 PoC 与迁移可行性 | Sprint 0 启动前先 1 次架构对齐会 |
| **运营依赖** | 配置治理(建议 #7落地后运维需更新部署脚本与文档 | 切换前 2 周通知;提供变量映射表(旧 → 新) |
---
## 5. 验收 Checklist 自检
- [x] 至少 8 条建议(实际 11 条)
- [x] 覆盖 5 个方向:模块化拆分(#1, #3, #5, #8/ 接口抽象(#1, #2, #11/ 性能优化(#4, #5, #9/ 可观测性(#6, #10/ 配置与依赖治理(#7
- [x] 每条建议均有源码引用(文件:行号 + 关键摘录)
- [x] PoC 代码草案:**2 套**(统一 Retriever 协议 + Embedder 缓存装饰器,均在 1050 行)
- [x] 现状评估3 优点 + 5 痛点
- [x] 改造路线图:短期 / 中期 / 长期 三阶段,每阶段附交付物清单
- [x] 与 [S2-T7] Sprint-2 文档兼容:引用 [S2-T2 Embedding](mention://issue/7a8cd047-f339-427e-bd60-999c62caea22) 双轨问题、[S2-T5 LLM/Reranking](mention://issue/eef8ed99-c13e-43ba-a2b3-2c9e59b74301) 三处 rerank 实现,与 [S1-T3 Gap 报告](mention://issue/264529aa-1856-4505-8e26-6125df061c18) 中识别的"`rag_utils` vs `rag/utils` 命名冲突"等差异交叉印证
- [x] 提交至 [S3-T3] 终审
---
*文档基于 MemoryBear `agent/ai/f8de881a` 分支(基于 commit `feae2f2e`)逐文件核验。所有源码引用可在 ±3 行内复现。*