最近在折腾私有知识库问答,说实话,用 DeepSeek-V3 做生成底座只是第一步,真正让知识库“能回答问题又不容易胡说”的关键,全在检索链路和上下文组织上。标题里这三个词——Hybrid RAG、Rerank、联网搜索——乍一看像是叠 buff,但每个都是实际跑过之后发现绕不开的刚需。
这篇东西不是从零讲 RAG 原理的教程,更像是我把一套可跑通的智能知识库从设计到实现完整梳理了一遍,附带核心源码思路。如果你手头也有一堆文档、工单、内部规范想着做成问答助手,或者已经搭过一版 Naive RAG 但效果一言难尽,这篇文章应该能帮你省几周的弯路。
1. 先想清楚:知识库为什么要做“混合”
1.1 纯向量检索的翻车现场
第一批做 RAG 的人基本都踩过同一个坑:把文档切块、灌进向量库、用余弦相似度召回,然后交给 LLM 回答。听起来挺顺,但实际用起来你会发现,向量检索对“语义相似”特别敏感,对“精确匹配”反而很迟钝。
举一个很典型的场景。企业内部知识库里有一句话:“退款申请需在签收后 7 天内提交”。用户实际提问是“退款单号 TS20240215 被驳回了怎么办”。这里既包含精确的工单号,又包含“退款”这个语义概念。纯向量检索会把“退款”相关的段落都捞回来,但无法保证“TS20240215”这个精确编码命中的文档排在最前面。一旦命中的不是同一篇文档,生成模型就会把两段不相关内容缝在一起,看起来头头是道,实际全是错的。
这就是纯向量检索的第一个翻车点:语义匹配不等于事实匹配。名字、型号、编号、日期、金额这类 tokens,向量空间里根本找不准。
第二个翻车点也一样要命:当你的知识库文档是几十页的规章制度、产品手册时,往往会遇到“语义上很像但事实上不是同一件事”的问题。比如用户问“怎么开增值税发票”,向量检索可能召回“发票丢失如何处理”,因为这两句话在 embedding 空间里距离很近。这种误召回靠后面的 Rerank 能纠一批,但如果没有混合检索兜底,问题在源头就发生了。
1.2 LLM 幻觉不是模型的问题,是流程的问题
DeepSeek-V3 或者任何大模型单独扔在那里,本质是一个“接龙游戏”。它没有数据库,不会主动翻文档,你给它什么上下文,它就基于什么接下去。所谓幻觉,很多时候是因为 Prompt 里的上下文压根不对,模型只能靠训练时的记忆瞎编。
RAG 的思路其实很简单:不要相信模型的记忆,把回答的依据塞进上下文里。但这里藏着另一个问题:上下文塞得越多,模型越容易顾此失彼。我之前试过把 top-10 个 chunk 全塞进去,每段 500 字,加起来 5000 字。模型确实不会说“我不知道”,但它会从最不相关的那段里挑出一个词语就开始发挥,然后整个回答跑偏。
所以关键不是“有没有 RAG”,而是“你给模型的每一条证据是不是真的可靠”。这就引出我做这套系统的三个设计原则。
1.3 三个设计原则
第一,先求全,再求准。先用向量召回和关键词召回分别取一批候选,再用融合算法合并,最后用 Rerank 模型精排。召回阶段不要指望单一检索器包打天下,宁可多召回一点,交给后面精排去过滤。
第二,知识库优先,联网搜索兜底。知识库能答的问题不要让线上搜索结果来“抢戏”,否则你无法控制答案来源。只有知识库明显没覆盖到,或者用户问题强依赖实时信息(比如“最新版本号是多少”),才触发联网搜索。
第三,一切结果可溯源。LLM 回答里的每一个关键结论,都要能对应到具体的 chunk 和原文序号。做不到这一点,做出来的东西只能叫“聊天机器人”,不叫“知识库助手”。
2. 架构设计与技术选型
2.1 一条完整请求链路
先给出这条链路,后面所有实现都是为它服务的:
- 用户输入问题
- 对问题做轻量改写,抽取出更适合检索的关键词表述
- 并行执行向量检索和 BM25 关键词检索,分别取 top-K
- 用 RRF(Reciprocal Rank Fusion)合并两路候选的排序信息
- 把合并后的候选输入 Rerank 模型,按相关性精排
- 判断知识库是否覆盖:如果最高分低于阈值,触发联网搜索
- 把知识库内容、联网搜索结果、对话历史组装成 Prompt
- DeepSeek-V3 生成答案,并在答案中给引用标注
这个链路看起来环节多,但每个环节都是可插拔的。向量库可以换,Rerank 模型可以换,联网搜索可以关掉。这种设计的好处是后续调优时可以单独替换某一环,而不用把整个系统推倒重来。
2.2 技术选型与理由
| 组件 | 选择 | 理由 |
|---|---|---|
| 生成模型 | DeepSeek-V3 | 中文理解能力强,上下文窗口大,支持 API 调用 |
| Embedding 模型 | bge-large-zh-v1.5 | 中文效果均衡,社区生态好,可以本地离线部署 |
| 向量库 | FAISS | 单机场景够用,索引保存在本地,部署最简单 |
| 关键词检索 | BM25(rank_bm25) | 无需额外服务,和 FAISS 一样嵌入式运行 |
| Rerank 模型 | bge-reranker-v2-m3 | 交叉编码器精度高,多语言支持,适合中文场景 |
| 联网搜索 | 搜索引擎官方 API | 稳定、合规,返回结构化结果 |
| 后端框架 | FastAPI | 异步支持好,方便封装成服务 |
选 FAISS 而不是 Milvus 或 Elasticsearch,是因为这套系统在单机、文档量百万字以内的场景下完全够用。FAISS 把索引保存在本地文件里,进程起来直接加载,没有任何外部依赖。如果未来数据量到了千万级,再平滑迁移到 Milvus 也不迟。关键词检索选 rank_bm25 同理,它在内存里维护一个倒排索引,几千篇文档毫秒级返回。
2.3 为什么不直接上重排全量文档
有人可能会问:既然 Rerank 模型精度高,为什么不把所有文档都丢进去跑一遍 Rerank?
原因很简单:成本和延迟受不了。Rerank 模型是交叉编码器,必须把 query 和每篇文档拼接后同时送进模型,计算复杂度是 O(N)。假设知识库有一万篇 chunk,每篇跑一次前向推理,一次检索请求的延迟就是几十秒,这还没算 GPU 占用。所以只能先用便宜高效的召回阶段做粗筛,把一万个候选缩到二三十个,再用 Rerank 做精排。这就是常见的“召回-粗排-精排”漏斗结构,搜索系统里已经验证过几十年了。
2.4 联网搜索该放进流程的哪个位置
联网搜索不能放在检索阶段一开始就并行跑,否则每次提问都会额外消耗搜索 API 的配额,而且线上结果会污染知识库答案。我把联网搜索放在 Rerank 之后,用知识库检索结果的分数做一次“门控判断”。最高分低于阈值,或者用户问题里明显包含“最新”“现在”“今天”这类实时诉求,才触发搜索。
这个阈值要调成一个保守值。宁可漏掉一些实时问题,也不要让线上结果频繁打断知识库回答。因为线上搜索返回的 snippet 质量参差不齐,稍有不慎就会把模型带偏。
3. 开发前准备
3.1 运行环境
建议 Python 3.10 以上。我这边用的是 3.11,依赖兼容性最省心。GPU 方面,Embedding 模型和 Rerank 模型都支持 CPU 运行,但 Rerank 模型在 CPU 上会比较慢,建议至少有一张普通显卡。如果没有 GPU,可以把 top-K 控制在 20 以内,CPU 上跑 bge-reranker-v2-m3 的耗时大约在几百毫秒到一秒左右,勉强可用。
内存方面,FAISS 索引和 BM25 索引都在内存里,百万字文档大概占用几百 MB,不用太担心。
3.2 依赖安装
核心依赖就这些:
pip install fastapi uvicorn faiss-cpu rank-bm25 pip install sentence-transformers FlagEmbedding pip install pypdf requests python-dotenv解释一下为什么用 sentence-transformers 而不是直接加载 embedding 模型。这个库封装了模型下载、tokenizer、归一化等细节,几行代码就能出向量。FlagEmbedding 是专用的 rerank 加载库,也能直接用 sentence-transformers 跑 CrossEncoder,但 FlagEmbedding 对 bge-reranker 系列的兼容性更好。
3.3 源码目录规划
不建议把所有代码塞一个文件里,后面维护会痛不欲生。我按链路拆成了下面几个模块:
kb_service/ ├── config.py # 全局配置,读取环境变量 ├── ingest.py # 文档加载、切分、向量化和建索引 ├── retriever.py # 向量检索、BM25检索、RRF融合 ├── reranker.py # Rerank模型封装 ├── web_search.py # 联网搜索封装 ├── generate.py # Prompt组装、DeepSeek调用 ├── api.py # FastAPI入口 └── data/ # 原始文档和索引存放目录每个模块只做一件事,替换某个组件时只动对应的文件。比如想把 FAISS 换成 Milvus,只需要改 retriever.py 里的向量检索部分,其他地方全部不动。
3.4 配置文件
密钥和模型路径不要写死在代码里,用 .env 管理:
DEEPSEEK_API_KEY=sk-xxxx DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 EMBED_MODEL=/models/bge-large-zh-v1.5 RERANK_MODEL=/models/bge-reranker-v2-m3 SEARCH_API_KEY=xxxx SEARCH_API_ENDPOINT=https://api.example.com/search VECTOR_INDEX_PATH=./data/faiss.index DOCS_CACHE_PATH=./data/chunks.json注意,Embedding 模型和 Rerank 模型路径我一般会先下载到本地再指向本地路径。好处是启动时不用每次检查网络,也方便离线环境部署。
4. 核心实现,逐段拆开看
4.1 文档切分:决定效果上限
很多人忽视切分,以为随便按固定长度切就行。实际上,chunk 质量决定了后面所有环节的天花板。切得太碎,语义不完整;切得太长,检索命中噪声大,Rerank 也容易失准。
我的切分策略是:先用段落分隔符把文档拆成自然块,再对超长段落做二次切分。切割时保留一定重叠,避免关键句正好落在边界上被拦腰截断。
import re def smart_split(text: str, max_chars: int = 500, overlap: int = 80): blocks = [] paragraphs = re.split(r"\n\s*\n", text) for para in paragraphs: para = para.strip() if not para: continue if len(para) <= max_chars: blocks.append(para) continue # 超长段落按句号进一步切分 sentences = re.split(r"(?<=[。!?;])", para) buf = "" for sent in sentences: if len(buf) + len(sent) <= max_chars: buf += sent else: if buf: blocks.append(buf.strip()) buf = sent if buf.strip(): blocks.append(buf.strip()) # 加重叠,保留上下文衔接 overlapped = [] for i, blk in enumerate(blocks): if i == 0: overlapped.append(blk) else: head = blk[:overlap] overlapped.append(head + blk) return overlapped说实话,这个切分函数对中文还比较友好,因为按句号切分基本不会把一句完整的话拆断。注意 Python 的re.split用了零宽断言(?<=[。!?;]),可以保留标点符号,不会把标点吞掉。这个细节很多人会漏,切出来的 chunk 最后几个字光秃秃的,读起来很怪。
切片之后,每个 chunk 要保存三个信息:正文内容、来源文件、原文序号。
chunk_records.append({ "content": blk, "source": file_name, "seq": idx })4.2 混合检索:向量 + 关键词 + RRF 融合
混合检索的核心不是“两个检索器都调一遍”,而是怎么把两路结果合并。我选的是 RRF(Reciprocal Rank Fusion),不用调权重参数,只依赖排序位置,比加权分数融合稳健得多。
先看向量检索部分:
from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer(EMBED_MODEL) vec = model.encode([query], normalize_embeddings=True) # FAISS 索引一定是归一化后的向量,才能用内积近似余弦相似度 scores, idxs = index.search(np.asarray(vec, dtype=np.float32), top_k=20) vector_hit_ids = [chunk_ids[i] for i in idxs[0]]BM25 部分:
from rank_bm25 import BM25Okapi # 建索引时已经构建好 bm25 = BM25Okapi(tokenized_corpus) bm25_scores = bm25.get_scores(tokenize(query)) top_rank = np.argsort(bm25_scores)[::-1][:20] bm25_hit_ids = [chunk_ids[i] for i in top_rank]这里 tokenize 建议用正则按字母数字连续串切分,再把中文按单字拆开。我没有用 jieba,原因后面在问题排查部分单说。然后 RRF 融合:
def rrf_fuse(ranked_lists: list[list[int]], k: int = 60) -> list[int]: scores = {} for ranked_list in ranked_lists: for rank, doc_id in enumerate(ranked_list): scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1) sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [doc_id for doc_id, _ in sorted_docs]RRF 的思路可以这样理解:如果一个文档在向量排序里排第 3,在关键词排序里排第 7,它得到的分值就来自 1/63 + 1/67,融合排序大概率靠前。而只在单一检索器里出现且排得很后的文档,分数很难超过双路都命中的文档。所以 RRF 天然鼓励“两路都认可”的候选,这对中文混合场景特别有用。
4.3 重排:交叉编码器补最后一刀
召回阶段得到的排序依据是向量相似度,它没有真正“理解”query 和 chunk 之间的语义互动。Rerank 模型把 query 和 chunk 拼接在一起过一遍完整 Transformer,相当于让模型做一道判断题:这两个句子到底相不相关。
from FlagEmbedding import FlagReranker reranker = FlagReranker(RERANK_MODEL, use_fp16=True) def rerank(query: str, candidates: list[dict], top_n: int = 5): pairs = [(query, c["content"]) for c in candidates] scores = reranker.compute_score(pairs, normalize=True) scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return scored[:top_n]compute_score 里面那个normalize=True会把分数归一化到 0~1 之间,方便我们后面去跟阈值比较。如果不做归一化,不同模型的分数区间差异很大,阈值没法统一。
Rerank 的输入量我固定在 20 到 30 个候选。太少的话,融合后的尾部候选没有意义;太多的话,算力开销上升明显。20 个候选在 GPU 上也就几十毫秒,完全可以接受。
4.4 联网搜索:知识库兜不住的场景交给它
联网搜索引擎我封装成独立模块,核心逻辑不是搜索本身,而是结果归一化。
import requests def search_web(query: str, count: int = 5): params = {"q": query, "count": count} headers = {"Ocp-Apim-Subscription-Key": SEARCH_API_KEY} resp = requests.get(SEARCH_API_ENDPOINT, params=params, headers=headers, timeout=10) data = resp.json() results = [] for item in data.get("webPages", {}).get("value", []): results.append({ "title": item.get("name", ""), "url": item.get("url", ""), "snippet": item.get("snippet", "") }) return results不同搜索引擎返回结构不一样,所以我在 web_search.py 里强制做了字段归一化,统一输出 title / url / snippet 三个字段。这样 generate.py 组装 Prompt 时根本不需要关心到底用的哪家搜索服务。
有没有可能只靠 snippet 就误导模型?完全可能。所以我从搜索结果的 snippet 里再额外提取正文片段会更好,但简单起见先只用 snippet。如果 snippet 截断到一半,我还在末尾加省略号,让模型知道自己看到的不是完整文本。
触发逻辑长这样:
if best_score < SEARCH_TRIGGER_THRESHOLD or needs_realtime(query): web_results = search_web(query) else: web_results = []needs_realtime就是一个简单的关键词匹配,包含“最新”“当前”“今天”“现在的价格”这类词时直接置为 True。
4.5 Prompt 组装与 DeepSeek 生成
Prompt 组织这部分,我踩过最深的坑是:把检索结果一股脑拼进去,然后发现模型根本分不清哪些是证据,哪些是用户的需求。后来我改成结构化模板,把证据区、用户问题区、指令区严格分开。
SYSTEM_PROMPT = """你是知识库助手。请严格依据给定的参考资料回答用户问题。 规则: 1. 优先使用【知识库片段】中的内容作答。 2. 如果知识库片段无法覆盖,可以使用【联网搜索结果】补充。 3. 若所有资料都不包含答案,请直接回答“根据现有资料无法回答”,不要编造。 4. 回答引用资料时,在句尾用[序号]标注来源。 """ def build_prompt(query, kb_docs, web_results): pieces = [] for i, doc in enumerate(kb_docs): pieces.append(f"[知识库{i+1}] {doc['content']} (来源: {doc['source']})") for i, res in enumerate(web_results): pieces.append(f"[联网{i+1}] {res['snippet']} (链接: {res['url']})") context = "\n\n".join(pieces) user_prompt = f"参考资料:\n{context}\n\n用户问题:\n{query}" return SYSTEM_PROMPT, user_prompt然后调用 DeepSeek-V3 的接口。这里不用额外引入复杂 SDK,用 requests 就够了:
import requests payload = { "model": "deepseek-chat", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], "temperature": 0.2, "max_tokens": 1200, "stream": False } resp = requests.post( f"{DEEPSEEK_BASE_URL}/chat/completions", json=payload, headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}"}, timeout=60 ) answer = resp.json()["choices"][0]["message"]["content"]temperature 我固定 0.2。生成类任务不是需要创意的地方,温度越高越容易把上下文里的噪声放大。
4.6 让回答自带引用
引用标注这件事,不是在生成之后才做的。如果想让引用真实可信,必须在 Prompt 里把编号和信息来源一起给模型,同时在系统指令里明确要求“句末标注”。
我在 build_prompt 里给每个片段编了号,并在指令中约定格式。实际生成效果像这样:
根据知识库资料,退货申请需签收后 7 天内提交[1];若涉及质量问题,需附检测报告[2]。
然后 API 返回后,我再对引用的编号做一次校验:如果答案里出现[x],但 x 超出了资料数量范围,说明模型在编引用,这种情况我会直接重试一次并强制降低温度。这个校验逻辑虽然简单,但能有效拦截“一本正经编引用”的漏网之鱼。
5. 实测与参数调优
5.1 怎么离线验证检索质量
不要一上来就端到端调 Prompt,先把检索质量单独拎出来测。我准备了一个小规模评测集:从知识库里挑 50 个真实用户问题,每个问题人工标注它属于哪几个 chunk。然后分别用三种模式跑检索:
- 模式 A:纯向量
- 模式 B:纯 BM25
- 模式 C:混合检索 + Rerank
统计指标就两个,召回率(命中的正确 chunk 是否出现在前 5 名里)和前排准确率(第一名是否正好对应正确答案)。这个评测集很小,但足够暴露出链路中的明显问题。我这边跑下来,模式 C 相对 A 的改善肉眼可见,尤其是在那些包含型号、编号的查询上,混合检索的领先幅度非常明显。
5.2 需要关注的几个关键参数
| 参数 | 默认值 | 调优方向 |
|---|---|---|
| 向量 top_k | 20 | 过大增加 Rerank 压力,过小漏召回 |
| BM25 top_k | 20 | 同上,按文档规模调整 |
| RRF k 值 | 60 | 增大让后排文档区分度更小 |
| Rerank top_n | 5 | 决定最终送进 Prompt 的数量 |
| chunk_size | 500 字符 | 根据文档特点调,术语密集可以缩小 |
| chunk_overlap | 80 字符 | 保证上下文衔接 |
| temperature | 0.2 | 回答偏保守时调低 |
| search_trigger_threshold | 0.45 | 知识库无法覆盖时触发搜索 |
RRF 的 k 值是最容易理解错的一个参数。k 值越大,不同名次之间的分数差异越小,后排文档越有机会参与竞争;k 值越小,排序靠前的文档优势越大。一般从 60 起步,如果想要排他性更强,就降到 30 到 40 试试。
5.3 效果观察
在实际内部场景里,我观察到几个比较一致的现象。
第一,混合检索之后,包含产品型号、订单号、内部代码的查询,命中率明显提升,因为 BM25 对这些精确 token 的匹配是“非黑即白”的,向量相似度则只能给出一个模糊的接近程度。第二,Rerank 真正解决的是“向量检索召回了主题相关的文档但顺序不对”的问题,典型表现是第一轮排序第一的 chunk 和用户问题核心诉求无关,经过 Rerank 后正确答案被翻上来。第三,联网搜索触发率默认设得很低,因为大部分知识库问题并不需要实时信息,宁可少答实时问题,也要保证回答来源可追溯。
6. 常见问题与排查速查
6.1 现象、原因、对策表
| 现象 | 原因 | 对策 |
|---|---|---|
| 检索结果为空 | 文档没进索引,或索引加载失败 | 检查 ingest 日志,确认 chunk 数大于 0 |
| 向量维度不一致 | 换过 Embedding 模型,旧索引没重建 | 删除索引文件重新 ingest |
| Rerank 延迟过高 | 候选 top_k 太大或模型跑在 CPU | 减小 top_k,或开 FP16/GPU |
| 联网搜索报 429 | 超过了搜索 API 限频 | 加指数退避重试,并压低触发率 |
| DeepSeek 调用超时 | 请求体过大或服务端繁忙 | 减少 max_tokens,超时时间放宽到 90s |
| 回答出现编造引用 | Prompt 中序号与资料不对应 | 增加引用编号校验逻辑,失败重试 |
| 切分后 chunk 重复 | 长文本多次切分时重叠叠加 | 检查切分逻辑,确保 base 块不重复拼接 |
6.2 中文切分的四个坑
中文做了几次项目后,我总结出四个必须避开的坑。
第一个坑是直接用西文空格切分。中文本身没有空格,必须从句号、感叹号、问号、分号这类边界标点入手。
第三个坑是忽略上下文衔接。切分时如果不加重叠,跨 chunk 的指代词(比如“该条款”“本规定”)就会在单独一个 chunk 里找不到指代对象,检索能命中,但生成时模型不理解“该”指的是什么。
第三个坑是一刀切 max_chars。规章制度里很多条款很短,一段只有几十字,和无脑 500 字切分完全不是一个状态。逐段切 + 超长再切,比固定长度切更适合中文文档。
第四个坑是分词工具选择。很多人给 BM25 配上 jieba,但 jieba 词表对专业术语不够友好,“退货单号”这个常用词可能被切分成“退货”和“单号”,导致搜索精度下降。我在 BM25 场景里干脆用字符级切分:连续的字母数字作为一个 token,中文按单字切。看起来粗暴,但配合 RRF 融合之后效果反而稳定,因为 BM25 的召回重点本来就在精确匹配,而字符级匹配对中文兼容性最好。
6.3 上线后要注意的稳定性问题
说几个不那么显眼但很现实的点。
Embedding 模型和 Rerank 模型加载之后要保持常驻内存,不要让每次请求都重复加载模型。FastAPI 里可以放在模块级变量,或者用 lazy singleton。否则并发一上来,内存爆炸,延迟也完全不可控。
FAISS 索引在 ingest 阶段写本地文件。生产环境要加文件锁,否则同时触发索引更新和查询请求时,可能出现索引正在被写入但查询已经读取的竞态问题。我这边用最简单的办法:更新走单独进程,更新完原子替换文件,查询进程定期重新加载。
日志要记录每一步的耗时和分数。比如向量检索耗时、BM25 耗时、Rerank 耗时、触发联网搜索的分数阈值。这些数据是后续调优的根据。没有日志,调参全靠猜,效率非常低。
7. 源码之外,我还想多聊几句
7.1 从最小闭环开始
这套系统我最想强调的其实不是“高级组件”,而是先跑通最小闭环。第一次做的时候不要一上来就接联网搜索,不要一上来就上 Rerank。先把“文档切分 → 建索引 → 向量检索 → DeepSeek 生成”这条最简链路跑通,再一格格往上加。原因很简单:链路越长,定位问题越难。如果一开始就是五六个环节串起来,某个检索分数怪怪的,你根本不知道是 Embedding 模型的问题还是 RRF 参数的问题。
我在本地调试阶段都是直接写 Python 脚本跑,确认每一步输出没问题,再用 FastAPI 封装成服务。不要迷信现成框架的一键 RAG,框架帮你省掉的复杂度,会在你排查问题时加倍还回来。
7.2 下一步可以怎么扩展
这套架构留了几个很容易接出去的扩展点。
第一个是对话记忆。目前每次请求都是独立的,用户追问“那这个流程要多久”这种指代问题会丢线索。可以在 generate.py 里把最近几轮对话拼到 Prompt 里,或者接入一个短期的对话历史存储。
第二个是 Rerank 后的阈值自适应。现在 search_trigger_threshold 是定死的,可以采集一批历史请求的分数分布,找到“知识库能答好”和“知识库答不好”之间的分界,更精细地控制联网搜索触发。
第三个是索引的增量更新。文档多了以后,全量重建成本高,可以在 ingest 阶段记录文档指纹(比如 md5),只对新变更的文档重新切分和向量化。
我个人在实际操作中的体会是,这类知识库项目的核心瓶颈永远不是模型能力,而是你对检索结果的控制力。先别急着堆功能,把一个最小闭环跑稳,让每一次检索和每一次生成都可追溯、可评价,后面所有优化才有抓手。