1. 从一条命令说起:DocResearch 到底在解决什么问题
第一次看到 DocResearch 这个项目名的时候,我以为又是一个"输入问题、吐出一段话"的问答玩具。真正把仓库拉下来跑通之后才发现,它想做的事情比普通问答要"重"得多——你给它一条命令,它还给你的是一份带引用出处的报告。这个差别很关键:普通问答给你的是"答案",DocResearch 给你的是"答案 + 它凭什么这么说"。
我先把它的定位讲清楚。DocResearch 本质上是一个面向文档的研究型 Agent 系统:用户丢进来一批文档(PDF、Markdown、网页存档都行),系统先把这些文档切块、向量化、存进向量库,然后由一个 Agent 负责"检索—阅读—推理—写作"这一整条链路,最后产出一份结构化的报告,报告里每一句关键结论后面都挂着来源片段。它解决的核心痛点是:大模型会一本正经地胡说,而研究报告这种场景恰恰最不能容忍胡说。你写行业分析、做竞品调研、整理技术选型材料,最怕的就是引用了不存在的资料。
那它适合谁?我梳理了一下,大概三类人用起来最舒服。第一类是做技术调研的工程师,手里攒了几十篇论文或者官方文档,想快速出一份对比结论;第二类是做内容/咨询的同学,需要基于一堆资料写报告,但又不想逐字逐句翻;第三类是想学 Agent 工程化的开发者,DocResearch 的代码结构相对干净,检索、编排、生成三段分得很清楚,拿来当 Agent 项目的学习模板很合适。
关键词里出现的Python、Agent、pgvector、Milvus基本就是它的技术骨架:Python 是主语言,Agent 是编排核心,pgvector 和 Milvus 是两个可选的向量存储后端。为什么会有两个向量库?这背后其实是一个很现实的工程取舍,我在第 2 节会展开讲。先记住一句话:DocResearch 的价值不在于"能回答",而在于"回答得有据可查",后面所有的设计都是围绕这句话转的。
2. 整体架构拆解:为什么是"检索 + Agent + 引用"这套组合
2.1 为什么不做纯 RAG,非要套一层 Agent
很多人第一反应是:这不就是个 RAG(检索增强生成)吗?检索几个片段塞进 prompt,让模型总结一下不就完了。我一开始也这么想,但真跑几个复杂问题就会发现纯 RAG 的天花板很明显。
纯 RAG 的流程是"一次检索、一次生成",问题在于:用户的问题往往不是一次检索能覆盖的。比如你问"这两个方案在并发场景下各自的取舍是什么",模型需要先找到方案 A 的并发描述,再找到方案 B 的并发描述,然后对比。一次检索很可能只召回其中一边,或者召回的都是泛泛的介绍。Agent 的价值就在这里——它可以把一个大问题拆成多个子查询,分别检索、分别阅读,最后再综合。这就是所谓"多跳检索"。
DocResearch 里的 Agent 大致承担了这几个职责:判断问题需不需要拆解、决定检索什么关键词、判断召回的内容够不够、不够就换个说法再检、最后组织成报告。这套逻辑用一句话概括就是:Agent 是"检索策略的决策者",而不是"答案的生成者"。生成只是最后一步,前面大量的工作是在决定"该看哪些材料"。
提示:如果你之前只写过"检索 + 拼接 + 生成"的线性 RAG,第一次看 Agent 版会觉得绕。别急着简化,先跑通再理解,很多"绕"是为了处理真实场景里的脏数据和不完整问题。
2.2 向量库为什么同时支持 pgvector 和 Milvus
这是我觉得 DocResearch 设计上比较务实的一点。它没有绑死一个向量库,而是同时支持pgvector和Milvus,这俩的定位完全不同。
pgvector 是 PostgreSQL 的一个扩展,把向量当成一种数据类型存进关系库。它的好处是你不需要额外维护一套系统——如果你的业务数据本来就在 Postgres 里,加个扩展就能做向量检索,事务、备份、权限全都复用现成的。缺点是数据量大了之后,向量索引的性能和扩展性会吃紧,几百万条以上就比较难受了。
Milvus 是专门的向量数据库,为大规模向量检索而生,支持多种索引类型(HNSW、IVF 等),能水平扩展。缺点是你得单独部署和维护一套服务,对个人项目或者小团队来说有点重。
所以选型逻辑很清晰:小规模、想省事、已有 Postgres,就用 pgvector;数据量大、要性能、能接受运维成本,就上 Milvus。DocResearch 把这两个都做成可插拔的,等于把选择权交给了使用者。这个设计思路值得学——别在项目里替用户做死决定,把接口抽象好,让用户按场景选。
2.3 引用溯源是怎么落地的
"有出处的报告"这句话听起来简单,实现起来要解决一个核心问题:怎么让模型生成的每句话都能对应回原文片段。
常见的做法有两种。一种是生成时带标记:检索到的每个片段都编号,让模型在生成时引用编号,比如"根据 [3],该方案在并发下……"。另一种是生成后对齐:先生成,再用相似度把句子和原文片段匹配上。DocResearch 走的是偏向前者的路子,因为后者的匹配准确率很难保证,容易出现"张冠李戴"。
具体实现上,检索阶段返回的每个 chunk 都带着元数据(来源文件、页码、chunk 序号),Agent 在组织报告时把这些元数据一起带上,最终渲染成引用列表。这里有个细节很关键:chunk 的切分粒度直接影响引用质量。切太碎,一句话被拆成三段,引用看起来支离破碎;切太大,一个 chunk 里混了好几个主题,引用就不精确。我实测下来,按语义段落切、单块控制在 300 到 500 字是个比较舒服的区间。
3. 环境搭建与核心组件实操:从零把项目跑起来
3.1 Python 环境与依赖安装的坑
DocResearch 是 Python 项目,第一步肯定是把环境弄干净。我强烈建议用虚拟环境,别直接往系统 Python 里装,不然依赖冲突能让你怀疑人生。
# 创建虚拟环境(Python 3.10 及以上,3.8 有些新库装不上) python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 升级 pip,老版本 pip 装某些包会卡住 pip install --upgrade pip # 安装项目依赖 pip install -r requirements.txt这里有几个我踩过的坑。第一,Python 版本别用 3.8,热词里有人搜"python 3.8",但 DocResearch 依赖的一些库(尤其是较新的向量库客户端)已经不支持 3.8 了,建议 3.10 或 3.11。第二,装 numpy 这类科学计算库时,如果报编译错误,多半是缺系统依赖,Linux 上先apt install python3-dev build-essential,Mac 上装好 Xcode Command Line Tools 基本就没事。第三,国内网络装包慢的话配个镜像源,这个不用我多说。
# 临时用镜像源装包 pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 用 Docker 在 Mac 上跑 Milvus Standalone
如果你决定用 Milvus 作为向量后端,最省心的方式是standalone 模式 + Docker。Milvus 官方提供了 docker-compose 配置,Mac 上(尤其是 M 系列芯片)跑起来没什么大问题。
# 下载官方 compose 文件(以官方仓库为准,版本号按需替换) wget https://github.com/milvus-io/milvus/releases/download/v2.4.x/milvus-standalone-docker-compose.yml -O docker-compose.yml # 启动 docker compose up -d # 检查容器状态,三个容器都 Up 才算正常 docker compose ps启动之后 Milvus 默认监听19530 端口(gRPC)和9091 端口(健康检查/指标)。你可以用curl http://localhost:9091/healthz看它活没活。
注意:Mac 上 Docker 默认分配的内存可能不够,Milvus standalone 建议至少给 Docker 分配8GB 内存,不然启动到一半容器会被 OOM kill,日志里能看到被杀的记录。这个坑我踩过,排查了半天以为是配置问题,结果是内存不够。
3.3 服务器 Linux 上用本地文件模式加载 Milvus
热词里有一条很具体:"服务器 linux 上使用milvus_uri: str = "./data/milvus.db"本地加载 milvus"。这说的是Milvus Lite模式——不用起服务,直接把向量存成一个本地文件。这对个人项目和小规模数据太友好了。
from pymilvus import MilvusClient # 本地文件模式,数据落在 ./data/milvus.db milvus_uri: str = "./data/milvus.db" client = MilvusClient(uri=milvus_uri) # 建集合,维度要和你的 embedding 模型对齐 client.create_collection( collection_name="doc_chunks", dimension=768, # 举例,实际看你用的 embedding 模型 )这个模式的好处是零运维,一个文件搞定。但要注意:Milvus Lite 有数据量上限,官方说法是适合百万级以下的向量,再大就得切到 standalone 或集群。另外,本地文件模式不支持多进程并发写,如果你打算多 worker 同时写库,会出问题,这种场景还是老老实实上 standalone。
3.4 pgvector 方案:已有 Postgres 就直接加扩展
如果你不想引入 Milvus,pgvector 是更轻的选择。前提是你得有个 Postgres。
-- 在 Postgres 里启用扩展 CREATE EXTENSION IF NOT EXISTS vector; -- 建表,embedding 维度按你的模型来 CREATE TABLE doc_chunks ( id BIGSERIAL PRIMARY KEY, content TEXT, source TEXT, embedding vector(768) ); -- 建 HNSW 索引,加速近似最近邻检索 CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops);这里有个关键点:索引类型和距离度量要匹配。DocResearch 里做的是语义检索,通常用余弦相似度(cosine),所以索引用vector_cosine_ops。如果你用欧氏距离,就换成vector_l2_ops。热词里有人搜"milvus 余弦值",其实说的就是同一件事——向量检索里,余弦相似度衡量的是方向一致性,对文本语义匹配最合适,因为文本向量的长度往往受文本长度影响,方向才是语义的载体。
4. 检索与生成链路:把"有出处"这件事做扎实
4.1 文档切分:决定引用质量的第一道关
前面提过 chunk 粒度的重要性,这里展开讲。文档切分不是简单地按字数硬切,那样会把一句话、一个段落拦腰截断,检索出来的片段读起来莫名其妙。
我的做法是按结构切 + 按长度兜底。先按标题、段落这些自然边界切,如果某个段落特别长(超过 800 字),再按句子边界二次切分,保证每块在 300 到 500 字之间。这样切出来的 chunk 语义完整,引用的时候读者一看就知道上下文是什么。
def split_text(text, max_len=500, min_len=300): # 先按段落切 paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] chunks = [] buffer = "" for p in paragraphs: if len(buffer) + len(p) <= max_len: buffer += ("\n\n" if buffer else "") + p else: if buffer: chunks.append(buffer) # 单段就超长,按句子再切 if len(p) > max_len: sentences = p.replace("。", "。\n").split("\n") sub = "" for s in sentences: if len(sub) + len(s) <= max_len: sub += s else: chunks.append(sub) sub = s buffer = sub else: buffer = p if buffer: chunks.append(buffer) return chunks这段代码不复杂,但它决定了后面所有环节的上限。切分做不好,检索再准、模型再强,引用也是歪的。
4.2 向量化与检索:embedding 模型怎么选
向量化就是把文本变成一串数字(向量),让语义相近的文本在向量空间里距离也近。这一步用的模型叫 embedding 模型。选型上有几个考量:维度、语言支持、速度、成本。
维度不是越高越好。768 维和 1536 维在多数场景下效果差距不大,但 1536 维的存储和计算成本翻倍。中文场景要选对中文友好的模型,有些英文模型在中文上表现会明显掉档。如果追求本地化、不想调外部接口,可以用开源的 sentence-transformers 系列,跑在本地,隐私和成本都可控。
检索的时候,把用户查询也向量化,然后在向量库里找余弦相似度最高的 top-k 个 chunk。k 取多少?我一般取5 到 10。太小了召回不全,太大了塞进 prompt 会稀释重点,还费 token。
def retrieve(query, client, embed_model, top_k=8): q_vec = embed_model.encode(query).tolist() results = client.search( collection_name="doc_chunks", data=[q_vec], limit=top_k, output_fields=["content", "source"], ) return results[0]4.3 Agent 编排:让检索"多跳"起来
这是 DocResearch 区别于普通 RAG 的核心。Agent 拿到问题后,不是直接检索,而是先"想一下"。
我把它拆成几个可复现的步骤。第一步,问题分析:判断这个问题是单点事实查询,还是需要多步推理的对比/分析类问题。第二步,查询改写:把口语化的问题改写成适合检索的关键词组合,有时候一个问题要拆成两三个子查询。第三步,检索与评估:对每个子查询检索,然后判断召回内容是否足以回答,不够就换关键词重试。第四步,综合生成:把所有有效片段组织起来,生成带引用的报告。
def research_agent(question, retriever, llm): # 1. 拆解子查询 sub_queries = llm.decompose(question) # 返回 ["子问题1", "子问题2", ...] all_chunks = [] for sq in sub_queries: chunks = retriever.retrieve(sq, top_k=6) # 2. 评估召回是否足够,不够就改写重试 if not is_sufficient(chunks, sq): sq2 = llm.rewrite(sq) chunks += retriever.retrieve(sq2, top_k=6) all_chunks.extend(chunks) # 3. 去重后生成带引用的报告 unique_chunks = dedup(all_chunks) report = llm.generate_report(question, unique_chunks) return report这套流程里,"评估召回是否足够"是最难也最有价值的一步。简单做法是看召回片段的相似度分数,低于阈值就认为不够;进阶做法是让模型自己判断"这些材料能不能回答这个问题"。我实测下来,混合判断(分数 + 模型判断)比单用任何一种都稳。
提示:Agent 多跳检索很容易陷入"无限重试"。一定要设最大跳数(比如 3 跳),到顶了就用现有材料生成,并在报告里注明"部分结论基于有限材料"。宁可诚实,不要硬编。
4.4 生成带引用的报告:prompt 怎么写
最后一步是把材料喂给模型,让它写报告。prompt 的设计直接决定引用质量。我的模板大致是这样:
你是一个严谨的研究助手。请基于以下材料回答问题,要求: 1. 每个关键结论后面用 [编号] 标注来源,编号对应材料序号。 2. 材料中没有提到的内容,不要编造,直接说"材料未涉及"。 3. 如果不同材料有冲突,指出冲突并分别标注来源。 问题:{question} 材料: [1] {chunk_1_content} (来源:{source_1}) [2] {chunk_2_content} (来源:{source_2}) ...这里最关键的一条是**"材料未涉及就说未涉及"。不加这条约束,模型会习惯性地"补全"信息,引用就假了。加了之后,报告里会出现一些"材料未涉及"的句子,看起来不够"满",但这才是真实研究报告该有的样子**。
5. 常见问题与排查技巧实录
5.1 检索召回不准怎么办
这是最高频的问题。表现是:明明文档里有答案,检索就是召不回来。排查顺序我一般这样走。
先看切分。把召回的 chunk 打出来读一遍,如果读起来语义不完整,那就是切分的问题,回去调 chunk 大小和切分策略。再看embedding 模型。如果文档是中文、模型是纯英文的,召回质量会明显差,换中文友好的模型。最后看查询本身。用户的口语化问题直接拿去检索,效果往往不好,加一层查询改写,把"这俩哪个好"改成"方案 A 方案 B 对比 优缺点",召回率能明显提升。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 答案在文档里但召不回 | 切分过碎/过大 | 打印 chunk 检查语义完整性 |
| 中文文档召回差 | embedding 模型不匹配 | 换中文友好模型 |
| 口语化问题召回差 | 查询未改写 | 加查询改写层 |
| 召回内容重复 | 文档有重复段落 | 入库前去重 |
5.2 向量库连接与性能问题
用 Milvus standalone 的时候,最常见的报错是连不上。先确认容器状态,docker compose ps看三个容器是不是都 Up。如果 Milvus 容器反复重启,八成是内存不够,去 Docker 设置里加内存。如果连上了但检索很慢,检查索引建了没有——Milvus 默认可能用 FLAT(暴力检索),数据量一大就慢,要手动建 HNSW 或 IVF 索引。
用 pgvector 的话,常见问题是忘了建索引,几万条数据全表扫描,慢得离谱。建了 HNSW 索引之后速度会有数量级的提升。另外 pgvector 的索引要在数据插入之后建,先建索引再大批量插入会拖慢插入速度。
5.3 Agent 跑飞了怎么兜底
Agent 系统最怕的就是"跑飞"——无限循环、疯狂调接口、最后超时。我的兜底策略有三层。第一层是最大跳数限制,前面说过,硬性截断。第二层是单次请求超时,每个 LLM 调用和检索调用都设超时,超了就跳过。第三层是降级生成,如果 Agent 中途失败,用已经拿到的材料直接生成一份"简化版报告",而不是整个失败。
注意:Agent 的每一步都要打日志,记录它拆了什么子查询、检索了什么、为什么重试。出问题的时候,日志是你唯一的救命稻草。我见过太多人 Agent 跑飞了却不知道飞在哪一步,就是因为没打日志。
5.4 引用对不上的排查
报告里的引用编号和实际来源对不上,通常是chunk 编号在传递过程中错位了。排查方法是:在生成前把"编号 → chunk 内容 → 来源"的映射表打出来,生成后再核对一遍报告里的引用。如果错位,检查去重和拼接的逻辑,很多时候是去重之后编号没重新排。
6. 一些实操心得与扩展方向
跑通 DocResearch 之后,我最大的体会是:"有出处"这三个字看着简单,做起来全是细节。切分、检索、评估、生成,每一环都会影响最终的引用质量,而且这些环节是串联的,前面歪一点,后面就放大。所以别指望一次调好,做好迭代的准备。
关于扩展,我自己试过几个方向,效果还不错。一个是加缓存,相同或相似的查询直接返回缓存结果,省时省钱,尤其是调试阶段反复问同一个问题的时候。另一个是加多轮对话,让用户能对报告追问,Agent 基于已有材料继续回答,这个体验比一次性出报告好很多。还有一个是报告模板化,不同场景(技术调研、竞品分析、文献综述)用不同的输出结构,让报告更贴合实际用途。
最后分享一个小技巧:调试阶段把 top_k 调大、把中间结果全打出来,虽然慢,但能让你看清 Agent 到底在干什么。等逻辑稳定了,再把参数收回去。很多人一上来就追求"快",结果出了问题两眼一抹黑,反而更慢。先把链路看透,再谈优化,这是我做了这么多 Agent 项目最实在的一条经验。