news 2026/10/26 19:49:50

LangChain 构建 GPT 智能体:检索增强知识库问答实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangChain 构建 GPT 智能体:检索增强知识库问答实战

做这套东西的动机来自一件很小的事:评审会上有人问某个接口字段在半年前为什么改过一次,会议室里六个人翻了七分钟文档,最后结论是“好像在哪份需求里提过”。这七分钟就是全部理由。用 LangChain 把自有知识库接成一个 GPT 智能体,本质上就是让模型在开口之前先去你自己的资料里翻一遍,拿着找到的原文回答,并标出出处。它解决的问题不偏向“模型不聪明”,更在于“模型不知道你们内部发生过什么”。适合三类人看:手上有一堆散落文档想做成问答入口的产品或运营;被派来做内部知识助手、又不想从零写检索逻辑的开发;以及已经跑通 demo、但效果始终不稳定的同行。下面这套东西是我实际跑起来的版本,包含架构取舍、参数怎么定、代码骨架,以及上线后真正让我加班的那些坑。

1. 为什么不选微调,而是搭一套检索智能体

1.1 三个现实约束把方案逼到了检索增强

先说我最初为什么考虑过微调,又为什么很快放弃。第一个约束是知识更新频率。内部文档每周都在改,需求文档改字段、规范改流程、FAQ 加条目,微调意味着每次改动都要重新构造数据集、重新训练、重新验证,这条链路在我这里根本转不动,人力成本比收益高一个数量级。检索增强的逻辑完全不同:文档改了,删掉旧 chunk、灌入新 chunk,十分钟搞定,模型本身完全不用动。

第二个约束是数据量级。几百份文档、几十万字的规模,放在微调场景里属于“太少”。这个量级喂进去,模型记不住具体事实,反而容易把原有的指令遵循能力带偏,出现答什么都往那几份文档上靠的情况。检索增强不要求模型记住任何东西,它只要求模型会读——而读,恰恰是现在的 GPT 系列模型最擅长的事。

第三个约束是可解释性。内部问答场景里,用户要的往往是“这句话出自哪份文档第几节”,一旦答错,得能追溯到原文去判断是资料错了还是检索错了。微调后的模型给不出这种溯源。检索增强天生带引用,因为答案的原料就是从某个 chunk 里取出来的,把它的元数据一并带出来就行。这三条加起来,方案基本没有第二种选择。

1.2 LangChain 在这条链路里到底承担了什么

很多人对 LangChain 有误解,觉得它是“让模型变聪明”的框架。它真正解决的问题是接口统一。文档加载、文本切分、向量化、向量库读写、检索、Prompt 组装、模型调用,这七八个环节每一个都有多种实现,LangChain 把它们抽象成统一的类型和协议,让你换向量库、换模型、换检索策略的时候只改一两行初始化,而不是重写整条链路。这一点在项目初期反复试错阶段特别值钱,我前后换过三种切分策略、两个向量库,改动量都在几十行以内。

第二个值是 LCEL 那套管道写法。用|把各个环节串成一条 Runnable,流式输出、异步调用、批量处理、失败重试这些都是框架层现成的,你不用自己写线程池和回调。对于一个需要边生成边往前端推字的前端体验来说,这个能力省了我至少两天。

但它不解决的事情同样要讲清楚:文档解析的质量、切分的粒度、召回的准确率、评估集的构建——这四件事没有任何框架能替你做。我见过不少人搭完 demo 之后觉得“效果就那样”,问题几乎都出在这四件事上,而不是框架选错了。LangChain 是把管道铺好的水泥,水管里流什么水、水干不干净,还得自己管。

1.3 整条链路拆开看

把这套东西摊平,一共九个环节。我做了一张表,把我实际的选择和理由写清楚,后面几章都是围绕这张表展开的。

环节职责常见选项我的选择与理由
加载把各种格式转成纯文本各类解析库、OCRMarkdown/文本优先,PDF 用文本层,扫描件单独走 OCR
清洗去噪、合并断行正则规则手写规则,针对自家文档格式定制
切分切成可检索单元定长、按结构、语义按结构切 + 定长兜底,500 字上下
向量化文本转向量多语言、中文优化、不同维度中文优化模型,1024 维
存储向量 + 元数据嵌入式库、服务端库十万级 chunk 用嵌入式库足够
检索召回候选向量、关键词、混合向量 + 关键词混合,召回 20
重排精排前 N交叉编码器加一层重排,取前 5
组装拼上下文和指令模板强制引用编号 + 拒答策略
生成出答案GPT 系列模型低温度,控制输出长度

链路本身不复杂,复杂的是每个环节的参数怎么定。接下来按顺序往下拆。

2. 知识库预处理:效果上限在这一步就定死了

2.1 文档加载与清洗,先把脏数据挡在门外

我踩过最深的坑全部在解析环节,跟模型一点关系都没有。第一版我图省事,把 PDF 直接丢给解析器,出来一堆重复的页眉页脚,每页顶部都是文档标题加版本号,底部是页码,这些内容被切进 chunk 之后,向量里混进了大量和语义无关的重复噪声,检索时经常把同一份文档的十几个 chunk 一起召回,因为它们的向量长得太像了。后来我加了一组清洗规则,专门处理这类问题。

具体规则有四条。第一,去掉形如“第 X 页 / 共 Y 页”和纯数字行。第二,统计全文出现频次超过页数一半的短行,判定为页眉页脚,整行删除。第三,中文文档的软换行要合并——PDF 里一句话被排版切成两行,中间那个换行不是段落分隔,直接留着会让切分器误判句子边界,得判断行尾是否以句号、问号、分号结尾,不是就与下一行拼接。第四,全角半角统一,中文标点统一,不然同一个词在向量空间里会被切成不同 token。

import re from collections import Counter from langchain_core.documents import Document def clean_text(text: str, page_count: int = 1) -> str: lines = text.split("\n") # 1. 去掉页码行 lines = [ln for ln in lines if not re.fullmatch(r"\s*第?\s*\d+\s*页?\s*(/\s*共?\s*\d+\s*页)?\s*", ln)] # 2. 高频短行判定为页眉页脚(出现次数超过页数一半) counter = Counter(ln.strip() for ln in lines if 0 < len(ln.strip()) <= 30) noise = {ln for ln, c in counter.items() if page_count > 1 and c > page_count * 0.5} lines = [ln for ln in lines if ln.strip() not in noise] # 3. 合并中文软换行 merged, buf = [], "" for ln in lines: buf = (buf + ln.strip()) if buf else ln.strip() if re.search(r"[。!?;:]$", buf) or not ln.strip(): merged.append(buf) buf = "" if buf: merged.append(buf) # 4. 全角半角与标点统一 text = "\n".join(merged) text = text.replace(",", ",").replace("(", "(").replace(")", ")") return re.sub(r"\n{3,}", "\n\n", text)

表格是另一个重灾区。PDF 里的表格解析出来往往是一列一列错位排列的,语义完全丢失。我的处理方式是:检测到表格区域,就整块转成 Markdown 表格,并且把整张表作为一个不可分割的 chunk,绝不按长度切开。一张参数对照表被切成两半之后,前半段有表头没数据,后半段有数据没表头,检索出来谁都读不懂。

2.2 切分策略:chunk_size 到底怎么定

切分粒度是这套系统里最需要拍脑袋、也最影响效果的参数。我的判断标准只有一条:一个 chunk 应该只承载一个能独立回答的问题点。如果用户问“报销标准是多少”,命中的 chunk 里应该是一段完整的报销规则,而不是半句话加一个无关的标题。

按这个标准,纯定长切分是不够的。我实际用的是“按结构优先 + 定长兜底”的组合。先用 Markdown 标题层级做第一轮切分,把每个三级标题下的内容作为一个语义块;如果这个块超过 800 字,再用递归切分器按段落、句子往下拆;如果拆完还有超长的(比如一大段没有标点的枚举),才用硬切。

chunk_size我定在 500 字,chunk_overlap定在 80 字。这两个数字是算出来的,不是拍出来的。中文语境下,500 个汉字大约对应 350 到 450 个 token,落在这个区间的原因是:太短(200 字以下)会让单个 chunk 缺少上下文,检索命中了但模型读不出完整意思;太长(1000 字以上)会让一个 chunk 里混进多个主题,向量变成一个“平均语义”,问什么都能沾点边,但都排不到第一。80 字的 overlap 大约是 15%,作用是防止一个关键句正好被切在边界上,前后两半都缺主谓。

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, length_function=len, separators=["\n### ", "\n## ", "\n\n", "\n", "。", ";", "!", "?", ",", ""], )

注意separators的顺序就是切分时的优先级,从前往后尝试。把标题层级放在最前面,是为了让切分尽量在章节边界断开;中文句号放在换行之后、逗号之前,是为了保证尽量在完整句子处断开,而不是在半句话处。这个列表里千万不能漏掉中文标点,只写英文的.!?会导致中文文档几乎切不动,最后全部走硬切。

这里补一个我后来加上的进阶技巧,叫父子切分。用来检索的是小块(300 字),但送给模型的是它所属的大块(1200 字)。好处是检索精度按小块算,不容易被无关内容稀释;模型看到的上下文按大块算,信息足够完整。LangChain 里可以用ParentDocumentRetriever直接实现,也可以自己在元数据里存一个parent_id,检索后回表捞父块。

2.3 元数据字段设计,很多人漏掉的关键一环

切完块直接入库是能跑通的,但跑到第二周你就会发现缺东西。元数据最起码要有这几项:

字段作用是否用于过滤
doc_id文档唯一标识,用于增量更新与删除否
chunk_idchunk 唯一标识,用于幂等写入否
source原始文件路径或链接,用于引用展示否
title文档标题,回答里显示“依据《XXX》”否
section所属章节标题,精确定位否
version文档版本号否
effective_date生效日期,处理“最新版本”类问题是
dept归属部门,做权限隔离是
permission密级标签,检索时强制过滤是

chunk_id我用doc_id + 序号拼出来。这东西看着无用,实际上是增量更新的关键:重跑入库时用同一个chunk_id覆盖写入,就不会出现同一份文档被灌进去两遍、检索时前五条全是重复内容的情况。dept和permission两个字段是权限控制的基础,检索时在向量库层面做元数据过滤,比检索完再在应用层筛要可靠得多——后者很容易漏掉,把不该给的内容塞进上下文。

3. 向量化与检索链路的落地细节

3.1 Embedding 选型与批量入库的三个注意点

选型上,中文场景优先选中文语料占比较高的模型,不要用纯英文模型硬套。我对比过,同一个问题在中英混合的技术文档上,中文优化模型的首条命中率明显更高。维度选 1024,理由是 768 维在长文档上区分度偶有不足,1536 维存储和检索开销又上来了,十万级 chunk 的场景下 1024 是一个平衡点。真正重要的是:建库和查询必须用同一个模型、同一个版本。我吃过一次亏,中途换了 embedding 模型忘了重建索引,结果查询向量和库里向量不在一个空间,检索结果完全是随机的,排查了两小时才反应过来。

第二个注意点是批量写入的节奏。一次性把几千个 chunk 全推给 embedding 接口,必然触发限流。我用的批大小是 64,每批之间加一个很短的间隔,并且对 429 做指数退避重试。

import time from langchain_core.documents import Document def batch_embed(chunks, embeddings, batch_size=64, max_retry=5): vectors = [] for i in range(0, len(chunks), batch_size): batch = chunks[i:i + batch_size] for attempt in range(max_retry): try: vectors.extend(embeddings.embed_documents([c.page_content for c in batch])) break except Exception as e: if attempt == max_retry - 1: raise time.sleep(2 ** attempt) # 1s, 2s, 4s, 8s time.sleep(0.2) return vectors

第三个注意点是归一化。如果向量库用余弦相似度,确认一下 embed 出来的向量是否已归一化,没有就手动除一下模长。有些库在没归一化时用内积近似余弦,长文本的向量模长更大,会天然获得更高的分数,导致长 chunk 被系统性地偏爱。

from langchain_community.vectorstores import Chroma vectorstore = Chroma( collection_name="internal_docs", embedding_function=embeddings, persist_directory="./vector_db", collection_metadata={"hnsw:space": "cosine"}, ) # 用稳定 id 幂等写入,重跑不会产生重复 vectorstore.add_documents( documents=chunks, ids=[c.metadata["chunk_id"] for c in chunks], )

3.2 检索参数:topK 不是越大越好

topK这个参数我调了很久。直觉上召回越多越好,实际不是。topK太大,噪声 chunk 会被一起塞进上下文,模型容易被无关信息带偏,还会挤占 token 预算;topK太小,正确答案所在的 chunk 根本进不了候选,后面再怎么重排也救不回来。我的做法是两段式:召回阶段宽一点(20 条),精排阶段窄一点(5 条)。20 条保证召回率,5 条保证送给模型的上下文干净。

相似度阈值这块,我建议不要设死一个固定数值。不同文档、不同问题类型的分数分布差异很大,设 0.75 的结果可能是某类问题全被拒答、另一类问题全是噪声。我实际用的是相对阈值——取前 20 条的分数,如果第一名分数显著高于其余(比如是均值的 1.3 倍以上),就只带前几条;如果分数都很扁平,说明检索没找到真正相关的内容,触发兜底回复。

MMR(最大边际相关性)这个选项值得开。它会在相关性和多样性之间做平衡,避免召回的都是同一段话的不同切法。lambda_mult设 0.6,偏相关性多一点的设置,因为内部问答里答案通常就是集中的一两条,不需要太强的多样性。

retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={ "k": 5, # 最终给模型的条数 "fetch_k": 20, # 先召回 20 条再做 MMR "lambda_mult": 0.6, "filter": {"dept": "product", "permission": {"$in": ["internal", "public"]}}, }, )

3.3 混合检索与重排,这两步是效果的分水岭

纯向量检索有个硬伤:对精确匹配不敏感。用户问“XJ-2200 的额定功率”,向量检索很可能召回一堆讲功率的段落,就是找不到带这个型号的那一段,因为型号这串字符在语义上没什么信息量。解决办法是加一路关键词检索(BM25 这类),把两路结果用 RRF(倒数排名融合)合并。RRF 不需要归一化两路分数,只看排名,实现简单而且稳。

def rrf_fuse(vec_ids, bm25_ids, k=60): score = {} for rank, doc_id in enumerate(vec_ids): score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(bm25_ids): score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(score, key=score.get, reverse=True)

重排是第二道分水岭。向量检索是双塔结构,查询和文档分别编码再算相似度,快但粗;交叉编码器把查询和文档拼在一起过一遍模型,精度高得多,代价是慢。所以标准做法是先用向量粗排到 20 条,再用交叉编码器精排,取前 5。实测下来,加这一层之后,首条命中率有明显提升,而延迟只增加了几十毫秒,这个投入产出比非常高。

3.4 Prompt 模板与引用溯源

Prompt 这块看起来简单,实际上决定了模型会不会乱编。我的模板包含四块内容:角色与硬约束、资料区、问题、输出格式要求。硬约束里最重要的一句是“只能依据下面提供的资料回答,资料中没有的信息直接说没有找到,不要用常识补充”。这句话必须写死,否则模型一旦觉得资料不完整,就会自己编一段听起来很合理的答案,这在内部问答里是致命的。

资料区我要求每条带编号,格式是[1] 《文档标题》- 章节名,后面跟正文。输出格式要求模型在句末标注引用编号,比如“报销额度为 5000 元 [1]”。这样前端可以把编号渲染成可点击的角标,用户点一下就能跳到原文。

还有一个容易被忽略的细节:上下文顺序。模型对长上下文中间部分的注意力会衰减,这是普遍现象。所以我会把重排后分数最高的那条放在最前面,第二高的放在最后面,把相对不重要的夹在中间。这个调整看起来微不足道,实际对“答案就在资料里但模型没读到”这类问题的改善相当明显。

from langchain_core.prompts import ChatPromptTemplate PROMPT = ChatPromptTemplate.from_messages([ ("system", "你是一个内部知识库问答助手。严格遵守以下规则:\n" "1. 只能依据【资料】中的内容回答,不得使用资料之外的知识补充。\n" "2. 资料中没有相关信息时,直接回答“资料中未找到相关内容”,不要猜测。\n" "3. 每句结论后必须标注来源编号,格式为 [编号]。\n" "4. 回答控制在 300 字以内,条理化表达,不要复述资料原文。\n" "5. 如果不同资料存在冲突,指出冲突并分别标注来源。"), ("human", "【资料】\n{context}\n\n【问题】\n{question}"), ]) def format_docs(docs): return "\n\n".join( f"[{i + 1}] 《{d.metadata.get('title', '未命名')}》- {d.metadata.get('section', '')}\n{d.page_content}" for i, d in enumerate(docs) )

4. 从检索链升级成智能体

4.1 Chain 能解决八成问题,剩下两成才需要 Agent

一开始我就上了 Agent,结果反而是退步。原因很直接:Agent 每一轮都要让模型决定“下一步调什么工具”,多一次模型调用就多一份延迟和成本,而且模型偶尔会陷入反复调同一个工具的循环。对于“查一个事实”这种单跳问题,一条固定的检索链又快又稳,根本不需要 Agent 的判断能力。

后来我做了个路由:先用一个很轻的规则加一次分类判断,把问题分成三类。第一类是事实查询,直接走检索链;第二类是需要多跳的,比如“A 文档里的接口和 B 文档里的字段定义是否一致”,这种需要分别检索两份资料再比对,交给 Agent;第三类是闲聊或无关问题,直接兜底,不浪费 token。这个改动之后,平均响应时间降了将近一半,成本也降下来了。

Agent 真正有意思的地方在于它能自己组合工具。我给它配了三个工具:知识库检索、按文档 ID 取全文、按标签列出相关文档。遇到“把最近三个月产品相关的规范文档都过一遍,看看有没有互相矛盾的”这种问题,它会先列文档、再逐份取全文、最后汇总,这套流程用固定链写死会很难看。

from langchain_core.tools import tool @tool def search_knowledge_base(query: str) -> str: """在内部知识库中做语义检索。适用于查询流程、规范、参数定义等事实性问题。 输入应当是一个完整的自然语言问题,不要只输入关键词。""" docs = retriever.invoke(query) return format_docs(docs) @tool def get_document(doc_id: str) -> str: """按文档 ID 获取完整正文。仅在需要通读全文或对比多份文档时使用。""" return load_full_text(doc_id) @tool def list_documents(tag: str) -> str: """按标签列出文档清单,返回标题、版本和生效日期。""" return query_doc_index(tag)

4.2 多轮对话里的指代消解,绕不开的一步

单轮问答跑通之后,第二个问题马上来了:用户会追问。“这个流程要多久?”“那它的审批人是谁?”第二个问题里的“它”如果不做处理,检索出去就是一句废话,向量库里根本找不到对应内容。这是多轮问答最典型的失效场景。

我的处理方式是在检索之前加一层查询改写:把最近几轮对话和当前问题一起交给模型,让它输出一个自包含的完整问题。这一步的 prompt 很短,成本很低,但对多轮体验的提升非常直接。

REWRITE_PROMPT = ChatPromptTemplate.from_messages([ ("system", "把用户的最新问题改写成一个不依赖上下文、可独立检索的完整问题。\n" "要求:补全所有代词和省略的主语;保留原问题的关键实体和限定词;\n" "如果原问题本身已经完整,原样返回,不要添加任何额外信息;只输出改写结果。"), ("human", "对话历史:\n{history}\n\n最新问题:{question}"), ])

记忆的存储上,我不建议把全部历史都塞进去。超过一定轮数之后,早期的对话对当前问题的价值很低,反而占 token。我的做法是保留最近 3 轮原文,更早的压缩成一段摘要。摘要也会在主对话结束时异步更新,不阻塞用户。

4.3 成本、延迟与失败兜底

算一笔账。假设知识库有 400 份文档,平均每份 5000 字,总计 200 万字。按 500 字切块、15% 重叠,大约产生 4600 个 chunk。建库阶段一次性 embedding 这 200 万字,成本在几十元这个量级,跑一次十几分钟,可以忽略。

真正持续产生成本的是查询阶段。每次提问大致包含:一次查询 embedding(几十个 token)、一次重排(本地模型,无 API 成本)、一次生成(上下文 5 条、约 2500 字,加上回答 300 字)。按中等规格模型的价格算,单次问答成本在几分钱。按每天 500 次查询算,一个月大概几十到一百多元。这个数字对内部工具来说完全可以接受,但如果开了 Agent、一次问题触发三轮工具调用,成本会翻三倍,这就是我前面坚持做路由的原因。

延迟方面,端到端拆开看:embedding 约 100 毫秒,向量检索几十毫秒,重排几十到一百毫秒,模型生成是主要耗时(首字延迟一两秒,完整生成几秒)。用户体感最关键的是首字时间,所以生成环节一定要开流式,先把“正在检索”“已找到 3 份相关资料”这类状态吐出来,等待感会小很多。

兜底逻辑我至少写了三层:检索结果为空或最高分低于阈值时,直接返回“未找到相关资料,建议换个说法或联系文档负责人”,不走生成;模型调用超时或报错时,降级成只返回检索到的原文片段,让用户自己看;连续失败时在前端提示稍后重试,同时把这次的 query 记进日志,作为后续优化切分和检索的素材。

5. 上线后踩到的问题与排查手册

5.1 召回类问题:明明有这段,就是搜不到

这是最高频的一类。我整理了几种典型表现和对应处置。

现象大概率原因处置动作
关键词精确匹配失败(型号、编号)纯向量检索对字符级信息不敏感加 BM25 混合检索,RRF 融合
同义表述搜不到用户用词与文档用词不一致查询改写补充同义词,或用多语言 embedding
命中的全是同一份文档该文档 chunk 语义过于相似开 MMR,或按 doc_id 去重后取
表格里的数据搜不到表格被切碎或解析错位整表转 Markdown,作为独立 chunk 入库
旧版本内容盖过新版本元数据缺版本或未过滤加version/effective_date,检索时按时间过滤
长文档的后半部分永远排不上chunk 长度差异过大导致分数偏移统一 chunk 长度,或改用父子切分

其中第一条我想多说两句。向量检索本质上是语义相似度,它对“XJ-2200”和“XJ-2100”这种只差一个数字的字符串几乎没有区分能力,两个向量可能非常接近。这在内网文档里是灾难性的,因为大量型号、批次号、错误码都属于这类。关键词检索只认字面,恰好补上这块短板,所以混合检索在技术文档场景里应该算必备,不算可选。

5.2 生成类问题:检索对了,答案还是错的

检索到的资料是对的,但模型回答跑偏,这类问题更难查,因为你要去分辨到底是上下文有问题还是 Prompt 有问题。我遇到过四种。

第一种是上下文太长导致信息淹没。5 条 chunk 拼起来两三千字,模型抓住了开头和结尾,漏了中间。处置办法就是前面说的排序策略,把最相关的放两端。

第二种是资料之间互相冲突。同一个规范在新旧两版里说法不一样,模型各取一半,拼出一个既不符合旧版也不符合新版的答案。我的处置是在 Prompt 里明确要求“发现冲突时指出冲突并分别标注来源”,同时在入库时给文档打上生效日期,检索时优先返回最新版本,旧版本作为补充。

第三种是模型无视约束,用常识补全。资料里明明没写具体数字,它给你编一个看起来很像的。这种情况靠 Prompt 只能压到一定程度,最有效的办法是在生成之后加一个轻量校验:提取回答里引用的编号,检查每个编号对应的 chunk 里是否真的包含回答中的关键数字和实体。对不上就打回重生成,或者直接降级成原文片段展示。这个校验我写得比较糙,但拦住问题的比例不低。

第四种是拒答过于激进。资料里明明有相关内容,但表述方式和问题差异较大,模型保守地说“未找到”。这种多半是 Prompt 里“不要用常识补充”这条约束写得太硬。我把措辞调成了“资料中没有直接答案时,可以基于资料中相关段落做有限推论,但要明确标注这是推论”,误拒率明显下降。

5.3 工程类问题:更新、并发与权限

增量更新这块,必须实现“先删后加”,而且删除要按doc_id批量删。只加不删的结果是旧 chunk 一直躺在库里,用户搜出来的是三个月前的过期内容,而这份内容当前版本里已经删掉了。我现在的流程是:文件修改时间变化触发同步,先按 doc_id 删除该文档所有 chunk,再重新切分入库,最后更新索引表里的版本记录。

并发方面,主要问题是限流。内部工具早上九点到十点是使用高峰,几十个人同时问,embedding 和生成接口都会撞上速率限制。我在调用层加了信号量和队列,超过并发数的请求排队等待,同时对 429 做退避重试。还有一个容易忽略的点是向量库的并发读——嵌入式向量库在多进程同时写的时候容易出问题,所以入库任务必须串行,不能和应用服务共享同一个写入口。

权限这块我的做法是在检索层做硬过滤,而不是在应用层筛结果。permission字段作为元数据过滤条件传进检索器,不满足条件的 chunk 在召回阶段就被排除了,压根不会进入上下文。这样做的好处是不会因为代码逻辑疏漏而泄露,坏处是过滤条件写错的话会全军覆没,所以上线前要用不同权限的测试账号各跑一遍。

还有一个隐蔽的坑:文档解析失败时不要静默跳过。我第一版就是这么干的,某次一批 PDF 因为编码问题全部解析成空字符串,结果是索引里悄悄少了三十份文档,谁都没发现,直到有人问那几份文档里的内容一直答不上来。现在解析失败会写进一张失败清单表,并在管理页面上标红,定期清理。

6. 上线前值得花时间做的评估

评估这件事,我一开始也觉得麻烦,直到连续被两个“感觉效果变差了”的模糊反馈搞到无从下手,才老老实实建了一套。做法不复杂:从真实提问里攒 60 到 100 条,每条标注好“应该命中哪份文档的哪一节”和“答案要点是什么”。这活儿手动做一遍大概两三个小时,但它是后面所有优化的基准。

有了这个集合,每次改动切分参数、换 embedding 模型、调 topK,跑一遍就能看到召回命中率的变化,不用靠感觉。我关注三个指标:文档级命中率(正确答案所在文档是否出现在前 5 条里)、片段级命中率(答案所在的 chunk 是否被召回)、答案正确率(人工判断或抽样判断)。前两个是检索层的,第三个是生成层的,分开看才能定位问题出在哪一层。我印象很深的一次是换了 embedding 模型之后答案正确率掉了,但文档级命中率没变,查下去发现是片段级命中率降了——新模型把大段相似内容压得更平,导致真正的答案 chunk 排不到前面。如果只看最终答案,这个问题根本定位不出来。

参数调整上还有一个经验:每次只改一个变量。同时改 chunk 大小和 topK,效果变好了你也不知道是哪个起的作用,下次遇到问题也没法回滚到确定的配置。

最后分享一点我自己的体会。这套东西真正难的地方不在用 LangChain 把管道搭起来,那部分一两天就能跑通;难在文档清洗规则要一条一条加、切分粒度要一遍一遍试、评估集要一条一条标,这些都是笨功夫,也是效果差距的全部来源。我现在的习惯是每上线一个新领域的文档,先花半小时翻二十份原始文件,看看它们的格式、结构、有没有大量表格和扫描件,再决定这一批走什么解析路径。这一步看着慢,实际上比事后排查召回问题快得多。

另外一个我很推荐的扩展方向是给智能体加上“找不到就去问人”的能力。当检索置信度低于阈值时,它不硬答,而是把问题整理成一条待办,附上最接近的三份文档作为参考,推给对应的文档负责人。这个功能上线之后,知识库的更新速度明显变快了——因为大家终于知道哪些内容是真正被问过、而且答不上来的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 19:48:02

RAG从原理到实战:检索增强生成全链路拆解与优化指南

RAG&#xff08;检索增强生成&#xff09;这两年从学术圈火到工业界&#xff0c;几乎成了大模型应用落地的标配方案。但我在社区里观察到一个很普遍的现象&#xff1a;很多人一上来就急着搭向量库、调API、拼Prompt&#xff0c;结果系统跑起来要么答非所问&#xff0c;要么检索…

作者头像 李华
网站建设 2026/10/26 19:47:45

Redis Hash编码转换与内存优化实战

我至今记得那次线上排查&#xff1a;一个服务的内存曲线在凌晨三点突然抬了个头&#xff0c;之后一路缓慢上扬&#xff0c;重启就好&#xff0c;第二天又开始。查了半天&#xff0c;最后落在一个只写了二十来行代码的购物车模块上——问题不在业务逻辑&#xff0c;而在 Redis H…

作者头像 李华
网站建设 2026/10/26 19:47:41

电商价格预测系统:用AI帮你省钱的实战指南

1. 项目概述&#xff1a;用价格波动预测帮用户省钱每次网购最纠结的就是"现在买还是再等等"——买贵了心疼&#xff0c;等降价又怕错过。这个项目正是为了解决这个痛点&#xff1a;通过分析商品历史价格数据&#xff0c;预测未来最低价出现的时间点&#xff0c;在最佳…

作者头像 李华
网站建设 2026/10/26 19:47:32

AI编程智能体入门:从辅助补全到独立干活的完整指南

现在的程序员圈子里&#xff0c;“AI 编程智能体”这个词已经被聊烂了。但实话实说&#xff0c;大多数人聊的还是Copilot那套“你在前面写、它在后面补”的辅助模式&#xff0c;真正把AI当作一个能独立接活、自己拆任务、自己跑工具、自己检查结果的“智能体”来用的&#xff0…

作者头像 李华
网站建设 2026/10/26 19:46:26

Hadoop入门指南:从基础到实战的分布式数据处理

1. Hadoop入门&#xff1a;为什么每个大数据工程师都要从这里开始 第一次接触Hadoop时&#xff0c;我正面临一个棘手的问题&#xff1a;公司需要处理日均10TB的日志数据&#xff0c;传统数据库已经不堪重负。当时尝试用MySQL分库分表方案&#xff0c;不仅维护成本高&#xff0c…

作者头像 李华
网站建设 2026/10/26 19:46:20

手写RTSP服务器:协议状态机与生产级RTP打包实战

1. 为什么非得自己写一个RTSP服务器&#xff1f;——从协议本质看“造轮子”的真实价值很多人看到“从零开始写RTSP服务器”这个标题&#xff0c;第一反应是&#xff1a;FFmpeg、GStreamer、Live555不是现成的吗&#xff1f;再不济用Node.js配一个node-rtsp-stream不就完事了&a…

作者头像 李华