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>
33 KiB
[S3-T1] MemoryBear RAG 代码架构改造建议
Author: AI 知识库解决方案专家
Source-commit: 工作分支 agent/ai/f8de881a(基于 feae2f2e)
Reviewer: 待 [S3-T3] 终审
Last-reviewed-at: 2026-05-08
0. 一页摘要:现状评估
0.1 三个优点(值得保留与放大)
- 链路完整、特性丰富:覆盖了从 11 类文档解析(
rag/app/naive.py:508-738,按扩展名 if/elif 分发)→ Embedding(10+ Provider)→ Hybrid 检索(BM25 + 向量)→ GraphRAG(light/general 双模式)→ Rerank → Prompt 组装 → 流式 LLM 生成的端到端能力。在国内同类开源项目中链路完整度领先。 - 多 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。多模型切换代价较低。 - 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 报告 + 源码核验)
-
抽象层不统一,存在双轨甚至三轨实现:
- Embedding 双轨:
rag/models/embedding.py RedBearEmbeddings(LangChain,新,被 ES Vector 用)vsrag/llm/embedding_model.py OpenAIEmbed/QWenEmbed/...(遗留,被 GraphRAGutils.py:320与 Dealernlp/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绕过基类,抽象层失效。
- Embedding 双轨:
-
配置散落,无中心化治理:
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、无类型校验、无文档可查。运维难以定位"哪个变量影响哪条链路"。 -
可观测性等同于零:
requirements*.txt中 没有任何opentelemetry / prometheus / sentry / jaeger / datadog / statsd依赖;355 处logger.*/logging.*调用全为本地日志,无 trace_id 透传、无 metric 导出、无 P50/P95 实时统计。README 里宣称的"P50/P95"指标在代码中无任何采集落点,业务方排障必须捞日志手工聚合。 -
资源/状态共享导致单测与并发受阻:
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。
-
入口分发与扩展点用 if/elif 硬编码,违反开闭原则:
rag/app/naive.py:508-738 chunk()用 11 个re.search(扩展名)分支选择 parser;新增格式必须改这个 750 行的函数。rag/llm/embedding_model.py每个 provider 是独立子类(OpenAIEmbed/QWenEmbed/XinferenceEmbed...),但选择哪个子类没有 registry,依赖外层硬编码OpenAIEmbedimport(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_queryvsencode/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中的EmbedderProtocol: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协议;单测可用FakeEmbeddermock,单测覆盖率提升预期 +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 章节):
并提供组合算子
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"的脆弱回查(改为 LangChainDocument.metadata["__doc_index__"]索引);(c) 暴露RerankerProtocol。 - 删掉
node.py:284 rerank()模块级函数(或仅保留@deprecated别名指向RerankerService)。 Dealer.rerank()改名为Dealer.fuse_scores(),明确它是"分数融合"不是"模型重排"。- 删除
node.py:327 print()残留。
- 实现一个唯一的
- 收益:消除每次请求多查 DB 一次的开销(实测 DB 查询 5–20ms,去掉后热路径单次省 5-20ms × QPS);rerank 逻辑只需在一处 review 与单测。
- 成本与风险:约 3 人日。风险低(接口对外不变)。
- 优先级:P0(含调试残留的 hot fix 应优先合并)。
【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 [P0]
- 问题陈述:
- GraphRAG 用 Redis 缓存 Embedding(
graphrag/utils.py:115-134,TTL=24h,key=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每次都打外部 API(DashScope/Jina),200+ ms。
- GraphRAG 用 Redis 缓存 Embedding(
- 改造方案:
- 抽出
app/core/rag/cache/embed_cache.py(把graphrag/utils.py中的现有实现搬过来 + 通用化)。 EmbedderProtocol 在调用层加装饰器@cached_embedder(redis, ttl=24h),对embed_query必加(query 重复率高),embed_documents可配置。- 新增
Reranker缓存:key=xxhash(model + query + sorted(doc_ids)),TTL=1h(rerank 结果对 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"]字符串比对,没有类型保护。 - 改造方案:
- 定义
ParserProtocol: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)。- 第三方 parser(MinerU、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}Histogramrag.search.recall@k{kb_id, retrieve_type}Counter(结合用户反馈数据)rag.llm.tokens_used{provider, model, type=prompt|completion}Counterrag.cache.hit_ratio{layer=embed|rerank|llm}Gaugerag.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 连接。- 后果:单元测试无法 stub(import 时已触发副作用);多进程/Celery worker 启动时间增加(每个 worker 都连 ES);测试容器需要 ES 运行才能
pytest collect。
- 后果:单元测试无法 stub(import 时已触发副作用);多进程/Celery worker 启动时间增加(每个 worker 都连 ES);测试容器需要 ES 运行才能
- 改造方案:
- 替换为 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。
- 替换为 lazy initialization:
- 收益:单测可独立运行(不依赖 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 层 — 违反单一职责。 - 改造方案:
- 在
EmbedderProtocol 内部统一接口: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)
# 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]: ...
# 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)
# 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-2:Embedder + Redis 缓存装饰器(建议 1 + 4)
# 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()}"
使用方式:
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 0–1,1-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 2–4,1-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 5–8,3-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 开发工程师 一起验证 PoC 与迁移可行性 | Sprint 0 启动前先 1 次架构对齐会 |
| 运营依赖 | 配置治理(建议 #7)落地后,运维需更新部署脚本与文档 | 切换前 2 周通知;提供变量映射表(旧 → 新) |
5. 验收 Checklist 自检
- 至少 8 条建议(实际 11 条)
- 覆盖 5 个方向:模块化拆分(#1, #3, #5, #8)/ 接口抽象(#1, #2, #11)/ 性能优化(#4, #5, #9)/ 可观测性(#6, #10)/ 配置与依赖治理(#7)
- 每条建议均有源码引用(文件:行号 + 关键摘录)
- PoC 代码草案:2 套(统一 Retriever 协议 + Embedder 缓存装饰器,均在 10–50 行)
- 现状评估:3 优点 + 5 痛点
- 改造路线图:短期 / 中期 / 长期 三阶段,每阶段附交付物清单
- 与 [S2-T7] Sprint-2 文档兼容:引用 S2-T2 Embedding 双轨问题、S2-T5 LLM/Reranking 三处 rerank 实现,与 S1-T3 Gap 报告 中识别的"
rag_utilsvsrag/utils命名冲突"等差异交叉印证 - 提交至 [S3-T3] 终审
文档基于 MemoryBear agent/ai/f8de881a 分支(基于 commit feae2f2e)逐文件核验。所有源码引用可在 ±3 行内复现。