👔 面试官:请描述一下,使用 LangChain 构建一个文档问答系统,需要哪些关键技术组件?实现步骤是什么?

🙋‍♂️ 我:这个就是 RAG。先加载文档,然后切分,做 Embedding,存到向量数据库里。用户提问的时候,再检索相关片段,把片段和问题一起给大模型回答。

👔 面试官:流程听起来没错。那文档为什么要切?怎么切?chunkSize 怎么定?PDF 里的表格、标题、页码怎么处理?

🙋‍♂️ 我:切分主要是因为上下文窗口有限,也为了方便检索。一般会用 Text Splitter 按段落或者字符切块,保留一点 overlap,避免语义断掉。

👔 面试官:那 Top-K 是固定的吗?如果检索到的片段不相关怎么办?如果用户没有权限看某些文档,检索阶段怎么过滤?

🙋‍♂️ 我:Top-K 不能只拍脑袋定,要结合测试集调。权限可以靠 metadata filter,相关性不够可以加 rerank、混合检索,或者让模型先改写问题再检索。

👔 面试官:你终于开始讲工程了。LangChain 在这里到底负责什么?它是帮你解决 RAG 效果,还是帮你把流程接起来?

🙋‍♂️ 我:它主要是把 Loader、Splitter、Embedding、VectorStore、Retriever、Prompt、LLM 这些组件标准化,再用 Chain 或 Runnable 串起来。效果好不好,还是看文档清洗、切分策略、检索质量和评估。

👔 面试官:如果这个系统上线后答错了,你怎么判断是模型问题,还是检索问题?

🙋‍♂️ 我:先看相关文档有没有被召回。没召回就是检索问题,可能是切分、Embedding、Top-K、metadata 过滤出了问题。召回了但回答错了,再看 Prompt 约束、上下文噪声和模型生成。最好接 tracing,把 query、retrieved docs、prompt、output 都留下来。

聊到这里,这道题的重点就很清楚了。

这题不只看你会不会说 RAG,更看你能不能把一个文档问答系统讲成能落地、能排查、能评估的工程方案。

……

今天鸭鸭和大家分享一道 AI 大模型面试题。

【请描述使用LangChain构建一个文档问答系统的关键技术组件及实现步骤】

回答重点

用 LangChain 搭文档问答系统,核心就是 RAG 那套东西,分两大阶段:先把文档灌进向量库,再根据用户提问去检索相关片段喂给大模型。

1)文档索引阶段

第一步是把文档加载进来,LangChain 提供了一堆 Document Loader,PDF、Word、网页、Markdown 都能搞定。加载完不能直接用,因为文档太长塞不进大模型的上下文窗口,得用 Text Splitter 切成小块,一般每块 500-1000 token,块与块之间留点重叠避免语义断裂。切完之后调用 Embedding 模型把文本转成向量,存到 Chroma、Pinecone、Milvus 这类向量数据库里。

2)问答检索阶段

用户提问进来,先把问题也转成向量,拿去向量库里做相似度检索,捞出 Top-K 个最相关的文档片段。然后把这些片段和用户问题拼成一个 Prompt,扔给 LLM 生成答案。这一步 LangChain 封装了各种 Chain,最简单的 stuff chain 直接把所有检索结果塞进去,文档多的话可以用 map_reduce 或 refine 分批处理。

img

扩展知识

为什么要用 RAG 架构

大模型虽然知识面广,但有两个硬伤:一是知识有截止日期,训练数据之后的事它不知道;二是私有数据压根没见过,公司内部文档、业务知识它答不上来。直接 Fine-tune 成本太高,数据量少还容易过拟合。RAG 的思路就是“开卷考试”,把外部知识检索出来塞进 Prompt,让模型现场参考着答题,既省了微调成本,知识更新也方便。

实战代码示例

比如我们下面创建了一个基于《球状闪电》原文进行回答文档问答系统,代码如下:

项目是基于豆包的,大家可以自己去申请个 appkey 试一下:

import 'dotenv/config';
import { TextLoader } from "langchain/document_loaders/fs/text";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { Chroma } from "@langchain/community/vectorstores/chroma";

import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { MultiQueryRetriever } from 'langchain/retrievers/multi_query';
import { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression';
import { LLMChainExtractor } from 'langchain/retrievers/document_compressors/chain_extract';
import { ScoreThresholdRetriever } from 'langchain/retrievers/score_threshold';
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';

const chatModel = new ChatOpenAI({
  configuration:{
    baseURL: process.env.OPENAI_API_BASE_URL,
  },
   apiKey: process.env.OPENAI_API_KEY,
   modelName: "doubao-1-5-pro-256k-250115",
   maxRetries: 0,

});
const embeddings = new OpenAIEmbeddings({
  configuration: {
    baseURL: process.env.OPENAI_API_BASE_URL,
  },
  apiKey: process.env.OPENAI_API_KEY,
  modelName: "doubao-embedding-text-240715",
});

const loader = new TextLoader("data/qiu.txt");
const docs = await loader.load();

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 100,
});

const splitDocs = await splitter.splitDocuments(docs);

// 使用MemoryVectorStore向量存储
console.log(`开始处理 ${splitDocs.length} 个文档片段...`);

// 将所有文档加载到内存向量存储
const vectorStore = await MemoryVectorStore.fromDocuments(
  splitDocs,
  embeddings
);

console.log(`成功将 ${splitDocs.length} 个文档片段存储到内存向量存储中`);

const convertDocsToString = (documents: Document[]): string => {
  // @ts-ignore
    return documents.map((document) =>  document.pageContent).join("\n")
}

const retriever = vectorStore.asRetriever(2);



const contextRetriverChain = RunnableSequence.from([
    (input) => input.question,
    retriever,
    convertDocsToString
])

const TEMPLATE = `
你是一个熟读刘慈欣的《球状闪电》的终极原著党,精通根据作品原文详细解释和回答问题,你在回答时会引用作品原文。
并且回答时仅根据原文,尽可能回答用户问题,如果原文中没有相关内容,你可以回答“原文中没有相关内容”,

以下是原文中跟用户回答相关的内容:
{context}

现在,你需要基于原文,回答以下问题:
{question}`;

const prompt = ChatPromptTemplate.fromTemplate(
    TEMPLATE
);

const ragChain = RunnableSequence.from([
    {
        context: contextRetriverChain,
        question: (input) => input.question,
    },
    prompt,
    chatModel,
    new StringOutputParser()
])

const answer = await ragChain.invoke({
    question: "什么是球状闪电"
  });  
  console.log(answer);

img

检索效果优化

光靠基础的向量相似度检索,召回质量经常不够用。实际项目里有几个常用招数:

1)混合检索:向量检索擅长语义匹配,但关键词匹配有时更准,比如用户搜“API 文档”,你得把包含这几个字的段落优先捞出来。可以把 BM25 和向量检索结果做融合,LangChain 的 EnsembleRetriever 就干这事。

2)Rerank 重排序:向量检索捞出来的 Top-20 里可能有不少噪音,用 Cohere Rerank 或者 BGE-Reranker 这类模型做二次排序,把真正相关的顶上去。

3)Multi-Query 改写:用户的问题表述不一定精准,可以让 LLM 把原问题改写成 3-5 个不同角度的子问题,分别检索再合并结果,覆盖面更广。

4)Parent Document Retriever:检索时用小块提高精度,返回时把小块所属的大块整段拿出来,给模型更完整的上下文。

面试官追问

提问:向量检索的时候,相似度用余弦还是欧氏距离,有什么区别?

回答:大多数场景用余弦相似度就行,因为它只关注向量方向不管长度,对文本语义匹配更友好。欧氏距离会受向量模长影响,两段语义相近但长度差很多的文本,欧氏距离可能很大。不过如果向量已经做过归一化,两者效果差不多。Embedding 模型输出的向量一般都是归一化的,所以实际差别不大。

提问:文档切分的时候,chunkSize 和 chunkOverlap 怎么定?

详细解答可以看:RAG 中文档切割的 chunk_size 和 overlap 应该如何设置?

回答:chunkSize 主要看你的 Embedding 模型能吃多长,一般 512-1024 token 是比较常见的配置。太小了语义不完整,太大了检索精度下降。chunkOverlap 一般设成 chunkSize 的 10%-20%,目的是避免关键信息正好被切断。实际项目里建议多试几组参数,拿真实问题跑一遍看召回效果。

提问:如果文档量特别大,几百万条,向量数据库怎么选?

回答:几百万条的话 Milvus、Pinecone、Qdrant 这些专业向量库都扛得住。Milvus 开源可以自己部署,支持亿级向量,有 IVF、HNSW 这些索引算法可以调。Pinecone 是托管服务,省心但要花钱。如果数据量再大到十亿级别,得考虑分片部署,或者上 Elasticsearch 8.0 以后的版本,它原生支持向量检索,还能和传统全文检索混着用。

提问:RAG 系统上线后,怎么评估效果好不好?

回答:主要看三个指标:检索阶段看召回率和准确率,就是相关文档有没有被捞出来、捞出来的是不是真相关;生成阶段看答案的准确性和完整性,有没有幻觉、有没有漏掉关键信息。可以搞一套标注好的测试集,定期跑评估。线上还可以加用户反馈,点赞点踩收集起来做分析。RAGAS 这个开源框架专门干这事,能自动评估 Faithfulness、Answer Relevance 这些维度。

篇幅有限,更多 AI 大模型 相关面试题可以进入面试鸭进行查阅

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐