我见过不少RAG项目,demo阶段惊艳全场,一接真实业务就垮掉。垮的地方通常不在检索算法,而在代码结构——数据接入、分块、向量化、存储、生成,整个链路焊死在一起,换任何一块都得动全身。
今天想聊的不是怎么把准确率调高两个点,而是一个更前置的问题:如何把一个RAG项目做成可插拔的知识基础设施。可插拔的意义在于数据源、分块器、Embedding模型、向量库、检索策略、生成模型都能独立替换,不互相绑架;知识基础设施的意义在于它要像楼宇供水系统一样,多个业务方拧开龙头就有水可用,而不是各挖各的井。这适合正准备把RAG推上生产的团队,也适合搭个人知识库搭到一半、已经痛恨"换个模型就要改代码"的开发者。下面按边界设计、最小骨架、踩坑复盘、多业务落地四层展开,全程以Python为例,希望能帮你绕开我踩过的那些坑。
1. 先回答一个问题:你要做RAG功能,还是RAG设施
1.1 绝大多数RAG项目活不过第二个真实场景
一个很典型的轨迹是这样的:第一个场景通常很纯粹,拿几个PDF,选一个向量库,接一个模型,演示效果惊艳,团队信心爆棚。然后第二个场景来了,业务方说文档每周更新,要能自动同步;第三个场景说,出于合规考虑,必须换成开源模型部署在内网;第四个场景说,新项目的文档要和老项目共用一套检索,但权限严格隔离。
到了这个阶段你会发现,当初那份代码里,PDF解析、分块大小、Embedding调用、向量库collection名称、提示词模板全都紧紧耦合在一起。改一个分块策略,要重新灌一遍库;换一个向量库,所有查询代码重写;换一个模型,提示词和解析逻辑全部返工。这个过程我已经见过太多回,几乎每家公司做RAG都会经历一轮。问题不出在模型选型,而出在架构姿态——你写的其实是一个一次性脚本,只是碰巧叫了一个"RAG项目"的名字。
1.2 "可插拔"不是形容词,而是三个硬指标
把RAG当设施建设,需要给自己设三个硬指标,缺一个都谈不上可插拔的知识基础设施。
第一,可替换。六个核心环节(数据接入、分块、向量化、存储、检索、生成)里的任意一个组件,都能在不改业务代码的前提下换掉。换Embedding模型不是重写检索逻辑,而是改一段配置;换向量库不是改查询代码,而是替换一个实现类。第二,可观测。任意一个用户问题,你都能追溯它走了哪条检索链路、召回到哪些chunk、最终生成了哪些引用、每段耗时花在哪里。第三,可治理。知识有版本、有归属、有更新机制,出问题能回滚,废弃数据能清掉,而不是数据越积越乱、谁都不敢动。
说到底,这三个指标约束的是设计姿态,不是某个具体技术。满足它们,你的RAG才有资格被多个业务方依赖。否则它就只是一个跑在别人服务器里的黑盒,跟"知识基础设施"四个字没有任何关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 边界设计:六段链路各自该管什么,不该管什么
2.1 从数据接入到答案生成,责任要划清
可插拔的第一步是把整个链路切开,切到每个环节都能独立存活。结合我自己的实践和社区里大量RAG项目的迭代方向,我建议至少拆成六段:
| 环节 | 职责 | 典型实现 |
|---|---|---|
| Data Connector | 从各种源头拉出原始文档 | 本地文件、Web站点、RSS、数据库、云盘 |
| Chunker | 把长文档切成适合检索的片段 | 固定长度、递归字符分割、基于语义的切块 |
| Embedder | 把文本变成向量 | Ollama上的bge-m3、OpenAI接口、本地推理的m3e |
| VectorStore | 存储向量并支持相似度查询 | Milvus、PGVector、Qdrant、Chroma |
| Retriever | 根据查询召回相关片段并排序 | 纯向量检索、向量+BM25混合、重排模型 |
| Generator | 把上下文组装成最终回答 | 开源模型、商用API、带引用格式的输出 |
这个划分不是随意切的,它符合一个基本规律:每个环节的变化频率和变化原因都不一样。数据源头说加就加,分块策略要跟着文档类型调,Embedding模型可能因为成本或效果而切换,向量库往往由公司基础设施部门统一定,检索策略要针对业务场景优化,生成模型更是隔几个月就出一版新的。变化频率不同,就必须用有效的边界隔离,否则任何一处的变化都会连带毁掉其它环节。
2.2 接口归接口,实现归实现:依赖倒置的价值
边界画好后,代码上要做的事情其实只有一件:让每个上层模块只依赖接口,不依赖具体实现。这就是典型的依赖倒置原则,用大白话说,就是"插座只认插头的形状,不认插头是哪个牌子"。
在Python里,我用Protocol而不是抽象类来定义接口,因为Python是鸭子类型语言,只要一个类实现了对应的方法签名,它就可以被当作该接口的实现,不需要继承任何东西。举个例子,一个最简单的数据连接器协议长这样:
python复制from typing import Protocol, Iterable
from dataclasses import dataclass, field
@dataclass
class Document:
content: str
metadata: dict = field(default_factory=dict)
@dataclass
class Chunk:
content: str
metadata: dict
doc_id: str
class DataConnector(Protocol):
def load(self) -> Iterable[Document]: ...
这里的关键点是DataConnector只约定"给我一个能产出Document集合的load方法",至于你读的是本地文件、爬的网页还是拉的数据库,它一概不关心。后续所有组件都按同样的思路定义协议,核心业务层里只出现这些协议类型,具体的FileConnector、WebConnector被放在注册中心里,等待装配。
2.3 一个配置写清楚整个知识库
边界和接口都定好之后,下一步就是用什么方式把这些实现装起来。我不建议在代码里手动new一堆对象然后拼装,那个做法的可维护性比硬编码好不到哪里去。更合理的做法是配置驱动:每个知识库对应一份YAML配置,所有组件的类型和参数都写在配置里,程序启动时读配置,通过注册中心把对象图构建出来。
yaml复制knowledge_base:
name: "blog_archive"
connector:
type: "file"
params:
path: "./docs/blog"
chunker:
type: "fixed"
params:
chunk_size: 800
overlap: 150
embedder:
type: "ollama"
params:
model: "bge-m3"
base_url: "http://localhost:11434"
vector_store:
type: "milvus"
params:
uri: "http://localhost:19530"
collection: "blog_archive"
dim: 1024
retriever:
type: "hybrid"
params:
vector_weight: 0.7
bm25_weight: 0.3
top_k: 10
rerank: false
generator:
type: "ollama"
params:
model: "qwen2.5:14b"
base_url: "http://localhost:11434"
这份配置本身就回答了"这个知识库能用什么、不能用什么"。业务方想换一个Embedding模型,我只改一行配置再重启,业务代码一行不动。这一步做到位,可插拔的"插"和"拔"才真正落地。
3. 从协议到插头:一个够用的最小骨架长这样
3.1 核心协议:用Protocol定义插座的形状
理论讲再多,不如直接上个能跑的骨架。下面这套代码来自我维护的一个个人知识库项目,简化掉了鉴权和监控,但保留了可插拔的完整形态。
先把六个环节的协议统一写出来:
python复制from typing import Protocol, Iterable, Optional
class Chunker(Protocol):
def split(self, doc: Document) -> Iterable[Chunk]: ...
class Embedder(Protocol):
def embed(self, texts: list[str]) -> list[list[float]]: ...
class VectorStore(Protocol):
def add(self, chunks: list[Chunk]) -> None: ...
def search(self, embedding: list[float], top_k: int,
filters: Optional[dict] = None) -> list[Chunk]: ...
def delete_by_doc(self, doc_id: str) -> None: ...
class Retriever(Protocol):
def retrieve(self, query: str, top_k: int = 10,
filters: Optional[dict] = None) -> list[Chunk]: ...
class Generator(Protocol):
def generate(self, query: str, context: list[Chunk]) -> str: ...
这里有个细节值得注意:VectorStore.search返回的是带doc_id的Chunk列表,而不是裸字段。这个设计决定了后续所有环节(混合检索、重排、评估、引用溯源)都建立在统一数据形状上,不会出现"A模块返回字典、B模块又包一层对象"的混乱局面。
有了协议之后,还需要一个注册中心。注册中心本质是"插座面板",负责把名字映射到具体的构造函数:
python复制class Registry:
def __init__(self):
self._factories: dict[str, dict[str, callable]] = {}
def register(self, kind: str, name: str, factory: callable) -> None:
self._factories.setdefault(kind, {})[name] = factory
def build(self, kind: str, name: str, params: dict):
factories = self._factories.get(kind, {})
if name not in factories:
raise KeyError(f"未注册的组件类型: {kind}: {name}")
return factories[name](params)
坚持"组件只在注册中心出现一次",后续新增一个数据源、一个分块策略,都只是往注册中心加一行,核心链路完全不动。
3.2 具体插头:文件、Ollama、Milvus、混合检索
有了插座,就要造几个像样的插头。文件连接器比较简单,它负责把目录下所有指定后缀的文件读成Document:
python复制from pathlib import Path
class FileConnector:
def __init__(self, params: dict):
self.path = Path(params["path"])
self.extensions = params.get("extensions", [".md", ".txt", ".pdf"])
def load(self) -> Iterable[Document]:
for file_path in self.path.rglob("*"):
if file_path.suffix in self.extensions:
yield Document(
content=file_path.read_text(encoding="utf-8", errors="ignore"),
metadata={"source": str(file_path)},
)
固定长度分块器是我最常用的起步方案,逻辑简单、可解释性强:
python复制class FixedSizeChunker:
def __init__(self, params: dict):
self.size = params["chunk_size"]
self.overlap = params.get("overlap", 0)
def split(self, doc: Document) -> Iterable[Chunk]:
text = doc.content
step = self.size - self.overlap
for i in range(0, len(text), step):
piece = text[i:i + self.size]
if piece:
yield Chunk(
content=piece,
metadata={**doc.metadata, "chunk_index": i},
doc_id=doc.metadata["source"],
)
向量化和生成都通过Ollama的本地接口调用。现在Ollama的/api/embed接口已经比较稳定,直接发HTTP请求就能拿embedding,不需要额外引入很重的SDK:
python复制import requests
class OllamaEmbedder:
def __init__(self, params: dict):
self.model = params["model"]
self.base_url = params.get("base_url", "http://localhost:11434")
def embed(self, texts: list[str]) -> list[list[float]]:
resp = requests.post(
f"{self.base_url}/api/embed",
json={"model": self.model, "input": texts},
timeout=60,
)
resp.raise_for_status()
return resp.json()["embeddings"]
向量库我选Milvus,因为它在企业里被用得多、性能稳定,而且Milvus Client的API对二次开发比较友好。下面的代码是简化版,只保留建集合、插入和搜索三个核心操作:
python复制from pymilvus import MilvusClient
class MilvusVectorStore:
def __init__(self, params: dict):
self.client = MilvusClient(uri=params["uri"])
self.collection = params["collection"]
self.dim = params["dim"]
if not self.client.has_collection(self.collection):
self.client.create_collection(self.collection, dimension=self.dim)
def add(self, chunks: list[Chunk]) -> None:
rows = [
{
"id": abs(hash(chunk.doc_id + str(chunk.metadata.get("chunk_index", "")))),
"vector": chunk.vector,
"text": chunk.content,
"doc_id": chunk.doc_id,
}
for chunk in chunks
if hasattr(chunk, "vector")
]
if rows:
self.client.insert(self.collection, rows)
def search(self, embedding: list[float], top_k: int,
filters: Optional[dict] = None) -> list[Chunk]:
res = self.client.search(
self.collection,
data=[embedding],
limit=top_k,
output_fields=["text", "doc_id"],
)
hits = []
for item in res[0]:
entity = item["entity"]
hits.append(Chunk(
content=entity["text"],
metadata={"score": item["distance"]},
doc_id=entity["doc_id"],
))
return hits
这里留了个伏笔:Chunk定义里没有vector字段,真正灌库之前需要把分块结果和embedding合并。合并动作我会放在装配层做,而不是让VectorStore自己负责embedding,因为这样向量库才不依赖具体Embedder的实现。
混合检索是RAG项目从demo走向实用的关键一步。只靠向量检索,遇到专有名词、编号、精确短语时往往召回不准;只靠BM25,语义相关但字面不重叠的内容又会漏掉。所以我实现了一个基于RRF(Reciprocal Rank Fusion)的HybridRetriever,它不去操心两个通道的分数尺度,只看排名:
python复制class HybridRetriever:
def __init__(self, embedder, vector_store, bm25_index, params: dict):
self.embedder = embedder
self.vector_store = vector_store
self.bm25_index = bm25_index
self.vector_weight = params.get("vector_weight", 0.7)
self.bm25_weight = params.get("bm25_weight", 0.3)
self.top_k = params.get("top_k", 10)
def retrieve(self, query: str, top_k: int = 10,
filters: Optional[dict] = None) -> list[Chunk]:
embedding = self.embedder.embed([query])[0]
vector_hits = self.vector_store.search(embedding, top_k * 2, filters)
bm25_hits = self.bm25_index.search(query, top_k * 2, filters)
return self._rrf_fusion(vector_hits, bm25_hits, top_k)
def _rrf_fusion(self, hits_a: list[Chunk], hits_b: list[Chunk],
top_k: int) -> list[Chunk]:
scores: dict[str, float] = {}
merged: dict[str, Chunk] = {}
for rank, hit in enumerate(hits_a):
merged[hit.doc_id] = hit
scores[hit.doc_id] = scores.get(hit.doc_id, 0.0) + 1.0 / (60 + rank + 1)
for rank, hit in enumerate(hits_b):
merged.setdefault(hit.doc_id, hit)
scores[hit.doc_id] = scores.get(hit.doc_id, 0.0) + 1.0 / (60 + rank + 1)
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [merged[doc_id] for doc_id, _ in ranked[:top_k]]
RRF的好处是它完全不需要知道向量相似度和BM25分数是否同分布,天然对异构分数免疫,这在实际工程里能省掉无数调归一化参数的痛苦。
3.3 组装:改配置就能换管线
所有插头都齐了,可以写装配入口了。装配代码读取配置,用注册中心构建整条链路,然后把知识库的构建结果缓存起来,供上层API调用:
python复制def build_pipeline(config: dict):
kb = config["knowledge_base"]
pipeline = Pipeline(
connector=registry.build("connector", kb["connector"]["type"], kb["connector"]["params"]),
chunker=registry.build("chunker", kb["chunker"]["type"], kb["chunker"]["params"]),
embedder=registry.build("embedder", kb["embedder"]["type"], kb["embedder"]["params"]),
vector_store=registry.build("vector_store", kb["vector_store"]["type"], kb["vector_store"]["params"]),
retriever=registry.build("retriever", kb["retriever"]["type"], kb["retriever"]["params"]),
generator=registry.build("generator", kb["generator"]["type"], kb["generator"]["params"]),
)
return pipeline
Pipeline本身也很薄,它就是按顺序调用各环节:加载文档、分块、向量化、入库,或检索、组装上下文、生成回答。真正的业务方拿到的是一个不关心内部实现的answer(question)接口,而answer(question)到底用了什么模型、什么向量库,全部由配置文件决定。这就是把RAG当设施和把RAG当脚本的分水岭。
3.4 没有评估集的基础设施都是自嗨
可插拔架构有个隐藏风险:组件非常好换,换完不知道效果是变好还是变坏了。所以我在骨架里强制自己留了一个评估入口。前期不用搞太复杂,准备二三十个真实问题,每个问题标注期望命中的文档,然后跑一个hit@k指标就够了:
python复制def evaluate(pipeline, eval_set: list[dict], top_k: int = 5) -> float:
hit = 0
for item in eval_set:
hits = pipeline.retrieve(item["question"], top_k=top_k)
expected = set(item["reference_doc_ids"])
if expected & {h.doc_id for h in hits}:
hit += 1
return hit / len(eval_set)
每次更换分块策略、Embedding模型、检索方式后,先跑一遍评估集再决定要不要上线。没有这个环节,你根本不知道一次"看起来没问题"的升级是否带来了隐性退化,也就谈不上对业务方负责。
4. 换插头时翻过的车:四个典型事故复盘
4.1 换Embedding模型:维度翻车现场
我第一次做可插拔改造的时候就翻过车。之前用OpenAI接口,向量维度是1536;后来为了内网部署换成Ollama上的bge-m3,维度变成1024。代码层面倒是顺利,因为所有逻辑都走的是接口,结果一启动就报错,错误信息指向Milvus集合的向量维度不匹配。
原因很简单:向量库的collection在建的时候就把维度固定了,而我在配置里改了Embedding模型,却忘了重建collection。那次之后我加了一条规定——collection名称里带上embedding模型的标识,比如blog_archive_bgem3_1024,并且启动时做一次维度校验,不一致就直接报错别硬跑。这么一来,换Embedding模型变成一件显式的事情:改了配置,评估集跑一遍,重建索引,再切流量。整个过程少了"运行时才发现维度不对劲"的尴尬。
4.2 换分块策略:索引里的孤儿数据
第二个坑跟分块有关。原来用固定长度分块,后来想试试语义分块,于是改了分块器配置,重新跑了一遍灌库脚本。结果检索结果里出现了大量内容重复的chunk,而且有些chunk来自旧分块策略已经不存在的内容。
原因是分块器变了以后,同一个文档生成chunk的粒度完全不同,同样是doc_id,新数据插入时会因为主键冲突插入不进去,或者更新了部分数据但旧的chunk还残留在索引里。这个问题的根源在于我没有实现"按文档删除"的能力。修复方法就是在VectorStore里加上delete_by_doc,每次灌库前先按doc_id把旧的全部删掉,再插入新的。这套机制后来演变成知识库更新的标准动作:先删、再写、最后校验。
4.3 混合检索的分数归一问
混合检索上线前,我在测试环境对比过纯向量和混合的效果,当时发现一个诡异现象:加上BM25通道之后,整体效果不仅没变好,部分问题还变差了。我一开始以为是BM25参数没调好,后来把两个通道的得分打出来看才发现,向量相似度的cosine分数基本分布在0.6到0.8之间,而BM25的原始分数动辄几十上百。两个通道直接线性加权,BM25那一路把向量那一路完全淹没了。
这让我意识到,混合检索不能简单做分数加权。后来我换成了RRF排名融合,效果立刻稳定下来,因为它只看排名不看分数,天然免疫两个通道分数尺度不一致的问题。如果某个场景确实需要线性加权,也要先做min-max归一化,把两路分数都压到0到1之间再算权重,千万别拿原始分数直接相加。
4.4 重排的延迟预算
再往后,为了把top 10的chunk进一步精排到top 5,我在Retriever后面加了重排模型,用的是bge-reranker。效果确实有提升,但代价是响应时间从300毫秒涨到了接近1.5秒。在一次内部使用体验评审上,业务方直接说"慢得受不了"。
这个问题的本质是重排有一个固定的延迟预算:它对query和每个候选项做交叉编码,候选越多越慢。我的处理方式是给重排加上显式开关,默认关闭;需要开启的业务方自己评估可接受的延迟。另外,重排前尽量把候选数量压到20到30条,不要一股脑从两路召回里各取50条再交给重排。可插拔架构里,任何"新能力"都应该有成本开关,否则很容易好心办坏事。
5. 多了几个业务方之后,设施才真正开始
5.1 先用metadata把不同的业务隔开,再谈权限
当第二个业务方接入同一个知识设施时,第一个问题一定是隔离。我早期只建了一个collection,所有团队的文档都往里面灌,检索的时候不区分归属,结果A团队的问题经常搜出B团队的内部文档,体验极其糟糕。
后来在metadata里统一加了namespace字段,每个业务方一个命名空间,检索时强制带上过滤条件:
python复制filters = {"namespace": "crm"}
hits = retriever.retrieve(question, top_k=10, filters=filters)
向量库层面可以再进一步,比如在Milvus里按partition划分数据,但核心是metadata过滤要成为检索链路的一等公民。权限控制是另一个话题,但数据隔离是权限控制的前提。如果一个知识库连"谁的知识"都分不清,后面谈再多安全都是空话。
5.2 增量更新:带上doc_id才敢做upsert
业务方一旦开始依赖知识库,全量重建就不可接受了。每周一次的"把全部文档删了重新灌"在生产环境根本顶不住。我后来的做法是增量同步:连接器每次加载文档时带上文档的updated_at时间戳,和向量库里的记录对比,只有变化的文档才走"先删旧chunk、再插入新chunk"的流程。
这套流程能成立,靠的还是前文说的doc_id机制。每个chunk都绑定了它来自哪个文档,delete_by_doc才能精准清理。我做增量同步时踩过一个坑:有些文档内容没变但路径变了,导致doc_id也跟着变,老数据就变成了孤儿。后来我把doc_id改成文档的稳定业务ID,路径只放在metadata里作为展示字段,问题才算彻底解决。
5.3 可观测性:知识设施必须有"表计"
水电气要做成管网,必须有入户表计。RAG做成知识设施也一样,没有可观测性就没法对业务方负责。我的做法是在Pipeline里挂一个简单的中间件,记录每一次问答的检索链路、召回chunk列表、每段耗时和最终生成结果,结构化输出到日志。
下面是简化版的链路信息记录逻辑:
python复制def answer_with_trace(pipeline, question: str) -> dict:
trace = {"question": question}
t0 = time.time()
chunks = pipeline.retriever.retrieve(question)
trace["retrieve_ms"] = (time.time() - t0) * 1000
trace["hit_doc_ids"] = [c.doc_id for c in chunks]
t1 = time.time()
answer = pipeline.generator.generate(question, chunks)
trace["generate_ms"] = (time.time() - t1) * 1000
trace["answer"] = answer
return trace
有些团队习惯把这些trace上报到OpenTelemetry或者Elasticsearch,有些团队一开始只写日志就够。工具不是关键,关键是你要能回答三个问题:这个问题召回了什么、为什么这么回答、慢在哪。有了这份数据,业务方提出"为什么答错了"的时候,你不需要靠猜,直接把链路记录调出来就行。这是设施和脚本之间最直观的区别。
最后分享一点个人体会。我在动手做可插拔改造之前,觉得这是一件很麻烦的事,要抽象接口、设计注册中心、写配置解析,听起来怎么都像是过度设计。真正做完以后才明白,RAG项目最贵的时候不是刚开始,而是当它被越来越多的业务方依赖、却发现自己动不了的时候。如果让我重新做一个RAG项目,我一定会第一周就把接口定死,第二周把评估集建起来,第三周再接第一个真实数据源。基础设施这种活,越晚动手,代价越高;而所谓的可插拔,说到底只是把"将来一定会变"的部分,提前留好了位置。
