1. 面试官到底想听什么:DocResearch 项目的定位与考察逻辑
DocResearch 这个项目名字听起来像个学术工具,实际上它是一个典型的Agent + RAG复合型项目,核心场景是让 AI 自动完成对一批文档的检索、阅读、归纳和报告生成。面试官拿到这个项目,不会只问你“用了什么框架”,而是想确认三件事:你知不知道每个模块为什么存在,你能不能讲清楚数据在系统里怎么流动,以及你遇到瓶颈时是怎么定位和解决的。
我面过不少人,简历上写着“基于 LangChain 搭建 RAG 知识库”,一问细节就卡住。比如问他 chunk size 设了多少,他说“默认的”;问他检索命中率怎么评估,他说“感觉还行”。这种回答在 DocResearch 这类项目面试里是致命的,因为这个项目的复杂度恰好卡在“能跑”和“好用”之间,面试官要的就是你跨过那条线的经验。
DocResearch 的典型架构大致是这样:用户输入一个研究主题,Agent 拆解出若干子问题,每个子问题走 RAG 检索本地文档库,检索结果交给 LLM 做摘要和交叉验证,最后汇总成一份结构化报告。这里面涉及Agent 编排、RAG 检索、向量存储、文档解析、并发控制五个核心模块。面试官会逐个模块往下钻,钻到你说出“我试过 A 方案,发现 B 问题,最后换成 C”为止。
面试官判断你是否真做过这个项目,标准很简单:你能不能说出一个具体的失败案例,以及你从中学到了什么。背术语的人说不出失败案例。
所以这篇内容不是教你背八股,而是把 DocResearch 每个模块的“为什么”讲透,让你在面试时能用自己的话把设计决策说清楚。适合正在准备 Agent/RAG 方向面试的人,也适合刚接手类似项目、想把架构理顺的开发者。
2. 模块拆解:每个部件为什么长这样
2.1 Agent 编排模块:为什么不用一条链走到底
很多人第一版 DocResearch 会用一条 LangChain 的 SequentialChain 从头串到尾:输入主题、检索、总结、输出。跑 demo 没问题,但一上真实文档就崩。原因在于,研究型任务天然需要动态决策——有些子问题检索不到相关内容,需要换关键词重试;有些子问题之间有关联,需要交叉引用;有些文档太长,需要先做分层摘要再检索。
Agent 编排的核心价值就在这里:它把“检索-判断-再检索”这个循环交给 LLM 来控制,而不是写死在代码里。我通常用 ReAct 模式做骨架,让 Agent 自己决定下一步是调检索工具、调摘要工具,还是直接输出。这里有个关键设计点:工具的描述要写得足够具体,否则 Agent 会乱调。比如检索工具的描述不能只写“搜索文档”,而要写“根据关键词在本地文档库中检索相关段落,返回最相关的 5 条结果,适用于事实性查询”。
from langchain.agents import Tool retrieval_tool = Tool( name="doc_retrieval", func=retriever.search, description="根据关键词在本地文档库中检索相关段落,返回最相关的5条结果。" "适用于需要查找具体事实、数据、定义的场景。" "输入应为简洁的查询语句,不要输入完整句子。" )工具描述写得好,Agent 的调用准确率能提升一大截。我实测下来,把描述从一句话扩展到三句话,错误调用率从 30% 降到了 8% 左右。这个细节面试时讲出来,面试官会知道你真正调过。
另一个常被问的点是Agent 的最大迭代次数。设太小,复杂问题没检索完就停了;设太大,遇到死循环会烧掉大量 token。我的经验值是 6 到 8 轮,配合一个“连续两轮检索结果相似则强制停止”的兜底逻辑。这个兜底逻辑面试时一定要提,因为它体现你在考虑成本和稳定性,而不只是功能。
2.2 RAG 检索模块:chunk 策略决定上限
RAG 模块是 DocResearch 的重头戏,也是面试官最爱钻的地方。检索质量的上限在 chunk 阶段就决定了,后面 rerank 再强也救不回来。我见过最常见的错误是直接用RecursiveCharacterTextSplitter的默认参数,chunk size 设 1000,overlap 设 200,然后就不管了。
问题在于,DocResearch 处理的文档类型很杂,可能有技术手册、学术论文、产品说明。技术手册适合按章节切,学术论文适合按段落切,产品说明可能按条目切更好。统一用固定 size 切,会把一个完整的逻辑单元切碎,检索出来的片段缺少上下文,LLM 总结时就会断章取义。
我的做法是按文档类型走不同的切分策略,在文档入库时打上类型标签,检索时根据类型选择对应的 chunk 集合。具体参数上,技术类文档 chunk size 设 800 到 1000,overlap 设 150;叙述类文档 chunk size 设 500 到 600,overlap 设 100。overlap 的作用是防止关键信息正好落在切分边界上,设太小等于没设,设太大又会导致检索结果重复。
一个实用的自检方法:随机抽 10 个 chunk,看每个 chunk 单独拿出来能不能读懂。如果读不懂,说明切分粒度有问题。
检索策略上,纯向量检索在 DocResearch 里是不够的。向量检索擅长语义相似,但对专有名词、编号、代码标识符的匹配很弱。比如用户问“3.2 节的配置项”,向量检索可能返回一堆语义相近但编号不对的段落。所以我会加一路BM25 关键词检索,两路结果做融合。融合算法用 RRF 就行,简单有效,不需要调参。
def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, doc in enumerate(vector_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) for rank, doc in enumerate(bm25_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)RRF 的好处是不需要归一化两路分数,直接按排名融合,工程上很省心。面试时如果被问到“怎么融合多路检索结果”,答 RRF 并解释为什么不用加权求和,会显得你踩过坑——加权求和需要调权重,而权重在不同查询下最优值不一样,调起来很痛苦。
2.3 向量存储选型:pgvector 为什么够用
热词里有 pgvector,说明这个项目大概率用了 PostgreSQL 做向量存储。面试时经常被问“为什么不用 Milvus 或 Pinecone”。这个问题要分场景答:如果 DocResearch 的文档量在百万级以下,pgvector 完全够用,而且它和业务数据在同一个数据库里,做元数据过滤、事务管理都很方便。Milvus 适合亿级向量,但运维成本高;Pinecone 是托管服务,数据出域有顾虑。
pgvector 的关键配置是索引类型。数据量小于 10 万条时,用暴力搜索(精确最近邻)就行,召回率 100%。超过 10 万条,建 HNSW 索引,把m设成 16,ef_construction设成 64。查询时把ef_search设成 40 到 100 之间,根据对召回率和延迟的要求调。
CREATE INDEX ON doc_embeddings USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);这里有个坑:pgvector 的 HNSW 索引是近似最近邻,建完索引后召回率会下降。我实测过,10 万条数据下,精确搜索召回率 100%,HNSW 索引召回率大概 95% 到 98%。如果 DocResearch 对召回率要求极高,可以先用 HNSW 粗筛,再对 top 50 做精确重排。这个“粗筛+精排”的思路面试时讲出来很加分。
另一个细节是向量维度和 embedding 模型要对齐。用 OpenAI 的 text-embedding-3-small 是 1536 维,用 BGE 系列可能是 768 或 1024 维。建表时维度写死,换模型要重建表。我建议在项目里把 embedding 模型名和维度写进配置,启动时校验,避免换模型后检索结果全乱。
2.4 文档解析模块:脏数据才是真正的敌人
DocResearch 的输入文档往往不是干净的 txt,而是 PDF、Word、HTML 混在一起。PDF 解析是最大的坑,尤其是扫描版 PDF 和双栏排版的学术论文。用 PyPDF2 提取双栏论文,会把左右栏文字交错在一起,读起来完全不通顺。
我的处理流程是:先判断 PDF 是文本型还是扫描型,文本型用pdfplumber按版面提取,扫描型走 OCR。双栏文档要检测栏边界,按栏分别提取再拼接。这个逻辑写起来不复杂,但没做过的人根本想不到。
import pdfplumber def extract_pdf(path): with pdfplumber.open(path) as pdf: text = "" for page in pdf.pages: # 检测是否为双栏 words = page.extract_words() mid_x = page.width / 2 left = [w for w in words if w['x0'] < mid_x] right = [w for w in words if w['x0'] >= mid_x] if len(left) > 20 and len(right) > 20: text += " ".join(w['text'] for w in left) text += " ".join(w['text'] for w in right) else: text += page.extract_text() return text表格提取更麻烦,pdfplumber 的extract_tables对合并单元格支持不好。如果文档里表格多,建议用camelot或直接调多模态模型做版面理解。面试时如果被问到“怎么处理表格”,答“用多模态模型做版面分析,把表格转成 Markdown 再入库”是当前比较稳的方案。
文档解析阶段一定要做去重和去噪。页眉页脚、页码、水印这些噪声如果不清理,会污染检索结果。我通常用正则把重复出现的短行过滤掉。
2.5 并发与性能:Agent 项目怎么扛住并发
热词里有“ai agent 怎么扛并发”,这是 DocResearch 面试的高频问题。Agent 项目的并发瓶颈通常不在 LLM 调用,而在检索和文档解析。LLM 调用可以异步,但向量检索如果走同步数据库连接,并发一高就排队。
我的做法是三层处理:第一层,检索请求走连接池,pgvector 的查询用异步驱动asyncpg;第二层,LLM 调用用asyncio.gather并发发起,但加信号量控制并发数,避免触发 API 限流;第三层,文档解析走消息队列,入库和检索解耦,解析慢不影响在线查询。
import asyncio semaphore = asyncio.Semaphore(10) async def call_llm(prompt): async with semaphore: return await llm.ainvoke(prompt) async def process_subquestions(subquestions): tasks = [call_llm(q) for q in subquestions] return await asyncio.gather(*tasks)信号量的值设多少,取决于 API 的限流策略。我一般从 5 开始试,逐步往上加,观察有没有 429 错误。这个“逐步加压找上限”的方法面试时讲出来,比直接说“设成 10”更有说服力。
还有一个容易被忽略的点:Agent 的中间状态要持久化。DocResearch 跑一个研究任务可能耗时几分钟,如果进程崩了,中间结果全丢。我会把每个子问题的检索结果和摘要写进数据库,任务恢复时从断点继续。这个设计在面试时是加分项,因为它体现你在考虑生产环境的可靠性。
3. 实操过程:从零跑通一个 DocResearch 原型
3.1 环境准备与依赖安装
先把基础环境搭起来。Python 版本建议 3.10 以上,因为要用到asyncio的一些新特性。虚拟环境用 venv 就行,不需要 conda。
python -m venv docresearch-env source docresearch-env/bin/activate # Windows 用 docresearch-env\Scripts\activate pip install langchain langchain-community pgvector psycopg2-binary pdfplumber pip install sentence-transformers rank-bm25PostgreSQL 要装 pgvector 扩展。如果用 Docker,直接拉pgvector/pgvector:pg16镜像,省去编译麻烦。建库后执行CREATE EXTENSION vector;启用扩展。
CREATE TABLE doc_embeddings ( id SERIAL PRIMARY KEY, doc_id TEXT NOT NULL, chunk_text TEXT NOT NULL, embedding vector(768), metadata JSONB );维度 768 对应 BGE-base 模型。如果换 OpenAI 的模型,改成 1536。建表时把维度写死,后面换模型要重建,这个前面提过。
3.2 文档入库流水线
入库流程分四步:解析、清洗、切分、向量化。我把它写成一个独立的脚本,支持增量入库。
from sentence_transformers import SentenceTransformer import psycopg2 model = SentenceTransformer('BAAI/bge-base-zh-v1.5') def ingest(file_path, doc_type): text = extract_pdf(file_path) if file_path.endswith('.pdf') else open(file_path).read() text = clean_text(text) chunks = split_by_type(text, doc_type) embeddings = model.encode(chunks, normalize_embeddings=True) conn = psycopg2.connect("dbname=docresearch") cur = conn.cursor() for chunk, emb in zip(chunks, embeddings): cur.execute( "INSERT INTO doc_embeddings (doc_id, chunk_text, embedding, metadata) VALUES (%s, %s, %s, %s)", (file_path, chunk, emb.tolist(), '{"type": "%s"}' % doc_type) ) conn.commit()normalize_embeddings=True很重要,归一化后用余弦距离和内积等价,查询时可以用内积索引,速度快一点。切分函数split_by_type根据文档类型走不同参数,前面讲过。
入库时一定要记录 chunk 在原文中的位置(页码、段落号),方便检索结果回溯。面试时如果被问“怎么让用户验证答案来源”,这就是答案。
3.3 检索链路搭建
检索链路是 DocResearch 的核心。我把它拆成三个函数:向量检索、关键词检索、融合排序。
from rank_bm25 import BM25Okapi def vector_search(query, top_k=10): q_emb = model.encode(query, normalize_embeddings=True) cur.execute( "SELECT chunk_text, 1 - (embedding <=> %s) AS score FROM doc_embeddings ORDER BY embedding <=> %s LIMIT %s", (q_emb.tolist(), q_emb.tolist(), top_k) ) return cur.fetchall() def bm25_search(query, corpus, top_k=10): tokenized = [doc.split() for doc in corpus] bm25 = BM25Okapi(tokenized) scores = bm25.get_scores(query.split()) ranked = sorted(zip(corpus, scores), key=lambda x: x[1], reverse=True) return ranked[:top_k]BM25 的语料需要预先加载到内存,文档量大时要注意内存占用。10 万条 chunk 大概占几百 MB,可以接受。再大就要考虑用 Elasticsearch 做关键词检索。
融合用前面写的 RRF 函数,把两路结果合并后取 top 5 送给 LLM。这里有个调优点:top_k 设多少。设太小,召回不够;设太大,LLM 上下文塞满噪声。我的经验是向量检索取 10,BM25 取 10,融合后取 5。如果 LLM 上下文窗口大,可以放宽到 8。
3.4 Agent 编排与报告生成
Agent 部分用 LangChain 的 AgentExecutor,工具集包括检索、摘要、计算。报告生成用结构化输出,让 LLM 按固定模板填内容。
from langchain.agents import AgentExecutor, create_react_agent from langchain_core.prompts import PromptTemplate prompt = PromptTemplate.from_template(""" 你是一个研究助手。根据用户主题,拆解子问题,逐个检索并总结。 可用工具:{tools} 工具名列表:{tool_names} 主题:{input} {agent_scratchpad} """) agent = create_react_agent(llm, tools, prompt) executor = AgentExecutor(agent=agent, tools=tools, max_iterations=8, verbose=True) result = executor.invoke({"input": "总结这份技术文档的核心架构"})max_iterations=8是前面提过的经验值。verbose=True在调试时打开,能看到 Agent 的思考过程,方便定位问题。上线时关掉,减少日志量。
报告生成阶段,我会让 LLM 输出 JSON,包含“核心结论”“支撑证据”“存疑点”三个字段。存疑点这个字段很重要,它让 LLM 主动标注哪些结论证据不足,避免用户被误导。面试时讲这个设计,面试官会觉得你在考虑 AI 输出的可靠性。
4. 常见问题与排查技巧实录
4.1 检索命中率低的排查路径
检索命中率低是 DocResearch 最常见的问题。排查要按顺序来,不要一上来就换模型。
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| chunk 质量 | 随机抽 10 个 chunk 人工阅读 | 切分过碎,语义不完整 |
| embedding 模型 | 用相同 query 对比不同模型 | 模型与语料语言不匹配 |
| 检索策略 | 对比纯向量和混合检索 | 专有名词检索不到 |
| 查询改写 | 看原始 query 是否口语化 | 口语 query 与文档表述差异大 |
| 索引参数 | 检查 HNSW 的 ef_search | 设太小导致召回不足 |
我遇到过一次典型问题:用户问“这个接口怎么鉴权”,检索结果全是接口列表,没有鉴权说明。原因是文档里写的是“认证方式”,和“鉴权”语义相近但向量模型没匹配上。解决办法是加一层查询改写,用 LLM 把口语 query 改写成文档可能使用的术语,再检索。改写后命中率从 40% 提升到了 75%。
查询改写会增加一次 LLM 调用,延迟增加 200 到 500 毫秒。如果对延迟敏感,可以只在检索结果置信度低时触发改写。
4.2 Agent 死循环与工具误调用
Agent 死循环的表现是反复调同一个工具,或者在不同工具之间来回跳。根因通常是工具描述有歧义,或者 Agent 的停止条件不明确。
我的处理办法有三个:第一,在 prompt 里明确写“如果连续两次检索结果相似,停止检索并输出当前结论”;第二,给每个工具加输入校验,格式不对直接返回错误提示,让 Agent 重新调;第三,设max_iterations硬上限,到上限强制输出。
工具误调用更隐蔽,表现是 Agent 调了不该调的工具。比如用户问“总结文档”,Agent 却去调计算工具。这通常是工具描述太宽泛导致的。把计算工具的描述改成“仅用于数学运算,不用于文本处理”,误调用率明显下降。
4.3 并发场景下的超时与限流
并发一高,最先出问题的是 LLM API 限流。表现是大量 429 错误,Agent 任务失败。解决办法是加信号量控制并发数,配合指数退避重试。
import asyncio from tenacity import retry, wait_exponential, stop_after_attempt @retry(wait=wait_exponential(multiplier=1, min=2, max=30), stop=stop_after_attempt(3)) async def call_llm_with_retry(prompt): async with semaphore: return await llm.ainvoke(prompt)指数退避的min=2表示首次重试等 2 秒,max=30表示最长等 30 秒。重试 3 次还失败就放弃,记录日志。这个配置我实测下来,在 API 限流的情况下能把任务成功率从 60% 拉到 95% 以上。
另一个并发坑是数据库连接池耗尽。pgvector 查询如果走同步连接,并发一高连接就不够用。换成asyncpg异步驱动,连接池设 20 到 50,基本能扛住百级并发。连接池大小不是越大越好,超过数据库的最大连接数反而会报错。
4.4 面试高频追问与应对思路
面试官追问通常集中在“为什么”和“如果”。比如“为什么用 pgvector 不用 Milvus”“如果文档量涨到千万级怎么办”“如果检索结果有冲突怎么处理”。
应对思路是:先答当前方案的理由,再答扩展方案,最后答权衡。比如千万级文档的问题,答“当前 pgvector 在百万级够用,千万级需要分库分表或换 Milvus,但换之前要先确认瓶颈是检索还是存储,如果是检索慢,可以先加缓存和预计算”。
冲突处理的问题,答“让 LLM 标注冲突点,输出时并列展示,不强行合并”。这个回答体现你尊重事实,不为了输出好看而掩盖矛盾。
面试时最忌讳的是不懂装懂。如果某个模块你没深入做过,直接说“这块我了解原理但没实操过,我的理解是……”,比硬编一个答案安全得多。
5. 我踩过的坑与最后几条实用建议
DocResearch 这类项目,坑大多不在代码本身,而在数据和边界条件。我印象最深的一次是文档解析阶段没做编码检测,一批 GBK 编码的文档入库后全是乱码,检索结果自然全废。后来加了chardet做编码检测,统一转 UTF-8 再入库,问题才解决。这个坑面试时讲出来,面试官会知道你处理过真实数据。
另一个坑是 embedding 模型换了之后忘了重建索引,检索结果和 query 完全不在一个语义空间,命中率跌到接近零。后来我在入库脚本里加了模型版本校验,模型变了就提示重建,避免类似问题。
最后分享一个提升检索质量的小技巧:给 chunk 加上下文前缀。比如在 chunk 前面拼上它所属的章节标题,检索时语义更完整。这个操作成本很低,但实测能把命中率提升 10% 到 15%。具体做法是在切分时记录章节路径,入库时拼到 chunk 文本前面。
面试 DocResearch 项目,核心不是把每个模块的名字背出来,而是能说出每个模块的设计决策和取舍。你踩过的坑、调过的参数、换过的方案,才是面试官真正想听的东西。把这篇内容里的思路内化成自己的语言,面试时自然能讲明白。